mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-03 03:46:37 +01:00 
			
		
		
		
	Compare commits
	
		
			32 Commits
		
	
	
		
			v0.5.4-bet
			...
			v0.6.0-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					72df0d8861 | ||
| 
						 | 
					9910aebf45 | ||
| 
						 | 
					f9f8ecb2b1 | ||
| 
						 | 
					438f7c5b0b | ||
| 
						 | 
					4b1d1aba74 | ||
| 
						 | 
					6dea73cfe2 | ||
| 
						 | 
					58f5d0cf6e | ||
| 
						 | 
					7b77e40514 | ||
| 
						 | 
					660908c54b | ||
| 
						 | 
					e970564036 | ||
| 
						 | 
					b3038487f8 | ||
| 
						 | 
					cac98392a6 | ||
| 
						 | 
					dbd28377e3 | ||
| 
						 | 
					c76e4faf5d | ||
| 
						 | 
					e011b9ae63 | ||
| 
						 | 
					7c74c77a2c | ||
| 
						 | 
					c2a2f195aa | ||
| 
						 | 
					85d32c66f2 | ||
| 
						 | 
					4e70cebf70 | ||
| 
						 | 
					214d2e7659 | ||
| 
						 | 
					f380bb7f65 | ||
| 
						 | 
					0a9a032daa | ||
| 
						 | 
					23a2b58b24 | ||
| 
						 | 
					aee64b2522 | ||
| 
						 | 
					02e07ec03a | ||
| 
						 | 
					3d2dc8e699 | ||
| 
						 | 
					c84e15c9be | ||
| 
						 | 
					e18d0b9fd4 | ||
| 
						 | 
					52817504d1 | ||
| 
						 | 
					a3b31fab54 | ||
| 
						 | 
					bc4aa3e40a | ||
| 
						 | 
					873ea67e9c | 
@@ -10,6 +10,7 @@ Trilium Notes is a hierarchical note taking application. Picture tells a thousan
 | 
			
		||||
* WYSIWYG (What You See Is What You Get) editing
 | 
			
		||||
* Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation)
 | 
			
		||||
* Seamless note versioning
 | 
			
		||||
* Note attributes can be used to tag/label notes as an alternative note organization and querying
 | 
			
		||||
* Can be deployed as web application and / or desktop application with offline access (electron based)
 | 
			
		||||
