mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	Compare commits
	
		
			16 Commits
		
	
	
		
			v0.5.1-bet
			...
			v0.5.5-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 02e07ec03a | ||
|  | 3d2dc8e699 | ||
|  | c84e15c9be | ||
|  | e18d0b9fd4 | ||
|  | 52817504d1 | ||
|  | a3b31fab54 | ||
|  | bc4aa3e40a | ||
|  | 873ea67e9c | ||
|  | 2c5115003b | ||
|  | e8ed913374 | ||
|  | 5bffba4e2f | ||
|  | 05575913db | ||
|  | 31c32ff42c | ||
|  | 6a671a5c02 | ||
|  | e174aec299 | ||
|  | d1329f60c3 | 
| @@ -7,15 +7,15 @@ rm -r dist/* | ||||
| echo "Rebuilding binaries for linux-ia32" | ||||
| ./node_modules/.bin/electron-rebuild --arch=ia32 | ||||
|  | ||||
| ./node_modules/.bin/electron-packager src/electron --out=dist --platform=linux --arch=ia32 --overwrite | ||||
| ./node_modules/.bin/electron-packager . --out=dist --platform=linux --arch=ia32 --overwrite | ||||
|  | ||||
| ./node_modules/.bin/electron-packager src/electron --out=dist --platform=win32 --arch=x64 --overwrite | ||||
| ./node_modules/.bin/electron-packager . --out=dist --platform=win32 --arch=x64 --overwrite | ||||
|  | ||||
| # we build x64 as second so that we keep X64 binaries in node_modules for local development | ||||
| echo "Rebuilding binaries for linux-x64" | ||||
| ./node_modules/.bin/electron-rebuild --arch=x64 | ||||
|  | ||||
| ./node_modules/.bin/electron-packager src/electron --out=dist --platform=linux --arch=x64 --overwrite | ||||
| ./node_modules/.bin/electron-packager . --out=dist --platform=linux --arch=x64 --overwrite | ||||
|  | ||||
| echo "Copying required windows binaries" | ||||
|  | ||||
|   | ||||
							
								
								
									
										196
									
								
								db/schema.sql
									
									
									
									
									
								
							
							
						
						
									
										196
									
								
								db/schema.sql
									
									
									
									
									
								
							| @@ -1,119 +1,121 @@ | ||||
| CREATE TABLE IF NOT EXISTS "options" ( | ||||
| 	`opt_name`	TEXT NOT NULL PRIMARY KEY, | ||||
| 	`opt_value`	TEXT, | ||||
| 	`date_modified` INT | ||||
| , is_synced INTEGER NOT NULL DEFAULT 0); | ||||
|     `name`	TEXT NOT NULL PRIMARY KEY, | ||||
|     `value`	TEXT, | ||||
|     `dateModified` INT, | ||||
|     isSynced INTEGER NOT NULL DEFAULT 0); | ||||
| CREATE TABLE IF NOT EXISTS "sync" ( | ||||
|     `id`	INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||
|     `entity_name`	TEXT NOT NULL, | ||||
|     `entity_id`	TEXT NOT NULL, | ||||
|     `source_id` TEXT NOT NULL, | ||||
|     `sync_date`	TEXT NOT NULL); | ||||
| CREATE UNIQUE INDEX `IDX_sync_entity_name_id` ON `sync` ( | ||||
|   `entity_name`, | ||||
|   `entity_id` | ||||
| ); | ||||
| CREATE INDEX `IDX_sync_sync_date` ON `sync` ( | ||||
|   `sync_date` | ||||
| ); | ||||
| CREATE TABLE `source_ids` ( | ||||
|   `source_id`	TEXT NOT NULL, | ||||
|   `date_created`	TEXT NOT NULL, | ||||
|   PRIMARY KEY(`source_id`) | ||||
|   `id`	INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||
|   `entityName`	TEXT NOT NULL, | ||||
|   `entityId`	TEXT NOT NULL, | ||||
|   `sourceId` TEXT NOT NULL, | ||||
|   `syncDate`	TEXT NOT NULL); | ||||
| CREATE TABLE IF NOT EXISTS "source_ids" ( | ||||
|   `sourceId`	TEXT NOT NULL, | ||||
|   `dateCreated`	TEXT NOT NULL, | ||||
|   PRIMARY KEY(`sourceId`) | ||||
| ); | ||||
| CREATE TABLE IF NOT EXISTS "notes" ( | ||||
|     `note_id`	TEXT NOT NULL, | ||||
|     `note_title`	TEXT, | ||||
|     `note_text`	TEXT, | ||||
|     `is_protected`	INT NOT NULL DEFAULT 0, | ||||
|     `is_deleted`	INT NOT NULL DEFAULT 0, | ||||
|     `date_created`	TEXT NOT NULL, | ||||
|     `date_modified`	TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'text', mime TEXT NOT NULL DEFAULT 'text/html', | ||||
|     PRIMARY KEY(`note_id`) | ||||
|   `noteId`	TEXT NOT NULL, | ||||
|   `title`	TEXT, | ||||
|   `content`	TEXT, | ||||
|   `isProtected`	INT NOT NULL DEFAULT 0, | ||||
|   `isDeleted`	INT NOT NULL DEFAULT 0, | ||||
|   `dateCreated`	TEXT NOT NULL, | ||||
|   `dateModified`	TEXT NOT NULL, | ||||
|   type TEXT NOT NULL DEFAULT 'text', | ||||
|   mime TEXT NOT NULL DEFAULT 'text/html', | ||||
|   PRIMARY KEY(`noteId`) | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_is_deleted` ON `notes` ( | ||||
|     `is_deleted` | ||||
| CREATE TABLE IF NOT EXISTS "event_log" ( | ||||
|   `id`	INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||
|   `noteId`	TEXT, | ||||
|   `comment`	TEXT, | ||||
|   `dateAdded`	TEXT NOT NULL, | ||||
|   FOREIGN KEY(noteId) REFERENCES notes(noteId) | ||||
| ); | ||||
| CREATE TABLE `event_log` ( | ||||
|     `id`	INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||
|     `note_id`	TEXT, | ||||
|     `comment`	TEXT, | ||||
|     `date_added`	TEXT NOT NULL, | ||||
|     FOREIGN KEY(note_id) REFERENCES notes(note_id) | ||||
| ); | ||||
| CREATE TABLE IF NOT EXISTS "notes_tree" ( | ||||
|   `note_tree_id`	TEXT NOT NULL, | ||||
|   `note_id`	TEXT NOT NULL, | ||||
|   `parent_note_id`	TEXT NOT NULL, | ||||
|   `note_position`	INTEGER NOT NULL, | ||||
| CREATE TABLE IF NOT EXISTS "note_tree" ( | ||||
|   `noteTreeId`	TEXT NOT NULL, | ||||
|   `noteId`	TEXT NOT NULL, | ||||
|   `parentNoteId`	TEXT NOT NULL, | ||||
|   `notePosition`	INTEGER NOT NULL, | ||||
|   `prefix`	TEXT, | ||||
|   `is_expanded`	BOOLEAN, | ||||
|   `is_deleted`	INTEGER NOT NULL DEFAULT 0, | ||||
|   `date_modified`	TEXT NOT NULL, | ||||
|   PRIMARY KEY(`note_tree_id`) | ||||
|   `isExpanded`	BOOLEAN, | ||||
|   `isDeleted`	INTEGER NOT NULL DEFAULT 0, | ||||
|   `dateModified`	TEXT NOT NULL, | ||||
|   PRIMARY KEY(`noteTreeId`) | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_tree_note_id` ON `notes_tree` ( | ||||
|   `note_id` | ||||
| CREATE TABLE IF NOT EXISTS "note_revisions" ( | ||||
|   `noteRevisionId`	TEXT NOT NULL PRIMARY KEY, | ||||
|   `noteId`	TEXT NOT NULL, | ||||
|   `title`	TEXT, | ||||
|   `content`	TEXT, | ||||
|   `isProtected`	INT NOT NULL DEFAULT 0, | ||||
|   `dateModifiedFrom` TEXT NOT NULL, | ||||
|   `dateModifiedTo` TEXT NOT NULL | ||||
| ); | ||||
| CREATE TABLE IF NOT EXISTS "notes_history" ( | ||||
|   `note_history_id`	TEXT NOT NULL PRIMARY KEY, | ||||
|   `note_id`	TEXT NOT NULL, | ||||
|   `note_title`	TEXT, | ||||
|   `note_text`	TEXT, | ||||
|   `is_protected`	INT NOT NULL DEFAULT 0, | ||||
|   `date_modified_from` TEXT NOT NULL, | ||||
|   `date_modified_to` TEXT NOT NULL | ||||
| CREATE TABLE IF NOT EXISTS "recent_notes" ( | ||||
|   `noteTreeId` TEXT NOT NULL PRIMARY KEY, | ||||
|   `notePath` TEXT NOT NULL, | ||||
|   `dateAccessed` TEXT NOT NULL, | ||||
|   isDeleted INT | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_history_note_id` ON `notes_history` ( | ||||
|   `note_id` | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_history_note_date_modified_from` ON `notes_history` ( | ||||
|   `date_modified_from` | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_history_note_date_modified_to` ON `notes_history` ( | ||||
|   `date_modified_to` | ||||
| ); | ||||
| CREATE TABLE `recent_notes` ( | ||||
|   `note_tree_id` TEXT NOT NULL PRIMARY KEY, | ||||
|   `note_path` TEXT NOT NULL, | ||||
|   `date_accessed` TEXT NOT NULL, | ||||
|   is_deleted INT | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` ( | ||||
|   `note_id`, | ||||
|   `parent_note_id` | ||||
| ); | ||||
| CREATE TABLE images | ||||
| CREATE TABLE IF NOT EXISTS "images" | ||||
| ( | ||||
|   image_id TEXT PRIMARY KEY NOT NULL, | ||||
|   imageId TEXT PRIMARY KEY NOT NULL, | ||||
|   format TEXT NOT NULL, | ||||
|   checksum TEXT NOT NULL, | ||||
|   name TEXT NOT NULL, | ||||
|   data BLOB, | ||||
|   is_deleted INT NOT NULL DEFAULT 0, | ||||
|   date_modified TEXT NOT NULL, | ||||
|   date_created TEXT NOT NULL | ||||
|   isDeleted INT NOT NULL DEFAULT 0, | ||||
|   dateModified TEXT NOT NULL, | ||||
|   dateCreated TEXT NOT NULL | ||||
| ); | ||||
| CREATE TABLE notes_image | ||||
| CREATE TABLE note_images | ||||
| ( | ||||
|   note_image_id TEXT PRIMARY KEY NOT NULL, | ||||
|   note_id TEXT NOT NULL, | ||||
|   image_id TEXT NOT NULL, | ||||
|   is_deleted INT NOT NULL DEFAULT 0, | ||||
|   date_modified TEXT NOT NULL, | ||||
|   date_created TEXT NOT NULL | ||||
|   noteImageId TEXT PRIMARY KEY NOT NULL, | ||||
|   noteId TEXT NOT NULL, | ||||
|   imageId TEXT NOT NULL, | ||||
|   isDeleted INT NOT NULL DEFAULT 0, | ||||
|   dateModified TEXT NOT NULL, | ||||
|   dateCreated TEXT NOT NULL | ||||
| ); | ||||
| CREATE INDEX notes_image_note_id_index ON notes_image (note_id); | ||||
| CREATE INDEX notes_image_image_id_index ON notes_image (image_id); | ||||
| CREATE INDEX notes_image_note_id_image_id_index ON notes_image (note_id, image_id); | ||||
| CREATE TABLE attributes | ||||
| CREATE TABLE IF NOT EXISTS "attributes" | ||||
| ( | ||||
|   attribute_id TEXT PRIMARY KEY NOT NULL, | ||||
|   note_id TEXT NOT NULL, | ||||
|   attributeId TEXT PRIMARY KEY NOT NULL, | ||||
|   noteId TEXT NOT NULL, | ||||
|   name TEXT NOT NULL, | ||||
|   value TEXT, | ||||
|   date_created TEXT NOT NULL, | ||||
|   date_modified TEXT NOT NULL | ||||
|   dateCreated TEXT NOT NULL, | ||||
|   dateModified TEXT NOT NULL | ||||
| ); | ||||
| CREATE INDEX attributes_note_id_index ON attributes (note_id); | ||||
| CREATE UNIQUE INDEX attributes_note_id_name_index ON attributes (note_id, name); | ||||
| CREATE UNIQUE INDEX `IDX_sync_entityName_entityId` ON `sync` ( | ||||
|   `entityName`, | ||||
|   `entityId` | ||||
| ); | ||||
| CREATE INDEX `IDX_sync_syncDate` ON `sync` ( | ||||
|   `syncDate` | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_isDeleted` ON `notes` ( | ||||
|   `isDeleted` | ||||
| ); | ||||
| CREATE INDEX `IDX_note_tree_noteId` ON `note_tree` ( | ||||
|   `noteId` | ||||
| ); | ||||
| CREATE INDEX `IDX_note_tree_noteId_parentNoteId` ON `note_tree` ( | ||||
|   `noteId`, | ||||
|   `parentNoteId` | ||||
| ); | ||||
| CREATE INDEX `IDX_note_revisions_noteId` ON `note_revisions` ( | ||||
|   `noteId` | ||||
| ); | ||||
| CREATE INDEX `IDX_note_revisions_dateModifiedFrom` ON `note_revisions` ( | ||||
|   `dateModifiedFrom` | ||||
| ); | ||||
| CREATE INDEX `IDX_note_revisions_dateModifiedTo` ON `note_revisions` ( | ||||
|   `dateModifiedTo` | ||||
| ); | ||||
| 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); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| 
 | ||||
| const electron = require('electron'); | ||||
| const path = require('path'); | ||||
| const config = require('./services/config'); | ||||
| const config = require('./src/services/config'); | ||||
| const url = require("url"); | ||||
| 
 | ||||
| const app = electron.app; | ||||
| @@ -24,7 +24,7 @@ function createMainWindow() { | ||||
|         width: 1200, | ||||
|         height: 900, | ||||
|         title: 'Trilium Notes', | ||||
|         icon: path.join(__dirname, 'public/images/app-icons/png/256x256.png') | ||||
|         icon: path.join(__dirname, 'src/public/images/app-icons/png/256x256.png') | ||||
|     }); | ||||
| 
 | ||||
|     const port = config['Network']['port'] || '3000'; | ||||
| @@ -69,4 +69,4 @@ app.on('ready', () => { | ||||
|     mainWindow = createMainWindow(); | ||||
| }); | ||||
| 
 | ||||
| require('./www'); | ||||
| require('./src/www'); | ||||
| @@ -1,8 +1,9 @@ | ||||
| { | ||||
|   "name": "trilium", | ||||
|   "description": "Trilium Notes", | ||||
|   "version": "0.5.1-beta", | ||||
|   "version": "0.5.5-beta", | ||||
|   "license": "AGPL-3.0-only", | ||||
|   "main": "electron.js", | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
|     "url": "https://github.com/zadam/trilium.git" | ||||
| @@ -11,8 +12,8 @@ | ||||
|     "start": "node ./bin/www", | ||||
|     "test-electron": "xo", | ||||
|     "rebuild-electron": "electron-rebuild", | ||||
|     "start-electron": "electron src/electron", | ||||
|     "build-electron": "electron-packager src/electron --out=dist --asar --overwrite --all", | ||||
|     "start-electron": "electron .", | ||||
|     "build-electron": "electron-packager . --out=dist --asar --overwrite --all", | ||||
|     "start-forge": "electron-forge start", | ||||
|     "package-forge": "electron-forge package", | ||||
|     "make-forge": "electron-forge make", | ||||
|   | ||||
							
								
								
									
										21
									
								
								src/public/javascripts/api.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/public/javascripts/api.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| const api = (function() { | ||||
|     const pluginButtonsEl = $("#plugin-buttons"); | ||||
|  | ||||
|     async function activateNote(notePath) { | ||||
|         await noteTree.activateNode(notePath); | ||||
|     } | ||||
|  | ||||
|     function addButtonToToolbar(buttonId, button) { | ||||
|         $("#" + buttonId).remove(); | ||||
|  | ||||
|         button.attr('id', buttonId); | ||||
|  | ||||
|         pluginButtonsEl.append(button); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     return { | ||||
|         addButtonToToolbar, | ||||
|         activateNote | ||||
|     } | ||||
| })(); | ||||
| @@ -2,7 +2,9 @@ | ||||
|  | ||||
| const attributesDialog = (function() { | ||||
|     const dialogEl = $("#attributes-dialog"); | ||||
|     const saveAttributesButton = $("#save-attributes-button"); | ||||
|     const attributesModel = new AttributesModel(); | ||||
|     let attributeNames = []; | ||||
|  | ||||
|     function AttributesModel() { | ||||
|         const self = this; | ||||
| @@ -14,38 +16,111 @@ const attributesDialog = (function() { | ||||
|  | ||||
|             const attributes = await server.get('notes/' + noteId + '/attributes'); | ||||
|  | ||||
|             this.attributes(attributes); | ||||
|             self.attributes(attributes.map(ko.observable)); | ||||
|  | ||||
|             addLastEmptyRow(); | ||||
|  | ||||
|             attributeNames = await server.get('attributes/names'); | ||||
|  | ||||
|             $(".attribute-name:last").focus(); | ||||
|         }; | ||||
|  | ||||
|         this.addNewRow = function() { | ||||
|             self.attributes.push({ | ||||
|                 attributeId: '', | ||||
|                 name: '', | ||||
|                 value: '' | ||||
|             }); | ||||
|         }; | ||||
|         function isValid() { | ||||
|             for (let attrs = self.attributes(), i = 0; i < attrs.length; i++) { | ||||
|                 if (self.isEmptyName(i) || self.isNotUnique(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(); | ||||
|             const last = attrs[attrs.length - 1](); | ||||
|  | ||||
|             if (last.name.trim() !== "" || last.value !== "") { | ||||
|                 self.attributes.push(ko.observable({ | ||||
|                     attributeId: '', | ||||
|                     name: '', | ||||
|                     value: '' | ||||
|                 })); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         this.attributeChanged = function (row) { | ||||
|             addLastEmptyRow(); | ||||
|  | ||||
|             for (const attr of self.attributes()) { | ||||
|                 if (row.attributeId === attr().attributeId) { | ||||
|                     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 !== ""); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async function showDialog() { | ||||
|         glob.activeDialog = dialogEl; | ||||
|  | ||||
|         await attributesModel.loadAttributes(); | ||||
|  | ||||
|         dialogEl.dialog({ | ||||
|             modal: true, | ||||
|             width: 800, | ||||
|             height: 700 | ||||
|             height: 500 | ||||
|         }); | ||||
|  | ||||
|         attributesModel.loadAttributes(); | ||||
|     } | ||||
|  | ||||
|     $(document).bind('keydown', 'alt+a', e => { | ||||
| @@ -56,6 +131,56 @@ 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('blur', '.attribute-name', function (e) { console.log("blur!"); }); | ||||
|  | ||||
|     $(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 | ||||
|     }; | ||||
|   | ||||
| @@ -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 | ||||
|     }; | ||||
| })(); | ||||
| @@ -116,5 +116,20 @@ async function stopWatch(what, func) { | ||||
| } | ||||
|  | ||||
| function executeScript(script) { | ||||
|     eval("(async function() {" + 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 /\s/.test(val) ? '"' + val + '"' : val; | ||||
| } | ||||
|  | ||||
| function formatAttribute(attr) { | ||||
|     let str = "@" + formatValueWithWhitespace(attr.name); | ||||
|  | ||||
|     if (attr.value !== "") { | ||||
|         str += "=" + formatValueWithWhitespace(attr.value); | ||||
|     } | ||||
|  | ||||
|     return str; | ||||
| } | ||||
| @@ -6,7 +6,8 @@ | ||||
|     grid-template-areas: "header header" | ||||
|                          "tree-actions title" | ||||
|                          "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 | ||||
| @@ -108,7 +109,7 @@ span.fancytree-active:not(.fancytree-focused) .fancytree-title { | ||||
| } | ||||
|  | ||||
| #header-title { | ||||
|     padding: 5px 50px 5px 10px; | ||||
|     padding: 5px 20px 5px 10px; | ||||
|     font-size: large; | ||||
|     font-weight: bold; | ||||
| } | ||||
| @@ -190,11 +191,6 @@ div.ui-tooltip { | ||||
|     float: right; | ||||
| } | ||||
|  | ||||
| #note-id-display { | ||||
|     color: lightgrey; | ||||
|     margin-left: 10px; | ||||
| } | ||||
|  | ||||
| #note-source { | ||||
|     height: 98%; | ||||
|     width: 100%; | ||||
| @@ -243,8 +239,9 @@ div.ui-tooltip { | ||||
| #note-id-display { | ||||
|     position: absolute; | ||||
|     right: 10px; | ||||
|     bottom: 5px; | ||||
|     bottom: 8px; | ||||
|     z-index: 1000; | ||||
|     color: lightgrey; | ||||
| } | ||||
|  | ||||
| #note-type-dropdown { | ||||
| @@ -253,4 +250,16 @@ div.ui-tooltip { | ||||
|     overflow-x: hidden; | ||||
| } | ||||
|  | ||||
| .cm-matchhighlight {background-color: #eeeeee} | ||||
| .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; | ||||
| } | ||||
| @@ -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])); | ||||
| })); | ||||
|  | ||||
| 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(); | ||||
| @@ -45,4 +46,26 @@ 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])); | ||||
| })); | ||||
|  | ||||
| router.get('/attributes/names', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const names = await sql.getColumn("SELECT DISTINCT name FROM attributes"); | ||||
|  | ||||
|     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 name = ? AND value != '' ORDER BY value", [attributeName]); | ||||
|  | ||||
|     res.send(values); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -58,15 +58,112 @@ 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); | ||||
|  | ||||
|     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.replace(new RegExp(match[0], 'g'), ''); | ||||
|  | ||||
|         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 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; | ||||
|   | ||||
| @@ -19,11 +19,12 @@ router.post('/exec', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|  | ||||
| router.get('/startup', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteIds = await attributes.getNoteIdsWithAttribute("run_on_startup"); | ||||
|     const repository = new Repository(req); | ||||
|  | ||||
|     const scripts = []; | ||||
|  | ||||
|     for (const noteId of noteIds) { | ||||
|         scripts.push(await getNoteWithSubtreeScript(noteId, req)); | ||||
|         scripts.push(await getNoteWithSubtreeScript(noteId, repository)); | ||||
|     } | ||||
|  | ||||
|     res.send(scripts); | ||||
| @@ -41,10 +42,10 @@ router.get('/subtree/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => | ||||
|     res.send(subTreeScripts + noteScript); | ||||
| })); | ||||
|  | ||||
| async function getNoteWithSubtreeScript(noteId, req) { | ||||
|     const noteScript = (await notes.getNoteById(noteId, req)).content; | ||||
| async function getNoteWithSubtreeScript(noteId, repository) { | ||||
|     const noteScript = (await repository.getNote(noteId)).content; | ||||
|  | ||||
|     const subTreeScripts = await getSubTreeScripts(noteId, [noteId], req); | ||||
|     const subTreeScripts = await getSubTreeScripts(noteId, [noteId], repository); | ||||
|  | ||||
|     return subTreeScripts + noteScript; | ||||
| } | ||||
|   | ||||
| @@ -40,7 +40,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); | ||||
|   | ||||
							
								
								
									
										11
									
								
								src/scripts/today.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/scripts/today.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| api.addButtonToToolbar('go-today', $('<button class="btn btn-xs" onclick="goToday();"><span class="ui-icon ui-icon-calendar"></span> Today</button>')); | ||||
|  | ||||
| window.goToday = async function() { | ||||
|     const todayDateStr = formatDateISO(new Date()); | ||||
|  | ||||
|     const todayNoteId = await server.exec([todayDateStr], async todayDateStr => { | ||||
|         return await this.getDateNoteId(todayDateStr); | ||||
|     }); | ||||
|  | ||||
|     api.activateNote(todayNoteId); | ||||
| }; | ||||
| @@ -65,6 +65,8 @@ | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             data.sort((a, b) => a.date < b.date ? -1 : +1); | ||||
|  | ||||
|             return data; | ||||
|         }); | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,8 @@ const utils = require('./utils'); | ||||
| const sync_table = require('./sync_table'); | ||||
| const Repository = require('./repository'); | ||||
|  | ||||
| const BUILTIN_ATTRIBUTES = [ 'run_on_startup', 'disable_versioning' ]; | ||||
|  | ||||
| async function getNoteAttributeMap(noteId) { | ||||
|     return await sql.getMap(`SELECT name, value FROM attributes WHERE noteId = ?`, [noteId]); | ||||
| } | ||||
| @@ -64,5 +66,6 @@ module.exports = { | ||||
|     getNotesWithAttribute, | ||||
|     getNoteWithAttribute, | ||||
|     getNoteIdsWithAttribute, | ||||
|     createAttribute | ||||
|     createAttribute, | ||||
|     BUILTIN_ATTRIBUTES | ||||
| }; | ||||
| @@ -5,32 +5,6 @@ const sync_table = require('./sync_table'); | ||||
| const attributes = require('./attributes'); | ||||
| const protected_session = require('./protected_session'); | ||||
|  | ||||
| async function updateJsonNote(noteId, data) { | ||||
|     const ret = await createNewNote(noteId, { | ||||
|         title: name, | ||||
|         content: JSON.stringify(data), | ||||
|         target: 'into', | ||||
|         isProtected: false, | ||||
|         type: 'code', | ||||
|         mime: 'application/json' | ||||
|     }); | ||||
|  | ||||
|     return ret.noteId; | ||||
| } | ||||
|  | ||||
| async function createNewJsonNote(parentNoteId, name, payload) { | ||||
|     const ret = await createNewNote(parentNoteId, { | ||||
|         title: name, | ||||
|         content: JSON.stringify(payload), | ||||
|         target: 'into', | ||||
|         isProtected: false, | ||||
|         type: 'code', | ||||
|         mime: 'application/json' | ||||
|     }); | ||||
|  | ||||
|     return ret.noteId; | ||||
| } | ||||
|  | ||||
| async function createNewNote(parentNoteId, noteOpts, dataKey, sourceId) { | ||||
|     const noteId = utils.newNoteId(); | ||||
|     const noteTreeId = utils.newNoteTreeId(); | ||||
|   | ||||
| @@ -17,15 +17,20 @@ | ||||
|           <button class="btn btn-xs" onclick="jumpToNote.showDialog();" title="CTRL+J">Jump to note</button> | ||||
|           <button class="btn btn-xs" onclick="recentNotes.showDialog();" title="CTRL+E">Recent notes</button> | ||||
|           <button class="btn btn-xs" onclick="recentChanges.showDialog();">Recent changes</button> | ||||
|           <button class="btn btn-xs" onclick="eventLog.showDialog();">Event log</button> | ||||
|         </div> | ||||
|  | ||||
|         <div id="plugin-buttons"> | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|           <button class="btn btn-xs" onclick="syncNow();" title="Number of outstanding changes to be pushed to server"> | ||||
|             <span class="ui-icon ui-icon-refresh"></span> | ||||
|  | ||||
|             Sync now (<span id="changes-to-push-count">0</span>) | ||||
|           </button> | ||||
|  | ||||
|           <button class="btn btn-xs" onclick="settings.showDialog();">Settings</button> | ||||
|           <button class="btn btn-xs" onclick="settings.showDialog();"> | ||||
|             <span class="ui-icon ui-icon-gear"></span> Settings</button> | ||||
|  | ||||
|           <form action="logout" id="logout-button" method="POST" style="display: inline;"> | ||||
|             <input type="submit" class="btn btn-xs" value="Logout"> | ||||
| @@ -53,12 +58,11 @@ | ||||
|         </div> | ||||
|  | ||||
|         <div id="search-box" style="display: none; padding: 10px; margin-top: 10px;"> | ||||
|           <p> | ||||
|           <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> | ||||
|       </div> | ||||
|  | ||||
| @@ -138,6 +142,12 @@ | ||||
|  | ||||
|         <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;"> | ||||
| @@ -378,10 +388,8 @@ | ||||
|  | ||||
|     <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-primary btn-large" id="save-attributes-button" type="submit">Save</button> | ||||
|       </div> | ||||
|  | ||||
|       <div style="height: 97%; overflow: auto"> | ||||
| @@ -397,10 +405,14 @@ | ||||
|             <tr> | ||||
|               <td data-bind="text: attributeId"></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: red" data-bind="if: $parent.isNotUnique($index())">Attribute name must be unique per note.</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> | ||||
|             </tr> | ||||
|           </tbody> | ||||
| @@ -492,7 +504,7 @@ | ||||
|     <script src="javascripts/link.js"></script> | ||||
|     <script src="javascripts/sync.js"></script> | ||||
|     <script src="javascripts/messaging.js"></script> | ||||
|  | ||||
|     <script src="javascripts/api.js"></script> | ||||
|  | ||||
|     <script type="text/javascript"> | ||||
|       // we hide container initally because otherwise it is rendered first without CSS and then flickers into | ||||
|   | ||||
		Reference in New Issue
	
	Block a user