* [Synchronization with](https://github.com/zadam/trilium/wiki/Synchronization) self-hosted sync server
 | 
			
		||||
* Strong [note encryption](https://github.com/zadam/trilium/wiki/Protected-notes)
 | 
			
		||||
@@ -34,6 +35,7 @@ List of documentation pages:
 | 
			
		||||
* [Installation as webapp](https://github.com/zadam/trilium/wiki/Installation-as-webapp)
 | 
			
		||||
* [Note navigation](https://github.com/zadam/trilium/wiki/Note-navigation)
 | 
			
		||||
* [Tree manipulation](https://github.com/zadam/trilium/wiki/Tree-manipulation)
 | 
			
		||||
* [Attributes](https://github.com/zadam/trilium/wiki/Attributes)
 | 
			
		||||
* [Links](https://github.com/zadam/trilium/wiki/Links)
 | 
			
		||||
* [Cloning notes](https://github.com/zadam/trilium/wiki/Cloning-notes)
 | 
			
		||||
* [Protected notes](https://github.com/zadam/trilium/wiki/Protected-notes)
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1 @@
 | 
			
		||||
DROP INDEX IDX_attributes_noteId_name;
 | 
			
		||||
							
								
								
									
										1
									
								
								db/migrations/0073__add_isDeleted_to_attributes.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								db/migrations/0073__add_isDeleted_to_attributes.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
ALTER TABLE attributes ADD COLUMN isDeleted INT NOT NULL DEFAULT 0;
 | 
			
		||||
							
								
								
									
										1
									
								
								db/migrations/0074__add_position_to_attribute.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								db/migrations/0074__add_position_to_attribute.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
ALTER TABLE attributes ADD COLUMN position INT NOT NULL DEFAULT 0;
 | 
			
		||||
							
								
								
									
										7
									
								
								db/migrations/0075__add_api_token.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								db/migrations/0075__add_api_token.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
CREATE TABLE IF NOT EXISTS "api_tokens"
 | 
			
		||||
(
 | 
			
		||||
  apiTokenId TEXT PRIMARY KEY NOT NULL,
 | 
			
		||||
  token TEXT NOT NULL,
 | 
			
		||||
  dateCreated TEXT NOT NULL,
 | 
			
		||||
  isDeleted INT NOT NULL DEFAULT 0
 | 
			
		||||
);
 | 
			
		||||
@@ -85,8 +85,10 @@ CREATE TABLE IF NOT EXISTS "attributes"
 | 
			
		||||
  noteId TEXT NOT NULL,
 | 
			
		||||
  name TEXT NOT NULL,
 | 
			
		||||
  value TEXT,
 | 
			
		||||
  position INT NOT NULL DEFAULT 0,
 | 
			
		||||
  dateCreated TEXT NOT NULL,
 | 
			
		||||
  dateModified TEXT NOT NULL
 | 
			
		||||
  dateModified TEXT NOT NULL,
 | 
			
		||||
  isDeleted INT NOT NULL
 | 
			
		||||
);
 | 
			
		||||
CREATE UNIQUE INDEX `IDX_sync_entityName_entityId` ON `sync` (
 | 
			
		||||
  `entityName`,
 | 
			
		||||
@@ -118,4 +120,11 @@ CREATE INDEX IDX_note_images_noteId ON note_images (noteId);
 | 
			
		||||
CREATE INDEX IDX_note_images_imageId ON note_images (imageId);
 | 
			
		||||
CREATE INDEX IDX_note_images_noteId_imageId ON note_images (noteId, imageId);
 | 
			
		||||
CREATE INDEX IDX_attributes_noteId ON attributes (noteId);
 | 
			
		||||
CREATE UNIQUE INDEX IDX_attributes_noteId_name ON attributes (noteId, name);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE IF NOT EXISTS "api_tokens"
 | 
			
		||||
(
 | 
			
		||||
  apiTokenId TEXT PRIMARY KEY NOT NULL,
 | 
			
		||||
  token TEXT NOT NULL,
 | 
			
		||||
  dateCreated TEXT NOT NULL,
 | 
			
		||||
  isDeleted INT NOT NULL DEFAULT 0
 | 
			
		||||
);
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "trilium",
 | 
			
		||||
  "description": "Trilium Notes",
 | 
			
		||||
  "version": "0.5.4-beta",
 | 
			
		||||
  "version": "0.6.0-beta",
 | 
			
		||||
  "license": "AGPL-3.0-only",
 | 
			
		||||
  "main": "electron.js",
 | 
			
		||||
  "repository": {
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,7 @@ class Note extends Entity {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAttributes() {
 | 
			
		||||
        return this.repository.getEntities("SELECT * FROM attributes WHERE noteId = ?", [this.noteId]);
 | 
			
		||||
        return this.repository.getEntities("SELECT * FROM attributes WHERE noteId = ? AND isDeleted = 0", [this.noteId]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAttribute(name) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,18 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const addLink = (function() {
 | 
			
		||||
    const dialogEl = $("#add-link-dialog");
 | 
			
		||||
    const formEl = $("#add-link-form");
 | 
			
		||||
    const autoCompleteEl = $("#note-autocomplete");
 | 
			
		||||
    const linkTitleEl = $("#link-title");
 | 
			
		||||
    const clonePrefixEl = $("#clone-prefix");
 | 
			
		||||
    const linkTitleFormGroup = $("#add-link-title-form-group");
 | 
			
		||||
    const prefixFormGroup = $("#add-link-prefix-form-group");
 | 
			
		||||
    const linkTypeEls = $("input[name='add-link-type']");
 | 
			
		||||
    const linkTypeHtmlEl = linkTypeEls.filter('input[value="html"]');
 | 
			
		||||
    const $dialog = $("#add-link-dialog");
 | 
			
		||||
    const $form = $("#add-link-form");
 | 
			
		||||
    const $autoComplete = $("#note-autocomplete");
 | 
			
		||||
    const $linkTitle = $("#link-title");
 | 
			
		||||
    const $clonePrefix = $("#clone-prefix");
 | 
			
		||||
    const $linkTitleFormGroup = $("#add-link-title-form-group");
 | 
			
		||||
    const $prefixFormGroup = $("#add-link-prefix-form-group");
 | 
			
		||||
    const $linkTypes = $("input[name='add-link-type']");
 | 
			
		||||
    const $linkTypeHtml = $linkTypes.filter('input[value="html"]');
 | 
			
		||||
 | 
			
		||||
    function setLinkType(linkType) {
 | 
			
		||||
        linkTypeEls.each(function () {
 | 
			
		||||
        $linkTypes.each(function () {
 | 
			
		||||
            $(this).prop('checked', $(this).val() === linkType);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
@@ -20,39 +20,39 @@ const addLink = (function() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        if (noteEditor.getCurrentNoteType() === 'text') {
 | 
			
		||||
            linkTypeHtmlEl.prop('disabled', false);
 | 
			
		||||
            $linkTypeHtml.prop('disabled', false);
 | 
			
		||||
 | 
			
		||||
            setLinkType('html');
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            linkTypeHtmlEl.prop('disabled', true);
 | 
			
		||||
            $linkTypeHtml.prop('disabled', true);
 | 
			
		||||
 | 
			
		||||
            setLinkType('selected-to-current');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 700
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        autoCompleteEl.val('').focus();
 | 
			
		||||
        clonePrefixEl.val('');
 | 
			
		||||
        linkTitleEl.val('');
 | 
			
		||||
        $autoComplete.val('').focus();
 | 
			
		||||
        $clonePrefix.val('');
 | 
			
		||||
        $linkTitle.val('');
 | 
			
		||||
 | 
			
		||||
        function setDefaultLinkTitle(noteId) {
 | 
			
		||||
            const noteTitle = noteTree.getNoteTitle(noteId);
 | 
			
		||||
 | 
			
		||||
            linkTitleEl.val(noteTitle);
 | 
			
		||||
            $linkTitle.val(noteTitle);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        autoCompleteEl.autocomplete({
 | 
			
		||||
        $autoComplete.autocomplete({
 | 
			
		||||
            source: noteTree.getAutocompleteItems(),
 | 
			
		||||
            minLength: 0,
 | 
			
		||||
            change: () => {
 | 
			
		||||
                const val = autoCompleteEl.val();
 | 
			
		||||
                const val = $autoComplete.val();
 | 
			
		||||
                const notePath = link.getNodePathFromLabel(val);
 | 
			
		||||
                if (!notePath) {
 | 
			
		||||
                    return;
 | 
			
		||||
@@ -75,8 +75,8 @@ const addLink = (function() {
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    formEl.submit(() => {
 | 
			
		||||
        const value = autoCompleteEl.val();
 | 
			
		||||
    $form.submit(() => {
 | 
			
		||||
        const value = $autoComplete.val();
 | 
			
		||||
 | 
			
		||||
        const notePath = link.getNodePathFromLabel(value);
 | 
			
		||||
        const noteId = treeUtils.getNoteIdFromNotePath(notePath);
 | 
			
		||||
@@ -85,25 +85,25 @@ const addLink = (function() {
 | 
			
		||||
            const linkType = $("input[name='add-link-type']:checked").val();
 | 
			
		||||
 | 
			
		||||
            if (linkType === 'html') {
 | 
			
		||||
                const linkTitle = linkTitleEl.val();
 | 
			
		||||
                const linkTitle = $linkTitle.val();
 | 
			
		||||
 | 
			
		||||
                dialogEl.dialog("close");
 | 
			
		||||
                $dialog.dialog("close");
 | 
			
		||||
 | 
			
		||||
                link.addLinkToEditor(linkTitle, '#' + notePath);
 | 
			
		||||
            }
 | 
			
		||||
            else if (linkType === 'selected-to-current') {
 | 
			
		||||
                const prefix = clonePrefixEl.val();
 | 
			
		||||
                const prefix = $clonePrefix.val();
 | 
			
		||||
 | 
			
		||||
                cloning.cloneNoteTo(noteId, noteEditor.getCurrentNoteId(), prefix);
 | 
			
		||||
 | 
			
		||||
                dialogEl.dialog("close");
 | 
			
		||||
                $dialog.dialog("close");
 | 
			
		||||
            }
 | 
			
		||||
            else if (linkType === 'current-to-selected') {
 | 
			
		||||
                const prefix = clonePrefixEl.val();
 | 
			
		||||
                const prefix = $clonePrefix.val();
 | 
			
		||||
 | 
			
		||||
                cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), noteId, prefix);
 | 
			
		||||
 | 
			
		||||
                dialogEl.dialog("close");
 | 
			
		||||
                $dialog.dialog("close");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -111,19 +111,19 @@ const addLink = (function() {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    function linkTypeChanged() {
 | 
			
		||||
        const value = linkTypeEls.filter(":checked").val();
 | 
			
		||||
        const value = $linkTypes.filter(":checked").val();
 | 
			
		||||
 | 
			
		||||
        if (value === 'html') {
 | 
			
		||||
            linkTitleFormGroup.show();
 | 
			
		||||
            prefixFormGroup.hide();
 | 
			
		||||
            $linkTitleFormGroup.show();
 | 
			
		||||
            $prefixFormGroup.hide();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            linkTitleFormGroup.hide();
 | 
			
		||||
            prefixFormGroup.show();
 | 
			
		||||
            $linkTitleFormGroup.hide();
 | 
			
		||||
            $prefixFormGroup.show();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    linkTypeEls.change(linkTypeChanged);
 | 
			
		||||
    $linkTypes.change(linkTypeChanged);
 | 
			
		||||
 | 
			
		||||
    $(document).bind('keydown', 'ctrl+l', e => {
 | 
			
		||||
        showDialog();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,12 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const attributesDialog = (function() {
 | 
			
		||||
    const dialogEl = $("#attributes-dialog");
 | 
			
		||||
    const $dialog = $("#attributes-dialog");
 | 
			
		||||
    const $saveAttributesButton = $("#save-attributes-button");
 | 
			
		||||
    const $attributesBody = $('#attributes-table tbody');
 | 
			
		||||
 | 
			
		||||
    const attributesModel = new AttributesModel();
 | 
			
		||||
    let attributeNames = [];
 | 
			
		||||
 | 
			
		||||
    function AttributesModel() {
 | 
			
		||||
        const self = this;
 | 
			
		||||
@@ -14,38 +18,148 @@ const attributesDialog = (function() {
 | 
			
		||||
 | 
			
		||||
            const attributes = await server.get('notes/' + noteId + '/attributes');
 | 
			
		||||
 | 
			
		||||
            this.attributes(attributes);
 | 
			
		||||
        };
 | 
			
		||||
            self.attributes(attributes.map(ko.observable));
 | 
			
		||||
 | 
			
		||||
        this.addNewRow = function() {
 | 
			
		||||
            self.attributes.push({
 | 
			
		||||
                attributeId: '',
 | 
			
		||||
                name: '',
 | 
			
		||||
                value: ''
 | 
			
		||||
            addLastEmptyRow();
 | 
			
		||||
 | 
			
		||||
            attributeNames = await server.get('attributes/names');
 | 
			
		||||
 | 
			
		||||
            // attribute might not be rendered immediatelly so could not focus
 | 
			
		||||
            setTimeout(() => $(".attribute-name:last").focus(), 100);
 | 
			
		||||
 | 
			
		||||
            $attributesBody.sortable({
 | 
			
		||||
                handle: '.handle',
 | 
			
		||||
                containment: $attributesBody,
 | 
			
		||||
                update: function() {
 | 
			
		||||
                    let position = 0;
 | 
			
		||||
 | 
			
		||||
                    // we need to update positions by searching in the DOM, because order of the
 | 
			
		||||
                    // attributes in the viewmodel (self.attributes()) stays the same
 | 
			
		||||
                    $attributesBody.find('input[name="position"]').each(function() {
 | 
			
		||||
                        const attr = self.getTargetAttribute(this);
 | 
			
		||||
 | 
			
		||||
                        attr().position = position++;
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        this.deleteAttribute = function(data, event) {
 | 
			
		||||
            const attr = self.getTargetAttribute(event.target);
 | 
			
		||||
            const attrData = attr();
 | 
			
		||||
 | 
			
		||||
            if (attrData) {
 | 
			
		||||
                attrData.isDeleted = 1;
 | 
			
		||||
 | 
			
		||||
                attr(attrData);
 | 
			
		||||
 | 
			
		||||
                addLastEmptyRow();
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        function isValid() {
 | 
			
		||||
            for (let attrs = self.attributes(), i = 0; i < attrs.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.
 | 
			
		||||
            $saveAttributesButton.focus();
 | 
			
		||||
 | 
			
		||||
            if (!isValid()) {
 | 
			
		||||
                alert("Please fix all validation errors and try saving again.");
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const noteId = noteEditor.getCurrentNoteId();
 | 
			
		||||
 | 
			
		||||
            const attributes = await server.put('notes/' + noteId + '/attributes', this.attributes());
 | 
			
		||||
            const attributesToSave = self.attributes()
 | 
			
		||||
                .map(attr => attr())
 | 
			
		||||
                .filter(attr => attr.attributeId !== "" || attr.name !== "");
 | 
			
		||||
 | 
			
		||||
            self.attributes(attributes);
 | 
			
		||||
            const attributes = await server.put('notes/' + noteId + '/attributes', attributesToSave);
 | 
			
		||||
 | 
			
		||||
            self.attributes(attributes.map(ko.observable));
 | 
			
		||||
 | 
			
		||||
            addLastEmptyRow();
 | 
			
		||||
 | 
			
		||||
            showMessage("Attributes have been saved.");
 | 
			
		||||
 | 
			
		||||
            noteEditor.loadAttributeList();
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        function addLastEmptyRow() {
 | 
			
		||||
            const attrs = self.attributes().filter(attr => attr().isDeleted === 0);
 | 
			
		||||
            const last = attrs.length === 0 ? null : attrs[attrs.length - 1]();
 | 
			
		||||
 | 
			
		||||
            if (!last || last.name.trim() !== "" || last.value !== "") {
 | 
			
		||||
                self.attributes.push(ko.observable({
 | 
			
		||||
                    attributeId: '',
 | 
			
		||||
                    name: '',
 | 
			
		||||
                    value: '',
 | 
			
		||||
                    isDeleted: 0,
 | 
			
		||||
                    position: 0
 | 
			
		||||
                }));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.attributeChanged = function (data, event) {
 | 
			
		||||
            addLastEmptyRow();
 | 
			
		||||
 | 
			
		||||
            const attr = self.getTargetAttribute(event.target);
 | 
			
		||||
 | 
			
		||||
            attr.valueHasMutated();
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        this.isNotUnique = function(index) {
 | 
			
		||||
            const cur = self.attributes()[index]();
 | 
			
		||||
 | 
			
		||||
            if (cur.name.trim() === "") {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (let attrs = self.attributes(), i = 0; i < attrs.length; i++) {
 | 
			
		||||
                const attr = attrs[i]();
 | 
			
		||||
 | 
			
		||||
                if (index !== i && cur.name === attr.name) {
 | 
			
		||||
                    return true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        this.isEmptyName = function(index) {
 | 
			
		||||
            const cur = self.attributes()[index]();
 | 
			
		||||
 | 
			
		||||
            return cur.name.trim() === "" && (cur.attributeId !== "" || cur.value !== "");
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        this.getTargetAttribute = function(target) {
 | 
			
		||||
            const context = ko.contextFor(target);
 | 
			
		||||
            const index = context.$index();
 | 
			
		||||
 | 
			
		||||
            return self.attributes()[index];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        await attributesModel.loadAttributes();
 | 
			
		||||
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 800,
 | 
			
		||||
            height: 500
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        attributesModel.loadAttributes();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $(document).bind('keydown', 'alt+a', e => {
 | 
			
		||||
@@ -56,6 +170,54 @@ const attributesDialog = (function() {
 | 
			
		||||
 | 
			
		||||
    ko.applyBindings(attributesModel, document.getElementById('attributes-dialog'));
 | 
			
		||||
 | 
			
		||||
    $(document).on('focus', '.attribute-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 init.js
 | 
			
		||||
                source: attributeNames.map(attr => {
 | 
			
		||||
                    return {
 | 
			
		||||
                        label: attr,
 | 
			
		||||
                        value: attr
 | 
			
		||||
                    }
 | 
			
		||||
                }),
 | 
			
		||||
                minLength: 0
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $(this).autocomplete("search", $(this).val());
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $(document).on('focus', '.attribute-value', async function (e) {
 | 
			
		||||
        if (!$(this).hasClass("ui-autocomplete-input")) {
 | 
			
		||||
            const attributeName = $(this).parent().parent().find('.attribute-name').val();
 | 
			
		||||
 | 
			
		||||
            if (attributeName.trim() === "") {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const attributeValues = await server.get('attributes/values/' + encodeURIComponent(attributeName));
 | 
			
		||||
 | 
			
		||||
            if (attributeValues.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 init.js
 | 
			
		||||
                source: attributeValues.map(attr => {
 | 
			
		||||
                    return {
 | 
			
		||||
                        label: attr,
 | 
			
		||||
                        value: attr
 | 
			
		||||
                    }
 | 
			
		||||
                }),
 | 
			
		||||
                minLength: 0
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $(this).autocomplete("search", $(this).val());
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        showDialog
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,17 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const editTreePrefix = (function() {
 | 
			
		||||
    const dialogEl = $("#edit-tree-prefix-dialog");
 | 
			
		||||
    const formEl = $("#edit-tree-prefix-form");
 | 
			
		||||
    const treePrefixInputEl = $("#tree-prefix-input");
 | 
			
		||||
    const noteTitleEl = $('#tree-prefix-note-title');
 | 
			
		||||
    const $dialog = $("#edit-tree-prefix-dialog");
 | 
			
		||||
    const $form = $("#edit-tree-prefix-form");
 | 
			
		||||
    const $treePrefixInput = $("#tree-prefix-input");
 | 
			
		||||
    const $noteTitle = $('#tree-prefix-note-title');
 | 
			
		||||
 | 
			
		||||
    let noteTreeId;
 | 
			
		||||
 | 
			
		||||
    async function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        await dialogEl.dialog({
 | 
			
		||||
        await $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 500
 | 
			
		||||
        });
 | 
			
		||||
@@ -20,21 +20,21 @@ const editTreePrefix = (function() {
 | 
			
		||||
 | 
			
		||||
        noteTreeId = currentNode.data.noteTreeId;
 | 
			
		||||
 | 
			
		||||
        treePrefixInputEl.val(currentNode.data.prefix).focus();
 | 
			
		||||
        $treePrefixInput.val(currentNode.data.prefix).focus();
 | 
			
		||||
 | 
			
		||||
        const noteTitle = noteTree.getNoteTitle(currentNode.data.noteId);
 | 
			
		||||
 | 
			
		||||
        noteTitleEl.html(noteTitle);
 | 
			
		||||
        $noteTitle.html(noteTitle);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    formEl.submit(() => {
 | 
			
		||||
        const prefix = treePrefixInputEl.val();
 | 
			
		||||
    $form.submit(() => {
 | 
			
		||||
        const prefix = $treePrefixInput.val();
 | 
			
		||||
 | 
			
		||||
        server.put('tree/' + noteTreeId + '/set-prefix', {
 | 
			
		||||
            prefix: prefix
 | 
			
		||||
        }).then(() => noteTree.setPrefix(noteTreeId, prefix));
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog("close");
 | 
			
		||||
        $dialog.dialog("close");
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const eventLog = (function() {
 | 
			
		||||
    const dialogEl = $("#event-log-dialog");
 | 
			
		||||
    const listEl = $("#event-log-list");
 | 
			
		||||
    const $dialog = $("#event-log-dialog");
 | 
			
		||||
    const $list = $("#event-log-list");
 | 
			
		||||
 | 
			
		||||
    async function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 800,
 | 
			
		||||
            height: 700
 | 
			
		||||
@@ -15,7 +15,7 @@ const eventLog = (function() {
 | 
			
		||||
 | 
			
		||||
        const result = await server.get('event-log');
 | 
			
		||||
 | 
			
		||||
        listEl.html('');
 | 
			
		||||
        $list.html('');
 | 
			
		||||
 | 
			
		||||
        for (const event of result) {
 | 
			
		||||
            const dateTime = formatDateTime(parseDate(event.dateAdded));
 | 
			
		||||
@@ -28,7 +28,7 @@ const eventLog = (function() {
 | 
			
		||||
 | 
			
		||||
            const eventEl = $('<li>').html(dateTime + " - " + event.comment);
 | 
			
		||||
 | 
			
		||||
            listEl.append(eventEl);
 | 
			
		||||
            $list.append(eventEl);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +1,28 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const jumpToNote = (function() {
 | 
			
		||||
    const dialogEl = $("#jump-to-note-dialog");
 | 
			
		||||
    const autoCompleteEl = $("#jump-to-note-autocomplete");
 | 
			
		||||
    const formEl = $("#jump-to-note-form");
 | 
			
		||||
    const $dialog = $("#jump-to-note-dialog");
 | 
			
		||||
    const $autoComplete = $("#jump-to-note-autocomplete");
 | 
			
		||||
    const $form = $("#jump-to-note-form");
 | 
			
		||||
 | 
			
		||||
    async function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        autoCompleteEl.val('');
 | 
			
		||||
        $autoComplete.val('');
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 800
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await autoCompleteEl.autocomplete({
 | 
			
		||||
        await $autoComplete.autocomplete({
 | 
			
		||||
            source: await stopWatch("building autocomplete", noteTree.getAutocompleteItems),
 | 
			
		||||
            minLength: 0
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function getSelectedNotePath() {
 | 
			
		||||
        const val = autoCompleteEl.val();
 | 
			
		||||
        const val = $autoComplete.val();
 | 
			
		||||
        return link.getNodePathFromLabel(val);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -32,7 +32,7 @@ const jumpToNote = (function() {
 | 
			
		||||
        if (notePath) {
 | 
			
		||||
            noteTree.activateNode(notePath);
 | 
			
		||||
 | 
			
		||||
            dialogEl.dialog('close');
 | 
			
		||||
            $dialog.dialog('close');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -42,8 +42,8 @@ const jumpToNote = (function() {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    formEl.submit(() => {
 | 
			
		||||
        const action = dialogEl.find("button:focus").val();
 | 
			
		||||
    $form.submit(() => {
 | 
			
		||||
        const action = $dialog.find("button:focus").val();
 | 
			
		||||
 | 
			
		||||
        goToNote();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,10 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const noteHistory = (function() {
 | 
			
		||||
    const dialogEl = $("#note-history-dialog");
 | 
			
		||||
    const listEl = $("#note-history-list");
 | 
			
		||||
    const contentEl = $("#note-history-content");
 | 
			
		||||
    const titleEl = $("#note-history-title");
 | 
			
		||||
    const $dialog = $("#note-history-dialog");
 | 
			
		||||
    const $list = $("#note-history-list");
 | 
			
		||||
    const $content = $("#note-history-content");
 | 
			
		||||
    const $title = $("#note-history-title");
 | 
			
		||||
 | 
			
		||||
    let historyItems = [];
 | 
			
		||||
 | 
			
		||||
@@ -13,23 +13,23 @@ const noteHistory = (function() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function showNoteHistoryDialog(noteId, noteRevisionId) {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 800,
 | 
			
		||||
            height: 700
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        listEl.empty();
 | 
			
		||||
        contentEl.empty();
 | 
			
		||||
        $list.empty();
 | 
			
		||||
        $content.empty();
 | 
			
		||||
 | 
			
		||||
        historyItems = await server.get('notes-history/' + noteId);
 | 
			
		||||
 | 
			
		||||
        for (const item of historyItems) {
 | 
			
		||||
            const dateModified = parseDate(item.dateModifiedFrom);
 | 
			
		||||
 | 
			
		||||
            listEl.append($('<option>', {
 | 
			
		||||
            $list.append($('<option>', {
 | 
			
		||||
                value: item.noteRevisionId,
 | 
			
		||||
                text: formatDateTime(dateModified)
 | 
			
		||||
            }));
 | 
			
		||||
@@ -37,13 +37,13 @@ const noteHistory = (function() {
 | 
			
		||||
 | 
			
		||||
        if (historyItems.length > 0) {
 | 
			
		||||
            if (!noteRevisionId) {
 | 
			
		||||
                noteRevisionId = listEl.find("option:first").val();
 | 
			
		||||
                noteRevisionId = $list.find("option:first").val();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            listEl.val(noteRevisionId).trigger('change');
 | 
			
		||||
            $list.val(noteRevisionId).trigger('change');
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            titleEl.text("No history for this note yet...");
 | 
			
		||||
            $title.text("No history for this note yet...");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -53,13 +53,13 @@ const noteHistory = (function() {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    listEl.on('change', () => {
 | 
			
		||||
        const optVal = listEl.find(":selected").val();
 | 
			
		||||
    $list.on('change', () => {
 | 
			
		||||
        const optVal = $list.find(":selected").val();
 | 
			
		||||
 | 
			
		||||
        const historyItem = historyItems.find(r => r.noteRevisionId === optVal);
 | 
			
		||||
 | 
			
		||||
        titleEl.html(historyItem.title);
 | 
			
		||||
        contentEl.html(historyItem.content);
 | 
			
		||||
        $title.html(historyItem.title);
 | 
			
		||||
        $content.html(historyItem.content);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $(document).on('click', "a[action='note-history']", event => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const noteSource = (function() {
 | 
			
		||||
    const dialogEl = $("#note-source-dialog");
 | 
			
		||||
    const noteSourceEl = $("#note-source");
 | 
			
		||||
    const $dialog = $("#note-source-dialog");
 | 
			
		||||
    const $noteSource = $("#note-source");
 | 
			
		||||
 | 
			
		||||
    function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 800,
 | 
			
		||||
            height: 500
 | 
			
		||||
@@ -15,7 +15,7 @@ const noteSource = (function() {
 | 
			
		||||
 | 
			
		||||
        const noteText = noteEditor.getCurrentNote().detail.content;
 | 
			
		||||
 | 
			
		||||
        noteSourceEl.text(formatHtml(noteText));
 | 
			
		||||
        $noteSource.text(formatHtml(noteText));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function formatHtml(str) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const recentChanges = (function() {
 | 
			
		||||
    const dialogEl = $("#recent-changes-dialog");
 | 
			
		||||
    const $dialog = $("#recent-changes-dialog");
 | 
			
		||||
 | 
			
		||||
    async function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 800,
 | 
			
		||||
            height: 700
 | 
			
		||||
@@ -14,7 +14,7 @@ const recentChanges = (function() {
 | 
			
		||||
 | 
			
		||||
        const result = await server.get('recent-changes/');
 | 
			
		||||
 | 
			
		||||
        dialogEl.html('');
 | 
			
		||||
        $dialog.html('');
 | 
			
		||||
 | 
			
		||||
        const groupedByDate = groupByDate(result);
 | 
			
		||||
 | 
			
		||||
@@ -48,7 +48,7 @@ const recentChanges = (function() {
 | 
			
		||||
                    .append(' (').append(revLink).append(')'));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            dialogEl.append(dayEl);
 | 
			
		||||
            $dialog.append(dayEl);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,9 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const recentNotes = (function() {
 | 
			
		||||
    const dialogEl = $("#recent-notes-dialog");
 | 
			
		||||
    const selectBoxEl = $('#recent-notes-select-box');
 | 
			
		||||
    const jumpToButtonEl = $('#recent-notes-jump-to');
 | 
			
		||||
    const addLinkButtonEl = $('#recent-notes-add-link');
 | 
			
		||||
    const addCurrentAsChildEl = $("#recent-notes-add-current-as-child");
 | 
			
		||||
    const addRecentAsChildEl = $("#recent-notes-add-recent-as-child");
 | 
			
		||||
    const noteDetailEl = $('#note-detail');
 | 
			
		||||
    const $dialog = $("#recent-notes-dialog");
 | 
			
		||||
    const $searchInput = $('#recent-notes-search-input');
 | 
			
		||||
 | 
			
		||||
    // list of recent note paths
 | 
			
		||||
    let list = [];
 | 
			
		||||
 | 
			
		||||
@@ -29,97 +25,65 @@ const recentNotes = (function() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 800
 | 
			
		||||
            width: 800,
 | 
			
		||||
            height: 400
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        selectBoxEl.find('option').remove();
 | 
			
		||||
        $searchInput.val('');
 | 
			
		||||
 | 
			
		||||
        // remove the current note
 | 
			
		||||
        const recNotes = list.filter(note => note !== noteTree.getCurrentNotePath());
 | 
			
		||||
 | 
			
		||||
        $.each(recNotes, (key, valueNotePath) => {
 | 
			
		||||
            const noteTitle = noteTree.getNotePathTitle(valueNotePath);
 | 
			
		||||
        $searchInput.autocomplete({
 | 
			
		||||
            source: recNotes.map(notePath => {
 | 
			
		||||
                let noteTitle;
 | 
			
		||||
 | 
			
		||||
            const option = $("<option></option>")
 | 
			
		||||
                .attr("value", valueNotePath)
 | 
			
		||||
                .text(noteTitle);
 | 
			
		||||
                try {
 | 
			
		||||
                    noteTitle = noteTree.getNotePathTitle(notePath);
 | 
			
		||||
                }
 | 
			
		||||
                catch (e) {
 | 
			
		||||
                    noteTitle = "[error - can't find note title]";
 | 
			
		||||
 | 
			
		||||
            // select the first one (most recent one) by default
 | 
			
		||||
            if (key === 0) {
 | 
			
		||||
                option.attr("selected", "selected");
 | 
			
		||||
                    messaging.logError("Could not find title for notePath=" + notePath + ", stack=" + e.stack);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            selectBoxEl.append(option);
 | 
			
		||||
        });
 | 
			
		||||
                return {
 | 
			
		||||
                    label: noteTitle,
 | 
			
		||||
                    value: notePath
 | 
			
		||||
                }
 | 
			
		||||
            }),
 | 
			
		||||
            minLength: 0,
 | 
			
		||||
            autoFocus: true,
 | 
			
		||||
            select: function (event, ui) {
 | 
			
		||||
                noteTree.activateNode(ui.item.value);
 | 
			
		||||
 | 
			
		||||
    function getSelectedNotePath() {
 | 
			
		||||
        return selectBoxEl.find("option:selected").val();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function getSelectedNoteId() {
 | 
			
		||||
        const notePath = getSelectedNotePath();
 | 
			
		||||
        return treeUtils.getNoteIdFromNotePath(notePath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function setActiveNoteBasedOnRecentNotes() {
 | 
			
		||||
        const notePath = getSelectedNotePath();
 | 
			
		||||
 | 
			
		||||
        noteTree.activateNode(notePath);
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog('close');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function addLinkBasedOnRecentNotes() {
 | 
			
		||||
        const notePath = getSelectedNotePath();
 | 
			
		||||
        const noteId = treeUtils.getNoteIdFromNotePath(notePath);
 | 
			
		||||
 | 
			
		||||
        const linkTitle = noteTree.getNoteTitle(noteId);
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog("close");
 | 
			
		||||
 | 
			
		||||
        link.addLinkToEditor(linkTitle, '#' + notePath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function addCurrentAsChild() {
 | 
			
		||||
        await cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), getSelectedNoteId());
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog("close");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function addRecentAsChild() {
 | 
			
		||||
        await cloning.cloneNoteTo(getSelectedNoteId(), noteEditor.getCurrentNoteId());
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog("close");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    selectBoxEl.keydown(e => {
 | 
			
		||||
        const key = e.which;
 | 
			
		||||
 | 
			
		||||
        // to get keycodes use http://keycode.info/
 | 
			
		||||
        if (key === 13)// the enter key code
 | 
			
		||||
        {
 | 
			
		||||
            setActiveNoteBasedOnRecentNotes();
 | 
			
		||||
        }
 | 
			
		||||
        else if (key === 76 /* l */) {
 | 
			
		||||
            addLinkBasedOnRecentNotes();
 | 
			
		||||
        }
 | 
			
		||||
        else if (key === 67 /* c */) {
 | 
			
		||||
            addCurrentAsChild();
 | 
			
		||||
        }
 | 
			
		||||
        else if (key === 82 /* r */) {
 | 
			
		||||
            addRecentAsChild()
 | 
			
		||||
                $searchInput.autocomplete('destroy');
 | 
			
		||||
                $dialog.dialog('close');
 | 
			
		||||
            },
 | 
			
		||||
            focus: function (event, ui) {
 | 
			
		||||
                event.preventDefault();
 | 
			
		||||
            },
 | 
			
		||||
            close: function (event, ui) {
 | 
			
		||||
                if (event.keyCode === 27) { // escape closes dialog
 | 
			
		||||
                    $searchInput.autocomplete('destroy');
 | 
			
		||||
                    $dialog.dialog('close');
 | 
			
		||||
                }
 | 
			
		||||
                else {
 | 
			
		||||
            return; // avoid prevent default
 | 
			
		||||
                    // keep autocomplete open
 | 
			
		||||
                    // we're kind of abusing autocomplete to work in a way which it's not designed for
 | 
			
		||||
                    $searchInput.autocomplete("search", "");
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            create: () => $searchInput.autocomplete("search", ""),
 | 
			
		||||
            classes: {
 | 
			
		||||
                "ui-autocomplete": "recent-notes-autocomplete"
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    reload();
 | 
			
		||||
 | 
			
		||||
@@ -129,15 +93,6 @@ const recentNotes = (function() {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    selectBoxEl.dblclick(e => {
 | 
			
		||||
        setActiveNoteBasedOnRecentNotes();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    jumpToButtonEl.click(setActiveNoteBasedOnRecentNotes);
 | 
			
		||||
    addLinkButtonEl.click(addLinkBasedOnRecentNotes);
 | 
			
		||||
    addCurrentAsChildEl.click(addCurrentAsChild);
 | 
			
		||||
    addRecentAsChildEl.click(addRecentAsChild);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        showDialog,
 | 
			
		||||
        addRecentNote,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const settings = (function() {
 | 
			
		||||
    const dialogEl = $("#settings-dialog");
 | 
			
		||||
    const tabsEl = $("#settings-tabs");
 | 
			
		||||
    const $dialog = $("#settings-dialog");
 | 
			
		||||
    const $tabs = $("#settings-tabs");
 | 
			
		||||
 | 
			
		||||
    const settingModules = [];
 | 
			
		||||
 | 
			
		||||
@@ -11,16 +11,16 @@ const settings = (function() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        const settings = await server.get('settings');
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: 900
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        tabsEl.tabs();
 | 
			
		||||
        $tabs.tabs();
 | 
			
		||||
 | 
			
		||||
        for (const module of settingModules) {
 | 
			
		||||
            if (module.settingsLoaded) {
 | 
			
		||||
@@ -46,22 +46,22 @@ const settings = (function() {
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
settings.addModule((function() {
 | 
			
		||||
    const formEl = $("#change-password-form");
 | 
			
		||||
    const oldPasswordEl = $("#old-password");
 | 
			
		||||
    const newPassword1El = $("#new-password1");
 | 
			
		||||
    const newPassword2El = $("#new-password2");
 | 
			
		||||
    const $form = $("#change-password-form");
 | 
			
		||||
    const $oldPassword = $("#old-password");
 | 
			
		||||
    const $newPassword1 = $("#new-password1");
 | 
			
		||||
    const $newPassword2 = $("#new-password2");
 | 
			
		||||
 | 
			
		||||
    function settingsLoaded(settings) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    formEl.submit(() => {
 | 
			
		||||
        const oldPassword = oldPasswordEl.val();
 | 
			
		||||
        const newPassword1 = newPassword1El.val();
 | 
			
		||||
        const newPassword2 = newPassword2El.val();
 | 
			
		||||
    $form.submit(() => {
 | 
			
		||||
        const oldPassword = $oldPassword.val();
 | 
			
		||||
        const newPassword1 = $newPassword1.val();
 | 
			
		||||
        const newPassword2 = $newPassword2.val();
 | 
			
		||||
 | 
			
		||||
        oldPasswordEl.val('');
 | 
			
		||||
        newPassword1El.val('');
 | 
			
		||||
        newPassword2El.val('');
 | 
			
		||||
        $oldPassword.val('');
 | 
			
		||||
        $newPassword1.val('');
 | 
			
		||||
        $newPassword2.val('');
 | 
			
		||||
 | 
			
		||||
        if (newPassword1 !== newPassword2) {
 | 
			
		||||
            alert("New passwords are not the same.");
 | 
			
		||||
@@ -92,16 +92,16 @@ settings.addModule((function() {
 | 
			
		||||
})());
 | 
			
		||||
 | 
			
		||||
settings.addModule((function() {
 | 
			
		||||
    const formEl = $("#protected-session-timeout-form");
 | 
			
		||||
    const protectedSessionTimeoutEl = $("#protected-session-timeout-in-seconds");
 | 
			
		||||
    const $form = $("#protected-session-timeout-form");
 | 
			
		||||
    const $protectedSessionTimeout = $("#protected-session-timeout-in-seconds");
 | 
			
		||||
    const settingName = 'protected_session_timeout';
 | 
			
		||||
 | 
			
		||||
    function settingsLoaded(settings) {
 | 
			
		||||
        protectedSessionTimeoutEl.val(settings[settingName]);
 | 
			
		||||
        $protectedSessionTimeout.val(settings[settingName]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    formEl.submit(() => {
 | 
			
		||||
        const protectedSessionTimeout = protectedSessionTimeoutEl.val();
 | 
			
		||||
    $form.submit(() => {
 | 
			
		||||
        const protectedSessionTimeout = $protectedSessionTimeout.val();
 | 
			
		||||
 | 
			
		||||
        settings.saveSettings(settingName, protectedSessionTimeout).then(() => {
 | 
			
		||||
            protected_session.setProtectedSessionTimeout(protectedSessionTimeout);
 | 
			
		||||
@@ -116,16 +116,16 @@ settings.addModule((function() {
 | 
			
		||||
})());
 | 
			
		||||
 | 
			
		||||
settings.addModule((function () {
 | 
			
		||||
    const formEl = $("#history-snapshot-time-interval-form");
 | 
			
		||||
    const timeIntervalEl = $("#history-snapshot-time-interval-in-seconds");
 | 
			
		||||
    const $form = $("#history-snapshot-time-interval-form");
 | 
			
		||||
    const $timeInterval = $("#history-snapshot-time-interval-in-seconds");
 | 
			
		||||
    const settingName = 'history_snapshot_time_interval';
 | 
			
		||||
 | 
			
		||||
    function settingsLoaded(settings) {
 | 
			
		||||
        timeIntervalEl.val(settings[settingName]);
 | 
			
		||||
        $timeInterval.val(settings[settingName]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    formEl.submit(() => {
 | 
			
		||||
        settings.saveSettings(settingName, timeIntervalEl.val());
 | 
			
		||||
    $form.submit(() => {
 | 
			
		||||
        settings.saveSettings(settingName, $timeInterval.val());
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    });
 | 
			
		||||
@@ -136,50 +136,50 @@ settings.addModule((function () {
 | 
			
		||||
})());
 | 
			
		||||
 | 
			
		||||
settings.addModule((async function () {
 | 
			
		||||
    const appVersionEl = $("#app-version");
 | 
			
		||||
    const dbVersionEl = $("#db-version");
 | 
			
		||||
    const buildDateEl = $("#build-date");
 | 
			
		||||
    const buildRevisionEl = $("#build-revision");
 | 
			
		||||
    const $appVersion = $("#app-version");
 | 
			
		||||
    const $dbVersion = $("#db-version");
 | 
			
		||||
    const $buildDate = $("#build-date");
 | 
			
		||||
    const $buildRevision = $("#build-revision");
 | 
			
		||||
 | 
			
		||||
    const appInfo = await server.get('app-info');
 | 
			
		||||
 | 
			
		||||
    appVersionEl.html(appInfo.app_version);
 | 
			
		||||
    dbVersionEl.html(appInfo.db_version);
 | 
			
		||||
    buildDateEl.html(appInfo.build_date);
 | 
			
		||||
    buildRevisionEl.html(appInfo.build_revision);
 | 
			
		||||
    buildRevisionEl.attr('href', 'https://github.com/zadam/trilium/commit/' + appInfo.build_revision);
 | 
			
		||||
    $appVersion.html(appInfo.app_version);
 | 
			
		||||
    $dbVersion.html(appInfo.db_version);
 | 
			
		||||
    $buildDate.html(appInfo.build_date);
 | 
			
		||||
    $buildRevision.html(appInfo.build_revision);
 | 
			
		||||
    $buildRevision.attr('href', 'https://github.com/zadam/trilium/commit/' + appInfo.build_revision);
 | 
			
		||||
 | 
			
		||||
    return {};
 | 
			
		||||
})());
 | 
			
		||||
 | 
			
		||||
settings.addModule((async function () {
 | 
			
		||||
    const forceFullSyncButton = $("#force-full-sync-button");
 | 
			
		||||
    const fillSyncRowsButton = $("#fill-sync-rows-button");
 | 
			
		||||
    const anonymizeButton = $("#anonymize-button");
 | 
			
		||||
    const cleanupSoftDeletedButton = $("#cleanup-soft-deleted-items-button");
 | 
			
		||||
    const cleanupUnusedImagesButton = $("#cleanup-unused-images-button");
 | 
			
		||||
    const vacuumDatabaseButton = $("#vacuum-database-button");
 | 
			
		||||
    const $forceFullSyncButton = $("#force-full-sync-button");
 | 
			
		||||
    const $fillSyncRowsButton = $("#fill-sync-rows-button");
 | 
			
		||||
    const $anonymizeButton = $("#anonymize-button");
 | 
			
		||||
    const $cleanupSoftDeletedButton = $("#cleanup-soft-deleted-items-button");
 | 
			
		||||
    const $cleanupUnusedImagesButton = $("#cleanup-unused-images-button");
 | 
			
		||||
    const $vacuumDatabaseButton = $("#vacuum-database-button");
 | 
			
		||||
 | 
			
		||||
    forceFullSyncButton.click(async () => {
 | 
			
		||||
    $forceFullSyncButton.click(async () => {
 | 
			
		||||
        await server.post('sync/force-full-sync');
 | 
			
		||||
 | 
			
		||||
        showMessage("Full sync triggered");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    fillSyncRowsButton.click(async () => {
 | 
			
		||||
    $fillSyncRowsButton.click(async () => {
 | 
			
		||||
        await server.post('sync/fill-sync-rows');
 | 
			
		||||
 | 
			
		||||
        showMessage("Sync rows filled successfully");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    anonymizeButton.click(async () => {
 | 
			
		||||
    $anonymizeButton.click(async () => {
 | 
			
		||||
        await server.post('anonymization/anonymize');
 | 
			
		||||
 | 
			
		||||
        showMessage("Created anonymized database");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    cleanupSoftDeletedButton.click(async () => {
 | 
			
		||||
    $cleanupSoftDeletedButton.click(async () => {
 | 
			
		||||
        if (confirm("Do you really want to clean up soft-deleted items?")) {
 | 
			
		||||
            await server.post('cleanup/cleanup-soft-deleted-items');
 | 
			
		||||
 | 
			
		||||
@@ -187,7 +187,7 @@ settings.addModule((async function () {
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    cleanupUnusedImagesButton.click(async () => {
 | 
			
		||||
    $cleanupUnusedImagesButton.click(async () => {
 | 
			
		||||
        if (confirm("Do you really want to clean up unused images?")) {
 | 
			
		||||
            await server.post('cleanup/cleanup-unused-images');
 | 
			
		||||
 | 
			
		||||
@@ -195,7 +195,7 @@ settings.addModule((async function () {
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    vacuumDatabaseButton.click(async () => {
 | 
			
		||||
    $vacuumDatabaseButton.click(async () => {
 | 
			
		||||
        await server.post('cleanup/vacuum-database');
 | 
			
		||||
 | 
			
		||||
        showMessage("Database has been vacuumed");
 | 
			
		||||
 
 | 
			
		||||
@@ -1,24 +1,44 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const sqlConsole = (function() {
 | 
			
		||||
    const dialogEl = $("#sql-console-dialog");
 | 
			
		||||
    const queryEl = $('#sql-console-query');
 | 
			
		||||
    const executeButton = $('#sql-console-execute');
 | 
			
		||||
    const resultHeadEl = $('#sql-console-results thead');
 | 
			
		||||
    const resultBodyEl = $('#sql-console-results tbody');
 | 
			
		||||
    const $dialog = $("#sql-console-dialog");
 | 
			
		||||
    const $query = $('#sql-console-query');
 | 
			
		||||
    const $executeButton = $('#sql-console-execute');
 | 
			
		||||
    const $resultHead = $('#sql-console-results thead');
 | 
			
		||||
    const $resultBody = $('#sql-console-results tbody');
 | 
			
		||||
 | 
			
		||||
    let codeEditor;
 | 
			
		||||
 | 
			
		||||
    function showDialog() {
 | 
			
		||||
        glob.activeDialog = dialogEl;
 | 
			
		||||
        glob.activeDialog = $dialog;
 | 
			
		||||
 | 
			
		||||
        dialogEl.dialog({
 | 
			
		||||
        $dialog.dialog({
 | 
			
		||||
            modal: true,
 | 
			
		||||
            width: $(window).width(),
 | 
			
		||||
            height: $(window).height()
 | 
			
		||||
            height: $(window).height(),
 | 
			
		||||
            open: function() {
 | 
			
		||||
                CodeMirror.keyMap.default["Shift-Tab"] = "indentLess";
 | 
			
		||||
                CodeMirror.keyMap.default["Tab"] = "indentMore";
 | 
			
		||||
 | 
			
		||||
                CodeMirror.modeURL = 'libraries/codemirror/mode/%N/%N.js';
 | 
			
		||||
 | 
			
		||||
                codeEditor = CodeMirror($query[0], {
 | 
			
		||||
                    value: "",
 | 
			
		||||
                    viewportMargin: Infinity,
 | 
			
		||||
                    indentUnit: 4,
 | 
			
		||||
                    highlightSelectionMatches: { showToken: /\w/, annotateScrollbar: false }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                codeEditor.setOption("mode", "text/x-sqlite");
 | 
			
		||||
                CodeMirror.autoLoadMode(codeEditor, "sql");
 | 
			
		||||
 | 
			
		||||
                codeEditor.focus();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function execute() {
 | 
			
		||||
        const sqlQuery = queryEl.val();
 | 
			
		||||
        const sqlQuery = codeEditor.getValue();
 | 
			
		||||
 | 
			
		||||
        const result = await server.post("sql/execute", {
 | 
			
		||||
            query: sqlQuery
 | 
			
		||||
@@ -34,8 +54,8 @@ const sqlConsole = (function() {
 | 
			
		||||
 | 
			
		||||
        const rows = result.rows;
 | 
			
		||||
 | 
			
		||||
        resultHeadEl.empty();
 | 
			
		||||
        resultBodyEl.empty();
 | 
			
		||||
        $resultHead.empty();
 | 
			
		||||
        $resultBody.empty();
 | 
			
		||||
 | 
			
		||||
        if (rows.length > 0) {
 | 
			
		||||
            const result = rows[0];
 | 
			
		||||
@@ -45,7 +65,7 @@ const sqlConsole = (function() {
 | 
			
		||||
                rowEl.append($("<th>").html(key));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            resultHeadEl.append(rowEl);
 | 
			
		||||
            $resultHead.append(rowEl);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (const result of rows) {
 | 
			
		||||
@@ -55,15 +75,15 @@ const sqlConsole = (function() {
 | 
			
		||||
                rowEl.append($("<td>").html(result[key]));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            resultBodyEl.append(rowEl);
 | 
			
		||||
            $resultBody.append(rowEl);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $(document).bind('keydown', 'alt+o', showDialog);
 | 
			
		||||
 | 
			
		||||
    queryEl.bind('keydown', 'ctrl+return', execute);
 | 
			
		||||
    $query.bind('keydown', 'ctrl+return', execute);
 | 
			
		||||
 | 
			
		||||
    executeButton.click(execute);
 | 
			
		||||
    $executeButton.click(execute);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        showDialog
 | 
			
		||||
 
 | 
			
		||||
@@ -54,24 +54,6 @@ $(document).bind('keydown', 'ctrl+f', () => {
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
$(document).bind('keydown', "ctrl+shift+left", () => {
 | 
			
		||||
    const node = noteTree.getCurrentNode();
 | 
			
		||||
    node.navigate($.ui.keyCode.LEFT, true);
 | 
			
		||||
 | 
			
		||||
    $("#note-detail").focus();
 | 
			
		||||
 | 
			
		||||
    return false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
$(document).bind('keydown', "ctrl+shift+right", () => {
 | 
			
		||||
    const node = noteTree.getCurrentNode();
 | 
			
		||||
    node.navigate($.ui.keyCode.RIGHT, true);
 | 
			
		||||
 | 
			
		||||
    $("#note-detail").focus();
 | 
			
		||||
 | 
			
		||||
    return false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
$(document).bind('keydown', "ctrl+shift+up", () => {
 | 
			
		||||
    const node = noteTree.getCurrentNode();
 | 
			
		||||
    node.navigate($.ui.keyCode.UP, true);
 | 
			
		||||
@@ -123,7 +105,7 @@ $(window).on('beforeunload', () => {
 | 
			
		||||
// Overrides the default autocomplete filter function to search for matched on atleast 1 word in each of the input term's words
 | 
			
		||||
$.ui.autocomplete.filter = (array, terms) => {
 | 
			
		||||
    if (!terms) {
 | 
			
		||||
        return [];
 | 
			
		||||
        return array;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const startDate = new Date();
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,8 @@ const noteEditor = (function() {
 | 
			
		||||
    const unprotectButton = $("#unprotect-button");
 | 
			
		||||
    const noteDetailWrapperEl = $("#note-detail-wrapper");
 | 
			
		||||
    const noteIdDisplayEl = $("#note-id-display");
 | 
			
		||||
    const attributeListEl = $("#attribute-list");
 | 
			
		||||
    const attributeListInnerEl = $("#attribute-list-inner");
 | 
			
		||||
 | 
			
		||||
    let editor = null;
 | 
			
		||||
    let codeEditor = null;
 | 
			
		||||
@@ -187,6 +189,27 @@ const noteEditor = (function() {
 | 
			
		||||
 | 
			
		||||
        // after loading new note make sure editor is scrolled to the top
 | 
			
		||||
        noteDetailWrapperEl.scrollTop(0);
 | 
			
		||||
 | 
			
		||||
        loadAttributeList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function loadAttributeList() {
 | 
			
		||||
        const noteId = getCurrentNoteId();
 | 
			
		||||
 | 
			
		||||
        const attributes = await server.get('notes/' + noteId + '/attributes');
 | 
			
		||||
 | 
			
		||||
        attributeListInnerEl.html('');
 | 
			
		||||
 | 
			
		||||
        if (attributes.length > 0) {
 | 
			
		||||
            for (const attr of attributes) {
 | 
			
		||||
                attributeListInnerEl.append(formatAttribute(attr) + " ");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            attributeListEl.show();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            attributeListEl.hide();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async function loadNote(noteId) {
 | 
			
		||||
@@ -290,6 +313,7 @@ const noteEditor = (function() {
 | 
			
		||||
        newNoteCreated,
 | 
			
		||||
        getEditor,
 | 
			
		||||
        focus,
 | 
			
		||||
        executeCurrentNote
 | 
			
		||||
        executeCurrentNote,
 | 
			
		||||
        loadAttributeList
 | 
			
		||||
    };
 | 
			
		||||
})();
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
const noteTree = (function() {
 | 
			
		||||
    const treeEl = $("#tree");
 | 
			
		||||
    const parentListEl = $("#parent-list");
 | 
			
		||||
    const parentListListEl = $("#parent-list-list");
 | 
			
		||||
    const parentListListEl = $("#parent-list-inner");
 | 
			
		||||
 | 
			
		||||
    let startNotePath = null;
 | 
			
		||||
    let notesTreeMap = {};
 | 
			
		||||
 
 | 
			
		||||
@@ -104,6 +104,8 @@ const server = (function() {
 | 
			
		||||
        post,
 | 
			
		||||
        put,
 | 
			
		||||
        remove,
 | 
			
		||||
        exec
 | 
			
		||||
        exec,
 | 
			
		||||
        // don't remove, used from CKEditor image upload!
 | 
			
		||||
        getHeaders
 | 
			
		||||
    }
 | 
			
		||||
})();
 | 
			
		||||
@@ -119,3 +119,17 @@ function executeScript(script) {
 | 
			
		||||
    // last \r\n is necessary if script contains line comment on its last line
 | 
			
		||||
    eval("(async function() {" + script + "\r\n})()");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function formatValueWithWhitespace(val) {
 | 
			
		||||
    return /[^\w_-]/.test(val) ? '"' + val + '"' : val;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function formatAttribute(attr) {
 | 
			
		||||
    let str = "@" + formatValueWithWhitespace(attr.name);
 | 
			
		||||
 | 
			
		||||
    if (attr.value !== "") {
 | 
			
		||||
        str += "=" + formatValueWithWhitespace(attr.value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return str;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2
									
								
								src/public/libraries/ckeditor/ckeditor.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/public/libraries/ckeditor/ckeditor.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -5,12 +5,18 @@
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-areas: "header header"
 | 
			
		||||
                         "tree-actions title"
 | 
			
		||||
                         "search note-content"
 | 
			
		||||
                         "tree note-content"
 | 
			
		||||
                         "parent-list note-content";
 | 
			
		||||
                         "parent-list note-content"
 | 
			
		||||
                         "parent-list attribute-list";
 | 
			
		||||
    grid-template-columns: 2fr 5fr;
 | 
			
		||||
    grid-template-rows: auto
 | 
			
		||||
                        auto
 | 
			
		||||
                        1fr;
 | 
			
		||||
                        auto
 | 
			
		||||
                        1fr
 | 
			
		||||
                        auto
 | 
			
		||||
                        auto;
 | 
			
		||||
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    grid-gap: 10px;
 | 
			
		||||
}
 | 
			
		||||
@@ -134,6 +140,7 @@ div.ui-tooltip {
 | 
			
		||||
    margin-left: 20px;
 | 
			
		||||
    border-top: 2px solid #eee;
 | 
			
		||||
    padding-top: 10px;
 | 
			
		||||
    grid-area: parent-list;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#parent-list ul {
 | 
			
		||||
@@ -238,7 +245,7 @@ div.ui-tooltip {
 | 
			
		||||
#note-id-display {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: 10px;
 | 
			
		||||
    bottom: 5px;
 | 
			
		||||
    bottom: 8px;
 | 
			
		||||
    z-index: 1000;
 | 
			
		||||
    color: lightgrey;
 | 
			
		||||
}
 | 
			
		||||
@@ -250,3 +257,19 @@ div.ui-tooltip {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.cm-matchhighlight {background-color: #eeeeee}
 | 
			
		||||
 | 
			
		||||
#attribute-list {
 | 
			
		||||
    grid-area: attribute-list;
 | 
			
		||||
    color: #777777;
 | 
			
		||||
    border-top: 1px solid #eee;
 | 
			
		||||
    padding: 5px; display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#attribute-list button {
 | 
			
		||||
    padding: 2px;
 | 
			
		||||
    margin-right: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.recent-notes-autocomplete {
 | 
			
		||||
    border: 0 !important;
 | 
			
		||||
}
 | 
			
		||||
@@ -7,14 +7,15 @@ const auth = require('../../services/auth');
 | 
			
		||||
const sync_table = require('../../services/sync_table');
 | 
			
		||||
const utils = require('../../services/utils');
 | 
			
		||||
const wrap = require('express-promise-wrap').wrap;
 | 
			
		||||
const attributes = require('../../services/attributes');
 | 
			
		||||
 | 
			
		||||
router.get('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
router.get('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    const noteId = req.params.noteId;
 | 
			
		||||
 | 
			
		||||
    res.send(await sql.getRows("SELECT * FROM attributes WHERE noteId = ? ORDER BY dateCreated", [noteId]));
 | 
			
		||||
    res.send(await sql.getRows("SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ? ORDER BY position, dateCreated", [noteId]));
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
router.put('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    const noteId = req.params.noteId;
 | 
			
		||||
    const attributes = req.body;
 | 
			
		||||
    const now = utils.nowDate();
 | 
			
		||||
@@ -22,10 +23,15 @@ router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next)
 | 
			
		||||
    await sql.doInTransaction(async () => {
 | 
			
		||||
        for (const attr of attributes) {
 | 
			
		||||
            if (attr.attributeId) {
 | 
			
		||||
                await sql.execute("UPDATE attributes SET name = ?, value = ?, dateModified = ? WHERE attributeId = ?",
 | 
			
		||||
                    [attr.name, attr.value, now, attr.attributeId]);
 | 
			
		||||
                await sql.execute("UPDATE attributes SET name = ?, value = ?, dateModified = ?, isDeleted = ?, position = ? WHERE attributeId = ?",
 | 
			
		||||
                    [attr.name, attr.value, now, attr.isDeleted, attr.position, attr.attributeId]);
 | 
			
		||||
            }
 | 
			
		||||
            else {
 | 
			
		||||
                // if it was "created" and then immediatelly deleted, we just don't create it at all
 | 
			
		||||
                if (attr.isDeleted) {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                attr.attributeId = utils.newAttributeId();
 | 
			
		||||
 | 
			
		||||
                await sql.insert("attributes", {
 | 
			
		||||
@@ -33,8 +39,10 @@ router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next)
 | 
			
		||||
                    noteId: noteId,
 | 
			
		||||
                    name: attr.name,
 | 
			
		||||
                    value: attr.value,
 | 
			
		||||
                    position: attr.position,
 | 
			
		||||
                    dateCreated: now,
 | 
			
		||||
                   dateModified: now
 | 
			
		||||
                    dateModified: now,
 | 
			
		||||
                    isDeleted: false
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -42,7 +50,29 @@ router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next)
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    res.send(await sql.getRows("SELECT * FROM attributes WHERE noteId = ? ORDER BY dateCreated", [noteId]));
 | 
			
		||||
    res.send(await sql.getRows("SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ? ORDER BY position, dateCreated", [noteId]));
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.get('/attributes/names', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    const names = await sql.getColumn("SELECT DISTINCT name FROM attributes WHERE isDeleted = 0");
 | 
			
		||||
 | 
			
		||||
    for (const attr of attributes.BUILTIN_ATTRIBUTES) {
 | 
			
		||||
        if (!names.includes(attr)) {
 | 
			
		||||
            names.push(attr);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    names.sort();
 | 
			
		||||
 | 
			
		||||
    res.send(names);
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.get('/attributes/values/:attributeName', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    const attributeName = req.params.attributeName;
 | 
			
		||||
 | 
			
		||||
    const values = await sql.getColumn("SELECT DISTINCT value FROM attributes WHERE isDeleted = 0 AND name = ? AND value != '' ORDER BY value", [attributeName]);
 | 
			
		||||
 | 
			
		||||
    res.send(values);
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
@@ -4,16 +4,8 @@ const express = require('express');
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const sql = require('../../services/sql');
 | 
			
		||||
const auth = require('../../services/auth');
 | 
			
		||||
const utils = require('../../services/utils');
 | 
			
		||||
const sync_table = require('../../services/sync_table');
 | 
			
		||||
const image = require('../../services/image');
 | 
			
		||||
const multer = require('multer')();
 | 
			
		||||
const imagemin = require('imagemin');
 | 
			
		||||
const imageminMozJpeg = require('imagemin-mozjpeg');
 | 
			
		||||
const imageminPngQuant = require('imagemin-pngquant');
 | 
			
		||||
const imageminGifLossy = require('imagemin-giflossy');
 | 
			
		||||
const jimp = require('jimp');
 | 
			
		||||
const imageType = require('image-type');
 | 
			
		||||
const sanitizeFilename = require('sanitize-filename');
 | 
			
		||||
const wrap = require('express-promise-wrap').wrap;
 | 
			
		||||
const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR;
 | 
			
		||||
const fs = require('fs');
 | 
			
		||||
@@ -49,45 +41,7 @@ router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async
 | 
			
		||||
        return res.status(400).send("Unknown image type: " + file.mimetype);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const now = utils.nowDate();
 | 
			
		||||
 | 
			
		||||
    const resizedImage = await resize(file.buffer);
 | 
			
		||||
    const optimizedImage = await optimize(resizedImage);
 | 
			
		||||
 | 
			
		||||
    const imageFormat = imageType(optimizedImage);
 | 
			
		||||
 | 
			
		||||
    const fileNameWithouExtension = file.originalname.replace(/\.[^/.]+$/, "");
 | 
			
		||||
    const fileName = sanitizeFilename(fileNameWithouExtension + "." + imageFormat.ext);
 | 
			
		||||
 | 
			
		||||
    const imageId = utils.newImageId();
 | 
			
		||||
 | 
			
		||||
    await sql.doInTransaction(async () => {
 | 
			
		||||
        await sql.insert("images", {
 | 
			
		||||
            imageId: imageId,
 | 
			
		||||
            format: imageFormat.ext,
 | 
			
		||||
            name: fileName,
 | 
			
		||||
            checksum: utils.hash(optimizedImage),
 | 
			
		||||
            data: optimizedImage,
 | 
			
		||||
            isDeleted: 0,
 | 
			
		||||
            dateModified: now,
 | 
			
		||||
            dateCreated: now
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await sync_table.addImageSync(imageId, sourceId);
 | 
			
		||||
 | 
			
		||||
        const noteImageId = utils.newNoteImageId();
 | 
			
		||||
 | 
			
		||||
        await sql.insert("note_images", {
 | 
			
		||||
            noteImageId: noteImageId,
 | 
			
		||||
            noteId: noteId,
 | 
			
		||||
            imageId: imageId,
 | 
			
		||||
            isDeleted: 0,
 | 
			
		||||
            dateModified: now,
 | 
			
		||||
            dateCreated: now
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await sync_table.addNoteImageSync(noteImageId, sourceId);
 | 
			
		||||
    });
 | 
			
		||||
    const {fileName, imageId} = await image.saveImage(file, sourceId, noteId);
 | 
			
		||||
 | 
			
		||||
    res.send({
 | 
			
		||||
        uploaded: true,
 | 
			
		||||
@@ -95,54 +49,4 @@ router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async
 | 
			
		||||
    });
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
const MAX_SIZE = 1000;
 | 
			
		||||
const MAX_BYTE_SIZE = 200000; // images should have under 100 KBs
 | 
			
		||||
 | 
			
		||||
async function resize(buffer) {
 | 
			
		||||
    const image = await jimp.read(buffer);
 | 
			
		||||
 | 
			
		||||
    if (image.bitmap.width > image.bitmap.height && image.bitmap.width > MAX_SIZE) {
 | 
			
		||||
        image.resize(MAX_SIZE, jimp.AUTO);
 | 
			
		||||
    }
 | 
			
		||||
    else if (image.bitmap.height > MAX_SIZE) {
 | 
			
		||||
        image.resize(jimp.AUTO, MAX_SIZE);
 | 
			
		||||
    }
 | 
			
		||||
    else if (buffer.byteLength <= MAX_BYTE_SIZE) {
 | 
			
		||||
        return buffer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // we do resizing with max quality which will be trimmed during optimization step next
 | 
			
		||||
    image.quality(100);
 | 
			
		||||
 | 
			
		||||
    // when converting PNG to JPG we lose alpha channel, this is replaced by white to match Trilium white background
 | 
			
		||||
    image.background(0xFFFFFFFF);
 | 
			
		||||
 | 
			
		||||
    // getBuffer doesn't support promises so this workaround
 | 
			
		||||
    return await new Promise((resolve, reject) => image.getBuffer(jimp.MIME_JPEG, (err, data) => {
 | 
			
		||||
        if (err) {
 | 
			
		||||
            reject(err);
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            resolve(data);
 | 
			
		||||
        }
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function optimize(buffer) {
 | 
			
		||||
    return await imagemin.buffer(buffer, {
 | 
			
		||||
        plugins: [
 | 
			
		||||
            imageminMozJpeg({
 | 
			
		||||
                quality: 50
 | 
			
		||||
            }),
 | 
			
		||||
            imageminPngQuant({
 | 
			
		||||
                quality: "0-70"
 | 
			
		||||
            }),
 | 
			
		||||
            imageminGifLossy({
 | 
			
		||||
                lossy: 80,
 | 
			
		||||
                optimize: '3' // needs to be string
 | 
			
		||||
            })
 | 
			
		||||
        ]
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
@@ -66,7 +66,7 @@ async function importNotes(dir, parentNoteId) {
 | 
			
		||||
        const noteText = fs.readFileSync(path, "utf8");
 | 
			
		||||
 | 
			
		||||
        const noteId = utils.newNoteId();
 | 
			
		||||
        const noteTreeId = utils.newnoteRevisionId();
 | 
			
		||||
        const noteTreeId = utils.newNoteRevisionId();
 | 
			
		||||
 | 
			
		||||
        const now = utils.nowDate();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ const wrap = require('express-promise-wrap').wrap;
 | 
			
		||||
router.post('/sync', wrap(async (req, res, next) => {
 | 
			
		||||
    const timestampStr = req.body.timestamp;
 | 
			
		||||
 | 
			
		||||
    const timestamp = utils.parseDate(timestampStr);
 | 
			
		||||
    const timestamp = utils.parseDateTime(timestampStr);
 | 
			
		||||
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -58,15 +58,114 @@ router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    const search = '%' + utils.sanitizeSql(req.query.search) + '%';
 | 
			
		||||
    let {attrFilters, searchText} = parseFilters(req.query.search);
 | 
			
		||||
 | 
			
		||||
    // searching in protected notes is pointless because of encryption
 | 
			
		||||
    const noteIds = await sql.getColumn(`SELECT noteId FROM notes 
 | 
			
		||||
              WHERE isDeleted = 0 AND isProtected = 0 AND (title LIKE ? OR content LIKE ?)`, [search, search]);
 | 
			
		||||
    const {query, params} = getSearchQuery(attrFilters, searchText);
 | 
			
		||||
 | 
			
		||||
    console.log(query, params);
 | 
			
		||||
 | 
			
		||||
    const noteIds = await sql.getColumn(query, params);
 | 
			
		||||
 | 
			
		||||
    res.send(noteIds);
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
function parseFilters(searchText) {
 | 
			
		||||
    const attrFilters = [];
 | 
			
		||||
 | 
			
		||||
    const attrRegex = /(\b(and|or)\s+)?@(!?)([\w_-]+|"[^"]+")((=|!=|<|<=|>|>=)([\w_-]+|"[^"]+"))?/i;
 | 
			
		||||
 | 
			
		||||
    let match = attrRegex.exec(searchText);
 | 
			
		||||
 | 
			
		||||
    function trimQuotes(str) { return str.startsWith('"') ? str.substr(1, str.length - 2) : str; }
 | 
			
		||||
 | 
			
		||||
    while (match != null) {
 | 
			
		||||
        const relation = match[2] !== undefined ? match[2].toLowerCase() : 'and';
 | 
			
		||||
        const operator = match[3] === '!' ? 'not-exists' : 'exists';
 | 
			
		||||
 | 
			
		||||
        attrFilters.push({
 | 
			
		||||
            relation: relation,
 | 
			
		||||
            name: trimQuotes(match[4]),
 | 
			
		||||
            operator: match[6] !== undefined ? match[6] : operator,
 | 
			
		||||
            value: match[7] !== undefined ? trimQuotes(match[7]) : null
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // remove attributes from further fulltext search
 | 
			
		||||
        searchText = searchText.split(match[0]).join('');
 | 
			
		||||
 | 
			
		||||
        match = attrRegex.exec(searchText);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {attrFilters, searchText};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getSearchQuery(attrFilters, searchText) {
 | 
			
		||||
    const joins = [];
 | 
			
		||||
    const joinParams = [];
 | 
			
		||||
    let where = '1';
 | 
			
		||||
    const whereParams = [];
 | 
			
		||||
 | 
			
		||||
    let i = 1;
 | 
			
		||||
 | 
			
		||||
    for (const filter of attrFilters) {
 | 
			
		||||
        joins.push(`LEFT JOIN attributes AS attr${i} ON attr${i}.noteId = notes.noteId AND attr${i}.name = ?`);
 | 
			
		||||
        joinParams.push(filter.name);
 | 
			
		||||
 | 
			
		||||
        where += " " + filter.relation + " ";
 | 
			
		||||
 | 
			
		||||
        if (filter.operator === 'exists') {
 | 
			
		||||
            where += `attr${i}.attributeId IS NOT NULL`;
 | 
			
		||||
        }
 | 
			
		||||
        else if (filter.operator === 'not-exists') {
 | 
			
		||||
            where += `attr${i}.attributeId IS NULL`;
 | 
			
		||||
        }
 | 
			
		||||
        else if (filter.operator === '=' || filter.operator === '!=') {
 | 
			
		||||
            where += `attr${i}.value ${filter.operator} ?`;
 | 
			
		||||
            whereParams.push(filter.value);
 | 
			
		||||
        }
 | 
			
		||||
        else if ([">", ">=", "<", "<="].includes(filter.operator)) {
 | 
			
		||||
            const floatParam = parseFloat(filter.value);
 | 
			
		||||
 | 
			
		||||
            if (isNaN(floatParam)) {
 | 
			
		||||
                where += `attr${i}.value ${filter.operator} ?`;
 | 
			
		||||
                whereParams.push(filter.value);
 | 
			
		||||
            }
 | 
			
		||||
            else {
 | 
			
		||||
                where += `CAST(attr${i}.value AS DECIMAL) ${filter.operator} ?`;
 | 
			
		||||
                whereParams.push(floatParam);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            throw new Error("Unknown operator " + filter.operator);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        i++;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let searchCondition = '';
 | 
			
		||||
    const searchParams = [];
 | 
			
		||||
 | 
			
		||||
    if (searchText.trim() !== '') {
 | 
			
		||||
        // searching in protected notes is pointless because of encryption
 | 
			
		||||
        searchCondition = ' AND (notes.isProtected = 0 AND (notes.title LIKE ? OR notes.content LIKE ?))';
 | 
			
		||||
 | 
			
		||||
        searchText = '%' + searchText.trim() + '%';
 | 
			
		||||
 | 
			
		||||
        searchParams.push(searchText);
 | 
			
		||||
        searchParams.push(searchText); // two occurences in searchCondition
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const query = `SELECT DISTINCT notes.noteId FROM notes
 | 
			
		||||
            ${joins.join('\r\n')}
 | 
			
		||||
              WHERE 
 | 
			
		||||
                notes.isDeleted = 0
 | 
			
		||||
                AND (${where}) 
 | 
			
		||||
                ${searchCondition}`;
 | 
			
		||||
 | 
			
		||||
    const params = joinParams.concat(whereParams).concat(searchParams);
 | 
			
		||||
 | 
			
		||||
    return { query, params };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
router.put('/:noteId/sort', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    const noteId = req.params.noteId;
 | 
			
		||||
    const sourceId = req.headers.source_id;
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,8 @@ async function getRecentNotes() {
 | 
			
		||||
        recent_notes.isDeleted = 0
 | 
			
		||||
        AND note_tree.isDeleted = 0
 | 
			
		||||
      ORDER BY 
 | 
			
		||||
        dateAccessed DESC`);
 | 
			
		||||
        dateAccessed DESC
 | 
			
		||||
      LIMIT 200`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
							
								
								
									
										106
									
								
								src/routes/api/sender.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/routes/api/sender.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const express = require('express');
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const image = require('../../services/image');
 | 
			
		||||
const utils = require('../../services/utils');
 | 
			
		||||
const date_notes = require('../../services/date_notes');
 | 
			
		||||
const sql = require('../../services/sql');
 | 
			
		||||
const wrap = require('express-promise-wrap').wrap;
 | 
			
		||||
const notes = require('../../services/notes');
 | 
			
		||||
const multer = require('multer')();
 | 
			
		||||
const password_encryption = require('../../services/password_encryption');
 | 
			
		||||
const options = require('../../services/options');
 | 
			
		||||
const sync_table = require('../../services/sync_table');
 | 
			
		||||
 | 
			
		||||
router.post('/login', wrap(async (req, res, next) => {
 | 
			
		||||
    const username = req.body.username;
 | 
			
		||||
    const password = req.body.password;
 | 
			
		||||
 | 
			
		||||
    const isUsernameValid = username === await options.getOption('username');
 | 
			
		||||
    const isPasswordValid = await password_encryption.verifyPassword(password);
 | 
			
		||||
 | 
			
		||||
    if (!isUsernameValid || !isPasswordValid) {
 | 
			
		||||
        res.status(401).send("Incorrect username/password");
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
        const token = utils.randomSecureToken();
 | 
			
		||||
 | 
			
		||||
        await sql.doInTransaction(async () => {
 | 
			
		||||
            const apiTokenId = utils.newApiTokenId();
 | 
			
		||||
 | 
			
		||||
            await sql.insert("api_tokens", {
 | 
			
		||||
                apiTokenId: apiTokenId,
 | 
			
		||||
                token: token,
 | 
			
		||||
                dateCreated: utils.nowDate(),
 | 
			
		||||
                isDeleted: false
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            await sync_table.addApiTokenSync(apiTokenId);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        res.send({
 | 
			
		||||
            token: token
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
async function checkSenderToken(req, res, next) {
 | 
			
		||||
    const token = req.headers.authorization;
 | 
			
		||||
 | 
			
		||||
    if (await sql.getValue("SELECT COUNT(*) FROM api_tokens WHERE isDeleted = 0 AND token = ?", [token]) === 0) {
 | 
			
		||||
        res.status(401).send("Not authorized");
 | 
			
		||||
    }
 | 
			
		||||
    else if (await sql.isDbUpToDate()) {
 | 
			
		||||
        next();
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
        res.status(409).send("Mismatched app versions"); // need better response than that
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
router.post('/image', checkSenderToken, multer.single('upload'), wrap(async (req, res, next) => {
 | 
			
		||||
    const file = req.file;
 | 
			
		||||
 | 
			
		||||
    if (!["image/png", "image/jpeg", "image/gif"].includes(file.mimetype)) {
 | 
			
		||||
        return res.status(400).send("Unknown image type: " + file.mimetype);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const parentNoteId = await date_notes.getDateNoteId(req.headers['x-local-date']);
 | 
			
		||||
 | 
			
		||||
    const noteId = (await notes.createNewNote(parentNoteId, {
 | 
			
		||||
        title: "Sender image",
 | 
			
		||||
        content: "",
 | 
			
		||||
        target: 'into',
 | 
			
		||||
        isProtected: false,
 | 
			
		||||
        type: 'text',
 | 
			
		||||
        mime: 'text/html'
 | 
			
		||||
    })).noteId;
 | 
			
		||||
 | 
			
		||||
    const {fileName, imageId} = await image.saveImage(file, null, noteId);
 | 
			
		||||
 | 
			
		||||
    const url = `/api/images/${imageId}/${fileName}`;
 | 
			
		||||
 | 
			
		||||
    const content = `<img src="${url}"/>`;
 | 
			
		||||
 | 
			
		||||
    await sql.execute("UPDATE notes SET content = ? WHERE noteId = ?", [content, noteId]);
 | 
			
		||||
 | 
			
		||||
    res.send({});
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.post('/note', checkSenderToken, wrap(async (req, res, next) => {
 | 
			
		||||
    const parentNoteId = await date_notes.getDateNoteId(req.headers['x-local-date']);
 | 
			
		||||
 | 
			
		||||
    await notes.createNewNote(parentNoteId, {
 | 
			
		||||
        title: req.body.title,
 | 
			
		||||
        content: req.body.content,
 | 
			
		||||
        target: 'into',
 | 
			
		||||
        isProtected: false,
 | 
			
		||||
        type: 'text',
 | 
			
		||||
        mime: 'text/html'
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    res.send({});
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
@@ -147,6 +147,12 @@ router.get('/attributes/:attributeId', auth.checkApiAuth, wrap(async (req, res,
 | 
			
		||||
    res.send(await sql.getRow("SELECT * FROM attributes WHERE attributeId = ?", [attributeId]));
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.get('/api_tokens/:apiTokenId', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    const apiTokenId = req.params.apiTokenId;
 | 
			
		||||
 | 
			
		||||
    res.send(await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [apiTokenId]));
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.put('/notes', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    await syncUpdate.updateNote(req.body.entity, req.body.sourceId);
 | 
			
		||||
 | 
			
		||||
@@ -201,4 +207,10 @@ router.put('/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    res.send({});
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
router.put('/api_tokens', auth.checkApiAuth, wrap(async (req, res, next) => {
 | 
			
		||||
    await syncUpdate.updateApiToken(req.body.entity, req.body.sourceId);
 | 
			
		||||
 | 
			
		||||
    res.send({});
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
@@ -28,6 +28,7 @@ const cleanupRoute = require('./api/cleanup');
 | 
			
		||||
const imageRoute = require('./api/image');
 | 
			
		||||
const attributesRoute = require('./api/attributes');
 | 
			
		||||
const scriptRoute = require('./api/script');
 | 
			
		||||
const senderRoute = require('./api/sender');
 | 
			
		||||
 | 
			
		||||
function register(app) {
 | 
			
		||||
    app.use('/', indexRoute);
 | 
			
		||||
@@ -40,7 +41,7 @@ function register(app) {
 | 
			
		||||
    app.use('/api/notes', notesApiRoute);
 | 
			
		||||
    app.use('/api/tree', treeChangesApiRoute);
 | 
			
		||||
    app.use('/api/notes', cloningApiRoute);
 | 
			
		||||
    app.use('/api/notes', attributesRoute);
 | 
			
		||||
    app.use('/api', attributesRoute);
 | 
			
		||||
    app.use('/api/notes-history', noteHistoryApiRoute);
 | 
			
		||||
    app.use('/api/recent-changes', recentChangesApiRoute);
 | 
			
		||||
    app.use('/api/settings', settingsApiRoute);
 | 
			
		||||
@@ -59,6 +60,7 @@ function register(app) {
 | 
			
		||||
    app.use('/api/cleanup', cleanupRoute);
 | 
			
		||||
    app.use('/api/images', imageRoute);
 | 
			
		||||
    app.use('/api/script', scriptRoute);
 | 
			
		||||
    app.use('/api/sender', senderRoute);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
const build = require('./build');
 | 
			
		||||
const packageJson = require('../../package');
 | 
			
		||||
 | 
			
		||||
const APP_DB_VERSION = 71;
 | 
			
		||||
const APP_DB_VERSION = 75;
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    app_version: packageJson.version,
 | 
			
		||||
 
 | 
			
		||||
@@ -5,13 +5,18 @@ const utils = require('./utils');
 | 
			
		||||
const sync_table = require('./sync_table');
 | 
			
		||||
const Repository = require('./repository');
 | 
			
		||||
 | 
			
		||||
const BUILTIN_ATTRIBUTES = [ 'run_on_startup', 'disable_versioning', 'calendar_root' ];
 | 
			
		||||
 | 
			
		||||
async function getNoteAttributeMap(noteId) {
 | 
			
		||||
    return await sql.getMap(`SELECT name, value FROM attributes WHERE noteId = ?`, [noteId]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getNoteIdWithAttribute(name, value) {
 | 
			
		||||
    return await sql.getValue(`SELECT notes.noteId FROM notes JOIN attributes USING(noteId) 
 | 
			
		||||
          WHERE notes.isDeleted = 0 AND attributes.name = ? AND attributes.value = ?`, [name, value]);
 | 
			
		||||
          WHERE notes.isDeleted = 0
 | 
			
		||||
                AND attributes.isDeleted = 0
 | 
			
		||||
                AND attributes.name = ? 
 | 
			
		||||
                AND attributes.value = ?`, [name, value]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getNotesWithAttribute(dataKey, name, value) {
 | 
			
		||||
@@ -21,11 +26,11 @@ async function getNotesWithAttribute(dataKey, name, value) {
 | 
			
		||||
 | 
			
		||||
    if (value !== undefined) {
 | 
			
		||||
        notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN attributes USING(noteId) 
 | 
			
		||||
          WHERE notes.isDeleted = 0 AND attributes.name = ? AND attributes.value = ?`, [name, value]);
 | 
			
		||||
          WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name = ? AND attributes.value = ?`, [name, value]);
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
        notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN attributes USING(noteId) 
 | 
			
		||||
          WHERE notes.isDeleted = 0 AND attributes.name = ?`, [name]);
 | 
			
		||||
          WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name = ?`, [name]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return notes;
 | 
			
		||||
@@ -39,7 +44,7 @@ async function getNoteWithAttribute(dataKey, name, value) {
 | 
			
		||||
 | 
			
		||||
async function getNoteIdsWithAttribute(name) {
 | 
			
		||||
    return await sql.getColumn(`SELECT DISTINCT notes.noteId FROM notes JOIN attributes USING(noteId) 
 | 
			
		||||
          WHERE notes.isDeleted = 0 AND attributes.name = ?`, [name]);
 | 
			
		||||
          WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name = ? AND attributes.isDeleted = 0`, [name]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function createAttribute(noteId, name, value = null, sourceId = null) {
 | 
			
		||||
@@ -52,7 +57,8 @@ async function createAttribute(noteId, name, value = null, sourceId = null) {
 | 
			
		||||
        name: name,
 | 
			
		||||
        value: value,
 | 
			
		||||
        dateModified: now,
 | 
			
		||||
        dateCreated: now
 | 
			
		||||
        dateCreated: now,
 | 
			
		||||
        isDeleted: false
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await sync_table.addAttributeSync(attributeId, sourceId);
 | 
			
		||||
@@ -64,5 +70,6 @@ module.exports = {
 | 
			
		||||
    getNotesWithAttribute,
 | 
			
		||||
    getNoteWithAttribute,
 | 
			
		||||
    getNoteIdsWithAttribute,
 | 
			
		||||
    createAttribute
 | 
			
		||||
    createAttribute,
 | 
			
		||||
    BUILTIN_ATTRIBUTES
 | 
			
		||||
};
 | 
			
		||||
@@ -10,7 +10,7 @@ const sync_mutex = require('./sync_mutex');
 | 
			
		||||
 | 
			
		||||
async function regularBackup() {
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
    const lastBackupDate = utils.parseDate(await options.getOption('last_backup_date'));
 | 
			
		||||
    const lastBackupDate = utils.parseDateTime(await options.getOption('last_backup_date'));
 | 
			
		||||
 | 
			
		||||
    console.log(lastBackupDate);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -223,6 +223,8 @@ async function runAllChecks() {
 | 
			
		||||
    await runSyncRowChecks("recent_notes", "noteTreeId", errorList);
 | 
			
		||||
    await runSyncRowChecks("images", "imageId", errorList);
 | 
			
		||||
    await runSyncRowChecks("note_images", "noteImageId", errorList);
 | 
			
		||||
    await runSyncRowChecks("attributes", "attributeId", errorList);
 | 
			
		||||
    await runSyncRowChecks("api_tokens", "apiTokenId", errorList);
 | 
			
		||||
 | 
			
		||||
    if (errorList.length === 0) {
 | 
			
		||||
        // we run this only if basic checks passed since this assumes basic data consistency
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,16 @@
 | 
			
		||||
const sql = require('./sql');
 | 
			
		||||
const notes = require('./notes');
 | 
			
		||||
const attributes = require('./attributes');
 | 
			
		||||
const utils = require('./utils');
 | 
			
		||||
 | 
			
		||||
const CALENDAR_ROOT_ATTRIBUTE = 'calendar_root';
 | 
			
		||||
const YEAR_ATTRIBUTE = 'year_note';
 | 
			
		||||
const MONTH_ATTRIBUTE = 'month_note';
 | 
			
		||||
const DATE_ATTRIBUTE = 'date_note';
 | 
			
		||||
 | 
			
		||||
const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
 | 
			
		||||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
 | 
			
		||||
 | 
			
		||||
async function createNote(parentNoteId, noteTitle, noteText) {
 | 
			
		||||
    return (await notes.createNewNote(parentNoteId, {
 | 
			
		||||
        title: noteTitle,
 | 
			
		||||
@@ -72,7 +76,11 @@ async function getMonthNoteId(dateTimeStr, rootNoteId) {
 | 
			
		||||
        monthNoteId = await getNoteStartingWith(yearNoteId, monthNumber);
 | 
			
		||||
 | 
			
		||||
        if (!monthNoteId) {
 | 
			
		||||
            monthNoteId = await createNote(yearNoteId, monthNumber);
 | 
			
		||||
            const dateObj = utils.parseDate(dateTimeStr);
 | 
			
		||||
 | 
			
		||||
            const noteTitle = monthNumber + " - " + MONTHS[dateObj.getMonth()];
 | 
			
		||||
 | 
			
		||||
            monthNoteId = await createNote(yearNoteId, noteTitle);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await attributes.createAttribute(monthNoteId, MONTH_ATTRIBUTE, monthStr);
 | 
			
		||||
@@ -97,7 +105,11 @@ async function getDateNoteId(dateTimeStr, rootNoteId = null) {
 | 
			
		||||
        dateNoteId = await getNoteStartingWith(monthNoteId, dayNumber);
 | 
			
		||||
 | 
			
		||||
        if (!dateNoteId) {
 | 
			
		||||
            dateNoteId = await createNote(monthNoteId, dayNumber);
 | 
			
		||||
            const dateObj = utils.parseDate(dateTimeStr);
 | 
			
		||||
 | 
			
		||||
            const noteTitle = dayNumber + " - " + DAYS[dateObj.getDay()];
 | 
			
		||||
 | 
			
		||||
            dateNoteId = await createNote(monthNoteId, noteTitle);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await attributes.createAttribute(dateNoteId, DATE_ATTRIBUTE, dateStr);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										108
									
								
								src/services/image.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/services/image.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
const utils = require('./utils');
 | 
			
		||||
const sql = require('./sql');
 | 
			
		||||
const sync_table = require('./sync_table');
 | 
			
		||||
const imagemin = require('imagemin');
 | 
			
		||||
const imageminMozJpeg = require('imagemin-mozjpeg');
 | 
			
		||||
const imageminPngQuant = require('imagemin-pngquant');
 | 
			
		||||
const imageminGifLossy = require('imagemin-giflossy');
 | 
			
		||||
const jimp = require('jimp');
 | 
			
		||||
const imageType = require('image-type');
 | 
			
		||||
const sanitizeFilename = require('sanitize-filename');
 | 
			
		||||
 | 
			
		||||
async function saveImage(file, sourceId, noteId) {
 | 
			
		||||
    const resizedImage = await resize(file.buffer);
 | 
			
		||||
    const optimizedImage = await optimize(resizedImage);
 | 
			
		||||
 | 
			
		||||
    const imageFormat = imageType(optimizedImage);
 | 
			
		||||
 | 
			
		||||
    const fileNameWithouExtension = file.originalname.replace(/\.[^/.]+$/, "");
 | 
			
		||||
    const fileName = sanitizeFilename(fileNameWithouExtension + "." + imageFormat.ext);
 | 
			
		||||
 | 
			
		||||
    const imageId = utils.newImageId();
 | 
			
		||||
    const now = utils.nowDate();
 | 
			
		||||
 | 
			
		||||
    await sql.doInTransaction(async () => {
 | 
			
		||||
        await sql.insert("images", {
 | 
			
		||||
            imageId: imageId,
 | 
			
		||||
            format: imageFormat.ext,
 | 
			
		||||
            name: fileName,
 | 
			
		||||
            checksum: utils.hash(optimizedImage),
 | 
			
		||||
            data: optimizedImage,
 | 
			
		||||
            isDeleted: 0,
 | 
			
		||||
            dateModified: now,
 | 
			
		||||
            dateCreated: now
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await sync_table.addImageSync(imageId, sourceId);
 | 
			
		||||
 | 
			
		||||
        const noteImageId = utils.newNoteImageId();
 | 
			
		||||
 | 
			
		||||
        await sql.insert("note_images", {
 | 
			
		||||
            noteImageId: noteImageId,
 | 
			
		||||
            noteId: noteId,
 | 
			
		||||
            imageId: imageId,
 | 
			
		||||
            isDeleted: 0,
 | 
			
		||||
            dateModified: now,
 | 
			
		||||
            dateCreated: now
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await sync_table.addNoteImageSync(noteImageId, sourceId);
 | 
			
		||||
    });
 | 
			
		||||
    return {fileName, imageId};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MAX_SIZE = 1000;
 | 
			
		||||
const MAX_BYTE_SIZE = 200000; // images should have under 100 KBs
 | 
			
		||||
 | 
			
		||||
async function resize(buffer) {
 | 
			
		||||
    const image = await jimp.read(buffer);
 | 
			
		||||
 | 
			
		||||
    if (image.bitmap.width > image.bitmap.height && image.bitmap.width > MAX_SIZE) {
 | 
			
		||||
        image.resize(MAX_SIZE, jimp.AUTO);
 | 
			
		||||
    }
 | 
			
		||||
    else if (image.bitmap.height > MAX_SIZE) {
 | 
			
		||||
        image.resize(jimp.AUTO, MAX_SIZE);
 | 
			
		||||
    }
 | 
			
		||||
    else if (buffer.byteLength <= MAX_BYTE_SIZE) {
 | 
			
		||||
        return buffer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // we do resizing with max quality which will be trimmed during optimization step next
 | 
			
		||||
    image.quality(100);
 | 
			
		||||
 | 
			
		||||
    // when converting PNG to JPG we lose alpha channel, this is replaced by white to match Trilium white background
 | 
			
		||||
    image.background(0xFFFFFFFF);
 | 
			
		||||
 | 
			
		||||
    // getBuffer doesn't support promises so this workaround
 | 
			
		||||
    return await new Promise((resolve, reject) => image.getBuffer(jimp.MIME_JPEG, (err, data) => {
 | 
			
		||||
        if (err) {
 | 
			
		||||
            reject(err);
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            resolve(data);
 | 
			
		||||
        }
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function optimize(buffer) {
 | 
			
		||||
    return await imagemin.buffer(buffer, {
 | 
			
		||||
        plugins: [
 | 
			
		||||
            imageminMozJpeg({
 | 
			
		||||
                quality: 50
 | 
			
		||||
            }),
 | 
			
		||||
            imageminPngQuant({
 | 
			
		||||
                quality: "0-70"
 | 
			
		||||
            }),
 | 
			
		||||
            imageminGifLossy({
 | 
			
		||||
                lossy: 80,
 | 
			
		||||
                optimize: '3' // needs to be string
 | 
			
		||||
            })
 | 
			
		||||
        ]
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    saveImage
 | 
			
		||||
};
 | 
			
		||||
@@ -154,10 +154,10 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
 | 
			
		||||
        note.isProtected = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const newnoteRevisionId = utils.newnoteRevisionId();
 | 
			
		||||
    const newNoteRevisionId = utils.newNoteRevisionId();
 | 
			
		||||
 | 
			
		||||
    await sql.insert('note_revisions', {
 | 
			
		||||
        noteRevisionId: newnoteRevisionId,
 | 
			
		||||
        noteRevisionId: newNoteRevisionId,
 | 
			
		||||
        noteId: noteId,
 | 
			
		||||
        // title and text should be decrypted now
 | 
			
		||||
        title: oldNote.title,
 | 
			
		||||
@@ -167,7 +167,7 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
 | 
			
		||||
        dateModifiedTo: nowStr
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await sync_table.addNoteHistorySync(newnoteRevisionId, sourceId);
 | 
			
		||||
    await sync_table.addNoteHistorySync(newNoteRevisionId, sourceId);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function saveNoteImages(noteId, noteText, sourceId) {
 | 
			
		||||
@@ -235,7 +235,7 @@ async function updateNote(noteId, newNote, dataKey, sourceId) {
 | 
			
		||||
        "SELECT noteRevisionId FROM note_revisions WHERE noteId = ? AND dateModifiedTo >= ?", [noteId, historyCutoff]);
 | 
			
		||||
 | 
			
		||||
    await sql.doInTransaction(async () => {
 | 
			
		||||
        const msSinceDateCreated = now.getTime() - utils.parseDate(newNote.detail.dateCreated).getTime();
 | 
			
		||||
        const msSinceDateCreated = now.getTime() - utils.parseDateTime(newNote.detail.dateCreated).getTime();
 | 
			
		||||
 | 
			
		||||
        if (attributesMap.disable_versioning !== 'true'
 | 
			
		||||
            && !existingnoteRevisionId
 | 
			
		||||
 
 | 
			
		||||
@@ -149,6 +149,9 @@ async function pullSync(syncContext) {
 | 
			
		||||
        else if (sync.entityName === 'attributes') {
 | 
			
		||||
            await syncUpdate.updateAttribute(resp, syncContext.sourceId);
 | 
			
		||||
        }
 | 
			
		||||
        else if (sync.entityName === 'api_tokens') {
 | 
			
		||||
            await syncUpdate.updateApiToken(resp, syncContext.sourceId);
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`);
 | 
			
		||||
        }
 | 
			
		||||
@@ -233,6 +236,9 @@ async function pushEntity(sync, syncContext) {
 | 
			
		||||
    else if (sync.entityName === 'attributes') {
 | 
			
		||||
        entity = await sql.getRow('SELECT * FROM attributes WHERE attributeId = ?', [sync.entityId]);
 | 
			
		||||
    }
 | 
			
		||||
    else if (sync.entityName === 'api_tokens') {
 | 
			
		||||
        entity = await sql.getRow('SELECT * FROM api_tokens WHERE apiTokenId = ?', [sync.entityId]);
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
        throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`);
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -40,6 +40,10 @@ async function addAttributeSync(attributeId, sourceId) {
 | 
			
		||||
    await addEntitySync("attributes", attributeId, sourceId);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function addApiTokenSync(apiTokenId, sourceId) {
 | 
			
		||||
    await addEntitySync("api_tokens", apiTokenId, sourceId);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function addEntitySync(entityName, entityId, sourceId) {
 | 
			
		||||
    await sql.replace("sync", {
 | 
			
		||||
        entityName: entityName,
 | 
			
		||||
@@ -93,6 +97,7 @@ async function fillAllSyncRows() {
 | 
			
		||||
    await fillSyncRows("images", "imageId");
 | 
			
		||||
    await fillSyncRows("note_images", "noteImageId");
 | 
			
		||||
    await fillSyncRows("attributes", "attributeId");
 | 
			
		||||
    await fillSyncRows("api_tokens", "apiTokenId");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
@@ -105,6 +110,7 @@ module.exports = {
 | 
			
		||||
    addImageSync,
 | 
			
		||||
    addNoteImageSync,
 | 
			
		||||
    addAttributeSync,
 | 
			
		||||
    addApiTokenSync,
 | 
			
		||||
    addEntitySync,
 | 
			
		||||
    cleanupSyncRowsForMissingEntities,
 | 
			
		||||
    fillAllSyncRows
 | 
			
		||||
 
 | 
			
		||||
@@ -137,6 +137,20 @@ async function updateAttribute(entity, sourceId) {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function updateApiToken(entity, sourceId) {
 | 
			
		||||
    const apiTokenId = await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [entity.apiTokenId]);
 | 
			
		||||
 | 
			
		||||
    if (!apiTokenId) {
 | 
			
		||||
        await sql.doInTransaction(async () => {
 | 
			
		||||
            await sql.replace("api_tokens", entity);
 | 
			
		||||
 | 
			
		||||
            await sync_table.addApiTokenSync(entity.apiTokenId, sourceId);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        log.info("Update/sync API token " + entity.apiTokenId);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    updateNote,
 | 
			
		||||
    updateNoteTree,
 | 
			
		||||
@@ -146,5 +160,6 @@ module.exports = {
 | 
			
		||||
    updateRecentNotes,
 | 
			
		||||
    updateImage,
 | 
			
		||||
    updateNoteImage,
 | 
			
		||||
    updateAttribute
 | 
			
		||||
    updateAttribute,
 | 
			
		||||
    updateApiToken
 | 
			
		||||
};
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
 | 
			
		||||
const sql = require('./sql');
 | 
			
		||||
const sync_table = require('./sync_table');
 | 
			
		||||
const protected_session = require('./protected_session');
 | 
			
		||||
 | 
			
		||||
async function validateParentChild(res, parentNoteId, childNoteId, noteTreeId = null) {
 | 
			
		||||
    const existing = await getExistingNoteTree(parentNoteId, childNoteId);
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ function newNoteTreeId() {
 | 
			
		||||
    return randomString(12);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function newnoteRevisionId() {
 | 
			
		||||
function newNoteRevisionId() {
 | 
			
		||||
    return randomString(12);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -27,6 +27,10 @@ function newAttributeId() {
 | 
			
		||||
    return randomString(12);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function newApiTokenId() {
 | 
			
		||||
    return randomString(12);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function randomString(length) {
 | 
			
		||||
    return randtoken.generate(length);
 | 
			
		||||
}
 | 
			
		||||
@@ -47,7 +51,7 @@ function dateStr(date) {
 | 
			
		||||
 * @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr().
 | 
			
		||||
 *              also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time
 | 
			
		||||
 */
 | 
			
		||||
function parseDate(str) {
 | 
			
		||||
function parseDateTime(str) {
 | 
			
		||||
    try {
 | 
			
		||||
        return new Date(Date.parse(str));
 | 
			
		||||
    }
 | 
			
		||||
@@ -56,6 +60,12 @@ function parseDate(str) {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function parseDate(str) {
 | 
			
		||||
    const datePart = str.substr(0, 10);
 | 
			
		||||
 | 
			
		||||
    return parseDateTime(datePart + "T12:00:00.000Z");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function toBase64(plainText) {
 | 
			
		||||
    return Buffer.from(plainText).toString('base64');
 | 
			
		||||
}
 | 
			
		||||
@@ -117,12 +127,14 @@ module.exports = {
 | 
			
		||||
    nowDate,
 | 
			
		||||
    dateStr,
 | 
			
		||||
    parseDate,
 | 
			
		||||
    parseDateTime,
 | 
			
		||||
    newNoteId,
 | 
			
		||||
    newNoteTreeId,
 | 
			
		||||
    newnoteRevisionId,
 | 
			
		||||
    newNoteRevisionId,
 | 
			
		||||
    newImageId,
 | 
			
		||||
    newNoteImageId,
 | 
			
		||||
    newAttributeId,
 | 
			
		||||
    newApiTokenId,
 | 
			
		||||
    toBase64,
 | 
			
		||||
    fromBase64,
 | 
			
		||||
    hmac,
 | 
			
		||||
 
 | 
			
		||||
@@ -56,14 +56,13 @@
 | 
			
		||||
            <img src="images/icons/search.png" alt="Search in notes"/>
 | 
			
		||||
          </a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
        <div id="search-box" style="display: none; padding: 10px; margin-top: 10px;">
 | 
			
		||||
          <p>
 | 
			
		||||
      <div id="search-box" class="hide-toggle" style="grid-area: search; display: none; padding: 10px; margin-top: 10px;">
 | 
			
		||||
        <div style="display: flex; align-items: center;">
 | 
			
		||||
          <label>Search:</label>
 | 
			
		||||
            <input name="search-text" autocomplete="off">
 | 
			
		||||
            <button id="reset-search-button">×</button>
 | 
			
		||||
            <span id="matches"></span>
 | 
			
		||||
          </p>
 | 
			
		||||
          <input name="search-text" style="flex-grow: 100; margin-left: 5px; margin-right: 5px;" autocomplete="off">
 | 
			
		||||
          <button id="reset-search-button" class="btn btn-sm" title="Reset search">×</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@@ -73,7 +72,7 @@
 | 
			
		||||
      <div id="parent-list" class="hide-toggle">
 | 
			
		||||
        <p><strong>Note locations:</strong></p>
 | 
			
		||||
 | 
			
		||||
        <ul id="parent-list-list"></ul>
 | 
			
		||||
        <ul id="parent-list-inner"></ul>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="hide-toggle" style="grid-area: title;">
 | 
			
		||||
@@ -143,23 +142,16 @@
 | 
			
		||||
 | 
			
		||||
        <div id="note-detail-render"></div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div id="attribute-list">
 | 
			
		||||
        <button class="btn btn-sm" onclick="attributesDialog.showDialog();">Attributes:</button>
 | 
			
		||||
 | 
			
		||||
        <span id="attribute-list-inner"></span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="recent-notes-dialog" title="Recent notes" style="display: none;">
 | 
			
		||||
      <select id="recent-notes-select-box" size="20" style="width: 100%">
 | 
			
		||||
      </select>
 | 
			
		||||
 | 
			
		||||
      <br/><br/>
 | 
			
		||||
 | 
			
		||||
      <p>
 | 
			
		||||
        <button class="btn btn-sm" id="recent-notes-jump-to">Jump to <kbd>enter</kbd></button>
 | 
			
		||||
         
 | 
			
		||||
        <button class="btn btn-sm" id="recent-notes-add-link">Add link <kbd>l</kbd></button>
 | 
			
		||||
 | 
			
		||||
        <button class="btn btn-sm" id="recent-notes-add-current-as-child">Add current as child <kbd>c</kbd></button>
 | 
			
		||||
 | 
			
		||||
        <button class="btn btn-sm" id="recent-notes-add-recent-as-child">Add recent as child <kbd>r</kbd></button>
 | 
			
		||||
      </p>
 | 
			
		||||
      <input id="recent-notes-search-input" class="form-control"/>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="add-link-dialog" title="Add link" style="display: none;">
 | 
			
		||||
@@ -368,8 +360,11 @@
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div id="sql-console-dialog" title="SQL console" style="display: none; padding: 20px;">
 | 
			
		||||
      <textarea style="width: 100%; height: 100px" id="sql-console-query"></textarea>
 | 
			
		||||
      <div style="height: 150px; width: 100%; border: 1px solid #ccc; margin-bottom: 10px;" id="sql-console-query"></div>
 | 
			
		||||
 | 
			
		||||
      <div style="text-align: center">
 | 
			
		||||
        <button class="btn btn-danger" id="sql-console-execute">Execute <kbd>CTRL+ENTER</kbd></button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <table id="sql-console-results" class="table table-striped" style="overflow: scroll; width: 100%;">
 | 
			
		||||
        <thead></thead>
 | 
			
		||||
@@ -383,29 +378,40 @@
 | 
			
		||||
 | 
			
		||||
    <div id="attributes-dialog" title="Note attributes" style="display: none; padding: 20px;">
 | 
			
		||||
      <form data-bind="submit: save">
 | 
			
		||||
      <div style="display: flex; justify-content: space-between; padding: 15px; padding-top: 0;">
 | 
			
		||||
        <button class="btn-default" type="button" data-bind="click: addNewRow">Add new attribute</button>
 | 
			
		||||
 | 
			
		||||
        <button class="btn-primary" type="submit">Save</button>
 | 
			
		||||
      <div style="text-align: center">
 | 
			
		||||
        <button class="btn btn-large" style="width: 200px;" id="save-attributes-button" type="submit">Save changes <kbd>enter</kbd></button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div style="height: 97%; overflow: auto">
 | 
			
		||||
        <table id="attributes-table" class="table">
 | 
			
		||||
          <thead>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <th></th>
 | 
			
		||||
              <th>ID</th>
 | 
			
		||||
              <th>Name</th>
 | 
			
		||||
              <th>Value</th>
 | 
			
		||||
              <th></th>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </thead>
 | 
			
		||||
          <tbody data-bind="foreach: attributes">
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td data-bind="text: attributeId"></td>
 | 
			
		||||
            <tr data-bind="if: isDeleted == 0">
 | 
			
		||||
              <td class="handle">
 | 
			
		||||
                <span class="glyphicon glyphicon-resize-vertical"></span>
 | 
			
		||||
                <input type="hidden" name="position" data-bind="value: position"/>
 | 
			
		||||
              </td>
 | 
			
		||||
              <!-- ID column has specific width because if it's empty its size can be deformed when dragging -->
 | 
			
		||||
              <td data-bind="text: attributeId" style="width: 150px;"></td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <input type="text" data-bind="value: name"/>
 | 
			
		||||
                <!-- Change to valueUpdate: blur is necessary because jQuery UI autocomplete hijacks change event -->
 | 
			
		||||
                <input type="text" class="attribute-name" data-bind="value: name, valueUpdate: 'blur',  event: { blur: $parent.attributeChanged }"/>
 | 
			
		||||
                <div style="color: yellowgreen" data-bind="if: $parent.isNotUnique($index())"><span class="glyphicon glyphicon-info-sign"></span> Duplicate attribute.</div>
 | 
			
		||||
                <div style="color: red" data-bind="if: $parent.isEmptyName($index())">Attribute name can't be empty.</div>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <input type="text" data-bind="value: value" style="width: 300px"/>
 | 
			
		||||
                <input type="text" class="attribute-value" data-bind="value: value, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }" style="width: 300px"/>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td title="Delete" style="padding: 13px;">
 | 
			
		||||
                <span class="glyphicon glyphicon-trash" data-bind="click: $parent.deleteAttribute"></span>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user