mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	Compare commits
	
		
			30 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 86b1410952 | ||
|  | 29eb88bac3 | ||
|  | 31b4186e17 | ||
|  | bde9e825c8 | ||
|  | 0e8285a7e4 | ||
|  | 780f462e94 | ||
|  | 488e657cc4 | ||
|  | 8bc2a21d80 | ||
|  | 743d72a0c3 | ||
|  | 20b1357be6 | ||
|  | d9f2bb37e7 | ||
|  | 97c1b3061f | ||
|  | c022fcf196 | ||
|  | b5baab056c | ||
|  | edc9a1a2bf | ||
|  | c0e45a73a8 | ||
|  | 784cd62df1 | ||
|  | 91cf090820 | ||
|  | d9f29cbf27 | ||
|  | 23a5e38e02 | ||
|  | 663bd1a8fe | ||
|  | a6a687c4a6 | ||
|  | f2aaf8b0a3 | ||
|  | 01ede22504 | ||
|  | b6d617aefa | ||
|  | 7921850186 | ||
|  | 244a4562b1 | ||
|  | 07c33979c3 | ||
|  | 353a9b24c1 | ||
|  | 548ecd4171 | 
							
								
								
									
										10
									
								
								bin/build.sh
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								bin/build.sh
									
									
									
									
									
								
							| @@ -4,9 +4,11 @@ echo "Deleting dist" | ||||
|  | ||||
| rm -r dist/* | ||||
|  | ||||
| cp -r ../trilium-node-binaries/sqlite/* node_modules/sqlite3/lib/binding/ | ||||
|  | ||||
| cp -r ../trilium-node-binaries/scrypt/* node_modules/scrypt/bin/ | ||||
| cp -r bin/deps/sqlite/* node_modules/sqlite3/lib/binding/ | ||||
| cp -r bin/deps/scrypt/* node_modules/scrypt/bin/ | ||||
| cp -r bin/deps/image/cjpeg.exe node_modules/mozjpeg/vendor/ | ||||
| cp -r bin/deps/image/pngquant.exe node_modules/pngquant-bin/vendor/ | ||||
| cp -r bin/deps/image/gifsicle.exe node_modules/giflossy/vendor/ | ||||
|  | ||||
| ./node_modules/.bin/electron-rebuild --arch=ia32 | ||||
|  | ||||
| @@ -19,4 +21,4 @@ cp -r ../trilium-node-binaries/scrypt/* node_modules/scrypt/bin/ | ||||
| ./node_modules/.bin/electron-packager . --out=dist --platform=win32 --arch=x64 --overwrite | ||||
|  | ||||
| # can't copy this before the packaging because the same file name is used for both linux and windows build | ||||
| cp ../trilium-node-binaries/scrypt.node ./dist/trilium-win32-x64/resources/app/node_modules/scrypt/build/Release/ | ||||
| cp bin/deps/scrypt/win32-x64-57/scrypt.node ./dist/trilium-win32-x64/resources/app/node_modules/scrypt/build/Release/ | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								bin/deps/image/cjpeg.exe
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bin/deps/image/cjpeg.exe
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								bin/deps/image/gifsicle.exe
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bin/deps/image/gifsicle.exe
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								bin/deps/image/pngquant.exe
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bin/deps/image/pngquant.exe
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								bin/deps/scrypt/win32-x64-57/scrypt.node
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bin/deps/scrypt/win32-x64-57/scrypt.node
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								bin/deps/sqlite/electron-v1.8-win32-x64/node_sqlite3.node
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bin/deps/sqlite/electron-v1.8-win32-x64/node_sqlite3.node
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								bin/deps/sqlite/node-v57-win32-x64/node_sqlite3.node
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bin/deps/sqlite/node-v57-win32-x64/node_sqlite3.node
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								db/image-deleted.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								db/image-deleted.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.4 KiB | 
| @@ -67,10 +67,7 @@ CREATE INDEX `IDX_sync_sync_date` ON `sync` ( | ||||
| CREATE INDEX `IDX_notes_is_deleted` ON `notes` ( | ||||
|     `is_deleted` | ||||
| ); | ||||
| CREATE INDEX `IDX_notes_tree_note_tree_id` ON `notes_tree` ( | ||||
|   `note_tree_id` | ||||
| ); | ||||
| CREATE UNIQUE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` ( | ||||
| CREATE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` ( | ||||
|   `note_id`, | ||||
|   `parent_note_id` | ||||
| ); | ||||
| @@ -86,3 +83,28 @@ CREATE INDEX `IDX_notes_history_note_date_modified_from` ON `notes_history` ( | ||||
| CREATE INDEX `IDX_notes_history_note_date_modified_to` ON `notes_history` ( | ||||
|   `date_modified_to` | ||||
| ); | ||||
| CREATE TABLE images | ||||
| ( | ||||
|   image_id 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 | ||||
| ); | ||||
|  | ||||
| CREATE TABLE notes_image | ||||
| ( | ||||
|   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 | ||||
| ); | ||||
|  | ||||
| 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); | ||||
							
								
								
									
										9
									
								
								migrations/0062__change_index_back_to_non_unique.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								migrations/0062__change_index_back_to_non_unique.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| DROP INDEX IDX_notes_tree_note_id_parent_note_id; | ||||
|  | ||||
| CREATE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` ( | ||||
|   `note_id`, | ||||
|   `parent_note_id` | ||||
| ); | ||||
|  | ||||
| -- dropping this as it's just duplicate of primary key | ||||
| DROP INDEX IDX_notes_tree_note_tree_id; | ||||
							
								
								
									
										11
									
								
								migrations/0063__image_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								migrations/0063__image_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| CREATE TABLE images | ||||
| ( | ||||
|   image_id 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 | ||||
| ); | ||||
							
								
								
									
										16
									
								
								migrations/0064__add_note_id_to_image_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								migrations/0064__add_note_id_to_image_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| DROP TABLE images; | ||||
|  | ||||
| CREATE TABLE images | ||||
| ( | ||||
|   image_id TEXT PRIMARY KEY NOT NULL, | ||||
|   note_id TEXT 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 | ||||
| ); | ||||
|  | ||||
| CREATE INDEX images_note_id_index ON images (note_id); | ||||
							
								
								
									
										27
									
								
								migrations/0065__notes_image.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								migrations/0065__notes_image.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| DROP TABLE images; | ||||
|  | ||||
| CREATE TABLE images | ||||
| ( | ||||
|   image_id 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 | ||||
| ); | ||||
|  | ||||
| CREATE TABLE notes_image | ||||
| ( | ||||
|   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 | ||||
| ); | ||||
|  | ||||
| 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); | ||||
							
								
								
									
										2318
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2318
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										17
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,7 +1,12 @@ | ||||
| { | ||||
|   "name": "trilium", | ||||
|   "description": "Trilium Notes", | ||||
|   "version": "0.2.1", | ||||
|   "version": "0.3.1", | ||||
|   "license": "AGPL-3.0-only", | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
|     "url": "https://github.com/zadam/trilium.git" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "node ./bin/www", | ||||
|     "test-electron": "xo", | ||||
| @@ -14,6 +19,7 @@ | ||||
|     "publish-forge": "electron-forge publish" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "async-mutex": "^0.1.3", | ||||
|     "body-parser": "~1.18.2", | ||||
|     "cookie-parser": "~1.4.3", | ||||
|     "debug": "~3.1.0", | ||||
| @@ -23,15 +29,24 @@ | ||||
|     "electron-debug": "^1.0.0", | ||||
|     "electron-in-page-search": "^1.2.4", | ||||
|     "express": "~4.16.2", | ||||
|     "express-promise-wrap": "^0.2.2", | ||||
|     "express-session": "^1.15.6", | ||||
|     "fs-extra": "^4.0.2", | ||||
|     "helmet": "^3.9.0", | ||||
|     "html": "^1.0.0", | ||||
|     "image-type": "^3.0.0", | ||||
|     "imagemin": "^5.3.1", | ||||
|     "imagemin-giflossy": "^5.1.10", | ||||
|     "imagemin-mozjpeg": "^7.0.0", | ||||
|     "imagemin-pngquant": "^5.0.1", | ||||
|     "ini": "^1.3.4", | ||||
|     "jimp": "^0.2.28", | ||||
|     "multer": "^1.3.0", | ||||
|     "rand-token": "^0.4.0", | ||||
|     "request": "^2.83.0", | ||||
|     "request-promise": "^4.2.2", | ||||
|     "rimraf": "^2.6.2", | ||||
|     "sanitize-filename": "^1.6.1", | ||||
|     "scrypt": "^6.0.3", | ||||
|     "serve-favicon": "~2.4.5", | ||||
|     "session-file-store": "^1.1.2", | ||||
|   | ||||
| @@ -157,6 +157,7 @@ settings.addModule((async function () { | ||||
|     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 () => { | ||||
| @@ -186,6 +187,14 @@ settings.addModule((async function () { | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     cleanupUnusedImagesButton.click(async () => { | ||||
|         if (confirm("Do you really want to clean up unused images?")) { | ||||
|             await server.post('cleanup/cleanup-unused-images'); | ||||
|  | ||||
|             showMessage("Unused images have been cleaned up"); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     vacuumDatabaseButton.click(async () => { | ||||
|         await server.post('cleanup/vacuum-database'); | ||||
|  | ||||
|   | ||||
| @@ -90,6 +90,8 @@ $(document).bind('keydown', "ctrl+shift+down", () => { | ||||
|     return false; | ||||
| }); | ||||
|  | ||||
| $("#note-title").bind('keydown', 'return', () => $("#note-detail").focus()); | ||||
|  | ||||
| $(window).on('beforeunload', () => { | ||||
|     // this makes sure that when user e.g. reloads the page or navigates away from the page, the note's content is saved | ||||
|     // this sends the request asynchronously and doesn't wait for result | ||||
|   | ||||
| @@ -29,7 +29,9 @@ const messaging = (function() { | ||||
|  | ||||
|             const syncData = message.data.filter(sync => sync.source_id !== glob.sourceId); | ||||
|  | ||||
|             if (syncData.some(sync => sync.entity_name === 'notes_tree')) { | ||||
|             if (syncData.some(sync => sync.entity_name === 'notes_tree') | ||||
|                 || syncData.some(sync => sync.entity_name === 'notes')) { | ||||
|  | ||||
|                 console.log(now(), "Reloading tree because of background changes"); | ||||
|  | ||||
|                 noteTree.reload(); | ||||
| @@ -47,6 +49,9 @@ const messaging = (function() { | ||||
|                 recentNotes.reload(); | ||||
|             } | ||||
|  | ||||
|             // we don't detect image changes here since images themselves are immutable and references should be | ||||
|             // updated in note detail as well | ||||
|  | ||||
|             changesToPushCountEl.html(message.changesToPushCount); | ||||
|         } | ||||
|         else if (message.type === 'sync-hash-check-failed') { | ||||
|   | ||||
| @@ -490,12 +490,14 @@ const noteTree = (function() { | ||||
|  | ||||
|                 return false; | ||||
|             }, | ||||
|             "ctrl+return": node => { | ||||
|                 noteDetailEl.focus(); | ||||
|             }, | ||||
|             "return": node => { | ||||
|                 noteDetailEl.focus(); | ||||
|             }, | ||||
|             "backspace": node => { | ||||
|                 if (!isTopLevelNode(node)) { | ||||
|                     node.getParent().setActive().then(() => clearSelectedNodes()); | ||||
|                 } | ||||
|             }, | ||||
|             // code below shouldn't be necessary normally, however there's some problem with interaction with context menu plugin | ||||
|             // after opening context menu, standard shortcuts don't work, but they are detected here | ||||
|             // so we essentially takeover the standard handling with our implementation. | ||||
| @@ -532,13 +534,15 @@ const noteTree = (function() { | ||||
|                 const node = data.node; | ||||
|  | ||||
|                 if (targetType === 'title' || targetType === 'icon') { | ||||
|                     node.setActive(); | ||||
|  | ||||
|                     if (!event.ctrlKey) { | ||||
|                         node.setActive(); | ||||
|                         node.setSelected(true); | ||||
|  | ||||
|                         clearSelectedNodes(); | ||||
|                     } | ||||
|  | ||||
|                     node.setSelected(true); | ||||
|                     else { | ||||
|                         node.setSelected(!node.isSelected()); | ||||
|                     } | ||||
|  | ||||
|                     return false; | ||||
|                 } | ||||
|   | ||||
| @@ -91,6 +91,7 @@ const server = (function() { | ||||
|         get, | ||||
|         post, | ||||
|         put, | ||||
|         remove | ||||
|         remove, | ||||
|         getHeaders | ||||
|     } | ||||
| })(); | ||||
							
								
								
									
										4
									
								
								public/libraries/ckeditor/ckeditor.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								public/libraries/ckeditor/ckeditor.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										631
									
								
								public/libraries/jquery.ui-contextmenu.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										631
									
								
								public/libraries/jquery.ui-contextmenu.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,631 @@ | ||||
| /******************************************************************************* | ||||
|  * jquery.ui-contextmenu.js plugin. | ||||
|  * | ||||
|  * jQuery plugin that provides a context menu (based on the jQueryUI menu widget). | ||||
|  * | ||||
|  * @see https://github.com/mar10/jquery-ui-contextmenu | ||||
|  * | ||||
|  * Copyright (c) 2013-2017, Martin Wendt (http://wwWendt.de). Licensed MIT. | ||||
|  */ | ||||
|  | ||||
| (function( factory ) { | ||||
| 	"use strict"; | ||||
| 	if ( typeof define === "function" && define.amd ) { | ||||
| 		// AMD. Register as an anonymous module. | ||||
| 		define([ "jquery", "jquery-ui/ui/widgets/menu" ], factory ); | ||||
| 	} else { | ||||
| 		// Browser globals | ||||
| 		factory( jQuery ); | ||||
| 	} | ||||
| }(function( $ ) { | ||||
|  | ||||
| "use strict"; | ||||
|  | ||||
| var supportSelectstart = "onselectstart" in document.createElement("div"), | ||||
| 	match = $.ui.menu.version.match(/^(\d)\.(\d+)/), | ||||
| 	uiVersion = { | ||||
| 		major: parseInt(match[1], 10), | ||||
| 		minor: parseInt(match[2], 10) | ||||
| 	}, | ||||
| 	isLTE110 = ( uiVersion.major < 2 && uiVersion.minor <= 10 ), | ||||
| 	isLTE111 = ( uiVersion.major < 2 && uiVersion.minor <= 11 ); | ||||
|  | ||||
| $.widget("moogle.contextmenu", { | ||||
| 	version: "@VERSION", | ||||
| 	options: { | ||||
| 		addClass: "ui-contextmenu",  // Add this class to the outer <ul> | ||||
| 		closeOnWindowBlur: true,     // Close menu when window loses focus | ||||
| 		autoFocus: false,     // Set keyboard focus to first entry on open | ||||
| 		autoTrigger: true,    // open menu on browser's `contextmenu` event | ||||
| 		delegate: null,       // selector | ||||
| 		hide: { effect: "fadeOut", duration: "fast" }, | ||||
| 		ignoreParentSelect: true, // Don't trigger 'select' for sub-menu parents | ||||
| 		menu: null,           // selector or jQuery pointing to <UL>, or a definition hash | ||||
| 		position: null,       // popup positon | ||||
| 		preventContextMenuForPopup: false, // prevent opening the browser's system | ||||
| 										   // context menu on menu entries | ||||
| 		preventSelect: false, // disable text selection of target | ||||
| 		show: { effect: "slideDown", duration: "fast" }, | ||||
| 		taphold: false,       // open menu on taphold events (requires external plugins) | ||||
| 		uiMenuOptions: {},	  // Additional options, used when UI Menu is created | ||||
| 		// Events: | ||||
| 		beforeOpen: $.noop,   // menu about to open; return `false` to prevent opening | ||||
| 		blur: $.noop,         // menu option lost focus | ||||
| 		close: $.noop,        // menu was closed | ||||
| 		create: $.noop,       // menu was initialized | ||||
| 		createMenu: $.noop,   // menu was initialized (original UI Menu) | ||||
| 		focus: $.noop,        // menu option got focus | ||||
| 		open: $.noop,         // menu was opened | ||||
| 		select: $.noop        // menu option was selected; return `false` to prevent closing | ||||
| 	}, | ||||
| 	/** Constructor */ | ||||
| 	_create: function() { | ||||
| 		var cssText, eventNames, targetId, | ||||
| 			opts = this.options; | ||||
|  | ||||
| 		this.$headStyle = null; | ||||
| 		this.$menu = null; | ||||
| 		this.menuIsTemp = false; | ||||
| 		this.currentTarget = null; | ||||
| 		this.extraData = {}; | ||||
| 		this.previousFocus = null; | ||||
|  | ||||
| 		if (opts.delegate == null) { | ||||
| 			$.error("ui-contextmenu: Missing required option `delegate`."); | ||||
| 		} | ||||
| 		if (opts.preventSelect) { | ||||
| 			// Create a global style for all potential menu targets | ||||
| 			// If the contextmenu was bound to `document`, we apply the | ||||
| 			// selector relative to the <body> tag instead | ||||
| 			targetId = ($(this.element).is(document) ? $("body") | ||||
| 				: this.element).uniqueId().attr("id"); | ||||
| 			cssText = "#" + targetId + " " + opts.delegate + " { " + | ||||
| 					"-webkit-user-select: none; " + | ||||
| 					"-khtml-user-select: none; " + | ||||
| 					"-moz-user-select: none; " + | ||||
| 					"-ms-user-select: none; " + | ||||
| 					"user-select: none; " + | ||||
| 					"}"; | ||||
| 			this.$headStyle = $("<style class='moogle-contextmenu-style' />") | ||||
| 				.prop("type", "text/css") | ||||
| 				.appendTo("head"); | ||||
|  | ||||
| 			try { | ||||
| 				this.$headStyle.html(cssText); | ||||
| 			} catch ( e ) { | ||||
| 				// issue #47: fix for IE 6-8 | ||||
| 				this.$headStyle[0].styleSheet.cssText = cssText; | ||||
| 			} | ||||
| 			// TODO: the selectstart is not supported by FF? | ||||
| 			if (supportSelectstart) { | ||||
| 				this.element.on("selectstart" + this.eventNamespace, opts.delegate, | ||||
| 									  function(event) { | ||||
| 					event.preventDefault(); | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 		this._createUiMenu(opts.menu); | ||||
|  | ||||
| 		eventNames = "contextmenu" + this.eventNamespace; | ||||
| 		if (opts.taphold) { | ||||
| 			eventNames += " taphold" + this.eventNamespace; | ||||
| 		} | ||||
| 		this.element.on(eventNames, opts.delegate, $.proxy(this._openMenu, this)); | ||||
| 	}, | ||||
| 	/** Destructor, called on $().contextmenu("destroy"). */ | ||||
| 	_destroy: function() { | ||||
| 		this.element.off(this.eventNamespace); | ||||
|  | ||||
| 		this._createUiMenu(null); | ||||
|  | ||||
| 		if (this.$headStyle) { | ||||
| 			this.$headStyle.remove(); | ||||
| 			this.$headStyle = null; | ||||
| 		} | ||||
| 	}, | ||||
| 	/** (Re)Create jQuery UI Menu. */ | ||||
| 	_createUiMenu: function(menuDef) { | ||||
| 		var ct, ed, | ||||
| 			opts = this.options; | ||||
|  | ||||
| 		// Remove temporary <ul> if any | ||||
| 		if (this.isOpen()) { | ||||
| 			// #58: 'replaceMenu' in beforeOpen causing select: to lose ui.target | ||||
| 			ct = this.currentTarget; | ||||
| 			ed = this.extraData; | ||||
| 			// close without animation, to force async mode | ||||
| 			this._closeMenu(true); | ||||
| 			this.currentTarget = ct; | ||||
| 			this.extraData = ed; | ||||
| 		} | ||||
| 		if (this.menuIsTemp) { | ||||
| 			this.$menu.remove(); // this will also destroy ui.menu | ||||
| 		} else if (this.$menu) { | ||||
| 			this.$menu | ||||
| 				.menu("destroy") | ||||
| 				.removeClass(this.options.addClass) | ||||
| 				.hide(); | ||||
| 		} | ||||
| 		this.$menu = null; | ||||
| 		this.menuIsTemp = false; | ||||
| 		// If a menu definition array was passed, create a hidden <ul> | ||||
| 		// and generate the structure now | ||||
| 		if ( !menuDef ) { | ||||
| 			return; | ||||
| 		} else if ($.isArray(menuDef)) { | ||||
| 			this.$menu = $.moogle.contextmenu.createMenuMarkup(menuDef); | ||||
| 			this.menuIsTemp = true; | ||||
| 		}else if ( typeof menuDef === "string" ) { | ||||
| 			this.$menu = $(menuDef); | ||||
| 		} else { | ||||
| 			this.$menu = menuDef; | ||||
| 		} | ||||
| 		// Create - but hide - the jQuery UI Menu widget | ||||
| 		this.$menu | ||||
| 			.hide() | ||||
| 			.addClass(opts.addClass) | ||||
| 			// Create a menu instance that delegates events to our widget | ||||
| 			.menu($.extend(true, {}, opts.uiMenuOptions, { | ||||
| 				items: "> :not(.ui-widget-header)", | ||||
| 				blur: $.proxy(opts.blur, this), | ||||
| 				create: $.proxy(opts.createMenu, this), | ||||
| 				focus: $.proxy(opts.focus, this), | ||||
| 				select: $.proxy(function(event, ui) { | ||||
| 					// User selected a menu entry | ||||
| 					var retval, | ||||
| 						isParent = $.moogle.contextmenu.isMenu(ui.item), | ||||
| 						actionHandler = ui.item.data("actionHandler"); | ||||
|  | ||||
| 					ui.cmd = ui.item.attr("data-command"); | ||||
| 					ui.target = $(this.currentTarget); | ||||
| 					ui.extraData = this.extraData; | ||||
| 					// ignore clicks, if they only open a sub-menu | ||||
| 					if ( !isParent || !opts.ignoreParentSelect) { | ||||
| 						retval = this._trigger.call(this, "select", event, ui); | ||||
| 						if ( actionHandler ) { | ||||
| 							retval = actionHandler.call(this, event, ui); | ||||
| 						} | ||||
| 						if ( retval !== false ) { | ||||
| 							this._closeMenu.call(this); | ||||
| 						} | ||||
| 						event.preventDefault(); | ||||
| 					} | ||||
| 				}, this) | ||||
| 			})); | ||||
| 	}, | ||||
| 	/** Open popup (called on 'contextmenu' event). */ | ||||
| 	_openMenu: function(event, recursive) { | ||||
| 		var res, promise, ui, | ||||
| 			opts = this.options, | ||||
| 			posOption = opts.position, | ||||
| 			self = this, | ||||
| 			manualTrigger = !!event.isTrigger; | ||||
|  | ||||
| 		if ( !opts.autoTrigger && !manualTrigger ) { | ||||
| 			// ignore browser's `contextmenu` events | ||||
| 			return; | ||||
| 		} | ||||
| 		// Prevent browser from opening the system context menu | ||||
| 		event.preventDefault(); | ||||
|  | ||||
| 		this.currentTarget = event.target; | ||||
| 		this.extraData = event._extraData || {}; | ||||
|  | ||||
| 		ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData, | ||||
| 			   originalEvent: event, result: null }; | ||||
|  | ||||
| 		if ( !recursive ) { | ||||
| 			res = this._trigger("beforeOpen", event, ui); | ||||
| 			promise = (ui.result && $.isFunction(ui.result.promise)) ? ui.result : null; | ||||
| 			ui.result = null; | ||||
| 			if ( res === false ) { | ||||
| 				this.currentTarget = null; | ||||
| 				return false; | ||||
| 			} else if ( promise ) { | ||||
| 				// Handler returned a Deferred or Promise. Delay menu open until | ||||
| 				// the promise is resolved | ||||
| 				promise.done(function() { | ||||
| 					self._openMenu(event, true); | ||||
| 				}); | ||||
| 				this.currentTarget = null; | ||||
| 				return false; | ||||
| 			} | ||||
| 			ui.menu = this.$menu; // Might have changed in beforeOpen | ||||
| 		} | ||||
|  | ||||
| 		// Register global event handlers that close the dropdown-menu | ||||
| 		$(document).on("keydown" + this.eventNamespace, function(event) { | ||||
| 			if ( event.which === $.ui.keyCode.ESCAPE ) { | ||||
| 				self._closeMenu(); | ||||
| 			} | ||||
| 		}).on("mousedown" + this.eventNamespace + " touchstart" + this.eventNamespace, | ||||
| 				function(event) { | ||||
| 			// Close menu when clicked outside menu | ||||
| 			if ( !$(event.target).closest(".ui-menu-item").length ) { | ||||
| 				self._closeMenu(); | ||||
| 			} | ||||
| 		}); | ||||
| 		$(window).on("blur" + this.eventNamespace, function(event) { | ||||
| 			if ( opts.closeOnWindowBlur ) { | ||||
| 				self._closeMenu(); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		// required for custom positioning (issue #18 and #13). | ||||
| 		if ($.isFunction(posOption)) { | ||||
| 			posOption = posOption(event, ui); | ||||
| 		} | ||||
| 		posOption = $.extend({ | ||||
| 			my: "left top", | ||||
| 			at: "left bottom", | ||||
| 			// if called by 'open' method, event does not have pageX/Y | ||||
| 			of: (event.pageX === undefined) ? event.target : event, | ||||
| 			collision: "fit" | ||||
| 		}, posOption); | ||||
|  | ||||
| 		// Update entry statuses from callbacks | ||||
| 		this._updateEntries(this.$menu); | ||||
|  | ||||
| 		// Finally display the popup | ||||
| 		this.$menu | ||||
| 			.show() // required to fix positioning error | ||||
| 			.css({ | ||||
| 				position: "absolute", | ||||
| 				left: 0, | ||||
| 				top: 0 | ||||
| 			}).position(posOption) | ||||
| 			.hide(); // hide again, so we can apply nice effects | ||||
|  | ||||
| 		if ( opts.preventContextMenuForPopup ) { | ||||
| 			this.$menu.on("contextmenu" + this.eventNamespace, function(event) { | ||||
| 				event.preventDefault(); | ||||
| 			}); | ||||
| 		} | ||||
| 		this._show(this.$menu, opts.show, function() { | ||||
| 			var $first; | ||||
|  | ||||
| 			// Set focus to first active menu entry | ||||
| 			if ( opts.autoFocus ) { | ||||
| 				self.previousFocus = $(event.target); | ||||
| 				// self.$menu.focus(); | ||||
| 				$first = self.$menu | ||||
| 					.children("li.ui-menu-item") | ||||
| 					.not(".ui-state-disabled") | ||||
| 					.first(); | ||||
| 				self.$menu.menu("focus", null, $first).focus(); | ||||
| 			} | ||||
| 			self._trigger.call(self, "open", event, ui); | ||||
| 		}); | ||||
| 	}, | ||||
| 	/** Close popup. */ | ||||
| 	_closeMenu: function(immediately) { | ||||
| 		var self = this, | ||||
| 			hideOpts = immediately ? false : this.options.hide, | ||||
| 			ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData }; | ||||
|  | ||||
| 		// Note: we don't want to unbind the 'contextmenu' event | ||||
| 		$(document) | ||||
| 			.off("mousedown" + this.eventNamespace) | ||||
| 			.off("touchstart" + this.eventNamespace) | ||||
| 			.off("keydown" + this.eventNamespace); | ||||
| 		$(window) | ||||
| 			.off("blur" + this.eventNamespace); | ||||
|  | ||||
| 		self.currentTarget = null; // issue #44 after hide animation is too late | ||||
| 		self.extraData = {}; | ||||
| 		if ( this.$menu ) { // #88: widget might have been destroyed already | ||||
| 			this.$menu | ||||
| 				.off("contextmenu" + this.eventNamespace); | ||||
| 			this._hide(this.$menu, hideOpts, function() { | ||||
| 				if ( self.previousFocus ) { | ||||
| 					self.previousFocus.focus(); | ||||
| 					self.previousFocus = null; | ||||
| 				} | ||||
| 				self._trigger("close", null, ui); | ||||
| 			}); | ||||
| 		} else { | ||||
| 			self._trigger("close", null, ui); | ||||
| 		} | ||||
| 	}, | ||||
| 	/** Handle $().contextmenu("option", key, value) calls. */ | ||||
| 	_setOption: function(key, value) { | ||||
| 		switch (key) { | ||||
| 		case "menu": | ||||
| 			this.replaceMenu(value); | ||||
| 			break; | ||||
| 		} | ||||
| 		$.Widget.prototype._setOption.apply(this, arguments); | ||||
| 	}, | ||||
| 	/** Return ui-menu entry (<LI> tag). */ | ||||
| 	_getMenuEntry: function(cmd) { | ||||
| 		return this.$menu.find("li[data-command=" + cmd + "]"); | ||||
| 	}, | ||||
| 	/** Close context menu. */ | ||||
| 	close: function() { | ||||
| 		if (this.isOpen()) { | ||||
| 			this._closeMenu(); | ||||
| 		} | ||||
| 	}, | ||||
| 	/* Apply status callbacks when menu is opened. */ | ||||
| 	_updateEntries: function() { | ||||
| 		var self = this, | ||||
| 			ui = { | ||||
| 				menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData }; | ||||
|  | ||||
| 		$.each(this.$menu.find(".ui-menu-item"), function(i, o) { | ||||
| 			var $entry = $(o), | ||||
| 				fn = $entry.data("disabledHandler"), | ||||
| 				res = fn ? fn({ type: "disabled" }, ui) : null; | ||||
|  | ||||
| 			ui.item = $entry; | ||||
| 			ui.cmd = $entry.attr("data-command"); | ||||
| 			// Evaluate `disabled()` callback | ||||
| 			if ( res != null ) { | ||||
| 				self.enableEntry(ui.cmd, !res); | ||||
| 				self.showEntry(ui.cmd, res !== "hide"); | ||||
| 			} | ||||
| 			// Evaluate `title()` callback | ||||
| 			fn = $entry.data("titleHandler"), | ||||
| 			res = fn ? fn({ type: "title" }, ui) : null; | ||||
| 			if ( res != null ) { | ||||
| 				self.setTitle(ui.cmd, "" + res); | ||||
| 			} | ||||
| 			// Evaluate `tooltip()` callback | ||||
| 			fn = $entry.data("tooltipHandler"), | ||||
| 			res = fn ? fn({ type: "tooltip" }, ui) : null; | ||||
| 			if ( res != null ) { | ||||
| 				$entry.attr("title", "" + res); | ||||
| 			} | ||||
| 		}); | ||||
| 	}, | ||||
| 	/** Enable or disable the menu command. */ | ||||
| 	enableEntry: function(cmd, flag) { | ||||
| 		this._getMenuEntry(cmd).toggleClass("ui-state-disabled", (flag === false)); | ||||
| 	}, | ||||
| 	/** Return ui-menu entry (LI tag) as jQuery object. */ | ||||
| 	getEntry: function(cmd) { | ||||
| 		return this._getMenuEntry(cmd); | ||||
| 	}, | ||||
| 	/** Return ui-menu entry wrapper as jQuery object. | ||||
| 		UI 1.10: this is the <a> tag inside the LI | ||||
| 		UI 1.11: this is the LI istself | ||||
| 		UI 1.12: this is the <div> tag inside the LI | ||||
| 	 */ | ||||
| 	getEntryWrapper: function(cmd) { | ||||
| 		return this._getMenuEntry(cmd).find(">[role=menuitem]").addBack("[role=menuitem]"); | ||||
| 	}, | ||||
| 	/** Return Menu element (UL). */ | ||||
| 	getMenu: function() { | ||||
| 		return this.$menu; | ||||
| 	}, | ||||
| 	/** Return true if menu is open. */ | ||||
| 	isOpen: function() { | ||||
| //            return this.$menu && this.$menu.is(":visible"); | ||||
| 		return !!this.$menu && !!this.currentTarget; | ||||
| 	}, | ||||
| 	/** Open context menu on a specific target (must match options.delegate) | ||||
| 	 *  Optional `extraData` is passed to event handlers as `ui.extraData`. | ||||
| 	 */ | ||||
| 	open: function(targetOrEvent, extraData) { | ||||
| 		// Fake a 'contextmenu' event | ||||
| 		extraData = extraData || {}; | ||||
|  | ||||
| 		var isEvent = (targetOrEvent && targetOrEvent.type && targetOrEvent.target), | ||||
| 			event =  isEvent ? targetOrEvent : {}, | ||||
| 			target = isEvent ? targetOrEvent.target : targetOrEvent, | ||||
| 			e = jQuery.Event("contextmenu", { | ||||
| 				target: $(target).get(0), | ||||
| 				pageX: event.pageX, | ||||
| 				pageY: event.pageY, | ||||
| 				originalEvent: isEvent ? targetOrEvent : undefined, | ||||
| 				_extraData: extraData | ||||
| 			}); | ||||
| 		return this.element.trigger(e); | ||||
| 	}, | ||||
| 	/** Replace the menu altogether. */ | ||||
| 	replaceMenu: function(data) { | ||||
| 		this._createUiMenu(data); | ||||
| 	}, | ||||
| 	/** Redefine a whole menu entry. */ | ||||
| 	setEntry: function(cmd, entry) { | ||||
| 		var $ul, | ||||
| 			$entryLi = this._getMenuEntry(cmd); | ||||
|  | ||||
| 		if (typeof entry === "string") { | ||||
| 			window.console && window.console.warn( | ||||
| 				"setEntry(cmd, t) with a plain string title is deprecated since v1.18." + | ||||
| 				"Use setTitle(cmd, '" + entry + "') instead."); | ||||
| 			return this.setTitle(cmd, entry); | ||||
| 		} | ||||
| 		$entryLi.empty(); | ||||
| 		entry.cmd = entry.cmd || cmd; | ||||
| 		$.moogle.contextmenu.createEntryMarkup(entry, $entryLi); | ||||
| 		if ($.isArray(entry.children)) { | ||||
| 			$ul = $("<ul/>").appendTo($entryLi); | ||||
| 			$.moogle.contextmenu.createMenuMarkup(entry.children, $ul); | ||||
| 		} | ||||
| 		// #110: jQuery UI 1.12: refresh only works when this class is not set: | ||||
| 		$entryLi.removeClass("ui-menu-item"); | ||||
| 		this.getMenu().menu("refresh"); | ||||
| 	}, | ||||
| 	/** Set icon (pass null to remove). */ | ||||
| 	setIcon: function(cmd, icon) { | ||||
| 		return this.updateEntry(cmd, { uiIcon: icon }); | ||||
| 	}, | ||||
| 	/** Set title. */ | ||||
| 	setTitle: function(cmd, title) { | ||||
| 		return this.updateEntry(cmd, { title: title }); | ||||
| 	}, | ||||
| 	// /** Set tooltip (pass null to remove). */ | ||||
| 	// setTooltip: function(cmd, tooltip) { | ||||
| 	// 	this._getMenuEntry(cmd).attr("title", tooltip); | ||||
| 	// }, | ||||
| 	/** Show or hide the menu command. */ | ||||
| 	showEntry: function(cmd, flag) { | ||||
| 		this._getMenuEntry(cmd).toggle(flag !== false); | ||||
| 	}, | ||||
| 	/** Redefine selective attributes of a menu entry. */ | ||||
| 	updateEntry: function(cmd, entry) { | ||||
| 		var $icon, $wrapper, | ||||
| 			$entryLi = this._getMenuEntry(cmd); | ||||
|  | ||||
| 		if ( entry.title !== undefined ) { | ||||
| 			$.moogle.contextmenu.updateTitle($entryLi, "" + entry.title); | ||||
| 		} | ||||
| 		if ( entry.tooltip !== undefined ) { | ||||
| 			if ( entry.tooltip === null ) { | ||||
| 				$entryLi.removeAttr("title"); | ||||
| 			} else { | ||||
| 				$entryLi.attr("title", entry.tooltip); | ||||
| 			} | ||||
| 		} | ||||
| 		if ( entry.uiIcon !== undefined ) { | ||||
| 			$wrapper = this.getEntryWrapper(cmd), | ||||
| 			$icon = $wrapper.find("span.ui-icon").not(".ui-menu-icon"); | ||||
| 			$icon.remove(); | ||||
| 			if ( entry.uiIcon ) { | ||||
| 				$wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon)); | ||||
| 			} | ||||
| 		} | ||||
| 		if ( entry.hide !== undefined ) { | ||||
| 			$entryLi.toggle(!entry.hide); | ||||
| 		} else if ( entry.show !== undefined ) { | ||||
| 			// Note: `show` is an undocumented variant. `hide: false` is preferred | ||||
| 			$entryLi.toggle(!!entry.show); | ||||
| 		} | ||||
| 		// if ( entry.isHeader !== undefined ) { | ||||
| 		// 	$entryLi.toggleClass("ui-widget-header", !!entry.isHeader); | ||||
| 		// } | ||||
| 		if ( entry.data !== undefined ) { | ||||
| 			$entryLi.data(entry.data); | ||||
| 		} | ||||
|  | ||||
| 		// Set/clear class names, but handle ui-state-disabled separately | ||||
| 		if ( entry.disabled === undefined ) { | ||||
| 			entry.disabled = $entryLi.hasClass("ui-state-disabled"); | ||||
| 		} | ||||
| 		if ( entry.setClass ) { | ||||
| 			if ( $entryLi.hasClass("ui-menu-item") ) { | ||||
| 				entry.setClass += " ui-menu-item"; | ||||
| 			} | ||||
| 			$entryLi.removeClass(); | ||||
| 			$entryLi.addClass(entry.setClass); | ||||
| 		} else if ( entry.addClass ) { | ||||
| 			$entryLi.addClass(entry.addClass); | ||||
| 		} | ||||
| 		$entryLi.toggleClass("ui-state-disabled", !!entry.disabled); | ||||
| 		// // #110: jQuery UI 1.12: refresh only works when this class is not set: | ||||
| 		// $entryLi.removeClass("ui-menu-item"); | ||||
| 		// this.getMenu().menu("refresh"); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| /* | ||||
|  * Global functions | ||||
|  */ | ||||
| $.extend($.moogle.contextmenu, { | ||||
| 	/** Convert a menu description into a into a <li> content. */ | ||||
| 	createEntryMarkup: function(entry, $parentLi) { | ||||
| 		var $wrapper = null; | ||||
|  | ||||
| 		$parentLi.attr("data-command", entry.cmd); | ||||
|  | ||||
| 		if ( !/[^\-\u2014\u2013\s]/.test( entry.title ) ) { | ||||
| 			// hyphen, em dash, en dash: separator as defined by UI Menu 1.10 | ||||
| 			$parentLi.text(entry.title); | ||||
| 		} else { | ||||
| 			if ( isLTE110 ) { | ||||
| 				// jQuery UI Menu 1.10 or before required an `<a>` tag | ||||
| 				$wrapper = $("<a/>", { | ||||
| 						html: "" + entry.title, | ||||
| 						href: "#" | ||||
| 					}).appendTo($parentLi); | ||||
|  | ||||
| 			} else if ( isLTE111 ) { | ||||
| 				// jQuery UI Menu 1.11 preferes to avoid `<a>` tags or <div> wrapper | ||||
| 				$parentLi.html("" + entry.title); | ||||
| 				$wrapper = $parentLi; | ||||
|  | ||||
| 			} else { | ||||
| 				// jQuery UI Menu 1.12 introduced `<div>` wrappers | ||||
| 				$wrapper = $("<div/>", { | ||||
| 						html: "" + entry.title | ||||
| 					}).appendTo($parentLi); | ||||
| 			} | ||||
| 			if ( entry.uiIcon ) { | ||||
| 				$wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon)); | ||||
| 			} | ||||
| 			// Store option callbacks in entry's data | ||||
| 			$.each( [ "action", "disabled", "title", "tooltip" ], function(i, attr) { | ||||
| 				if ( $.isFunction(entry[attr]) ) { | ||||
| 					$parentLi.data(attr + "Handler", entry[attr]); | ||||
| 				} | ||||
| 			}); | ||||
| 			if ( entry.disabled === true ) { | ||||
| 				$parentLi.addClass("ui-state-disabled"); | ||||
| 			} | ||||
| 			if ( entry.isHeader ) { | ||||
| 				$parentLi.addClass("ui-widget-header"); | ||||
| 			} | ||||
| 			if ( entry.addClass ) { | ||||
| 				$parentLi.addClass(entry.addClass); | ||||
| 			} | ||||
| 			if ( $.isPlainObject(entry.data) ) { | ||||
| 				$parentLi.data(entry.data); | ||||
| 			} | ||||
| 			if ( typeof entry.tooltip === "string" ) { | ||||
| 				$parentLi.attr("title", entry.tooltip); | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
| 	/** Convert a nested array of command objects into a <ul> structure. */ | ||||
| 	createMenuMarkup: function(options, $parentUl) { | ||||
| 		var i, menu, $ul, $li; | ||||
| 		if ( $parentUl == null ) { | ||||
| 			$parentUl = $("<ul class='ui-helper-hidden' />").appendTo("body"); | ||||
| 		} | ||||
| 		for (i = 0; i < options.length; i++) { | ||||
| 			menu = options[i]; | ||||
| 			$li = $("<li/>").appendTo($parentUl); | ||||
|  | ||||
| 			$.moogle.contextmenu.createEntryMarkup(menu, $li); | ||||
|  | ||||
| 			if ( $.isArray(menu.children) ) { | ||||
| 				$ul = $("<ul/>").appendTo($li); | ||||
| 				$.moogle.contextmenu.createMenuMarkup(menu.children, $ul); | ||||
| 			} | ||||
| 		} | ||||
| 		return $parentUl; | ||||
| 	}, | ||||
| 	/** Returns true if the menu item has child menu items */ | ||||
| 	isMenu: function(item) { | ||||
| 		if ( isLTE110 ) { | ||||
| 			return item.has(">a[aria-haspopup='true']").length > 0; | ||||
| 		} else if ( isLTE111 ) {  // jQuery UI 1.11 used no tag wrappers | ||||
| 			return item.is("[aria-haspopup='true']"); | ||||
| 		} else { | ||||
| 			return item.has(">div[aria-haspopup='true']").length > 0; | ||||
| 		} | ||||
| 	}, | ||||
| 	/** Replace the title of elem', but retain icons andchild entries. */ | ||||
| 	replaceFirstTextNodeChild: function(elem, html) { | ||||
| 		var $icons = elem.find(">span.ui-icon,>ul.ui-menu").detach(); | ||||
|  | ||||
| 		elem | ||||
| 			.empty() | ||||
| 			.html(html) | ||||
| 			.append($icons); | ||||
| 	}, | ||||
| 	/** Updates the menu item's title */ | ||||
| 	updateTitle: function(item, title) { | ||||
| 		if ( isLTE110 ) {  // jQuery UI 1.10 and before used <a> tags | ||||
| 			$.moogle.contextmenu.replaceFirstTextNodeChild($("a", item), title); | ||||
| 		} else if ( isLTE111 ) {  // jQuery UI 1.11 used no tag wrappers | ||||
| 			$.moogle.contextmenu.replaceFirstTextNodeChild(item, title); | ||||
| 		} else {  // jQuery UI 1.12+ introduced <div> tag wrappers | ||||
| 			$.moogle.contextmenu.replaceFirstTextNodeChild($("div", item), title); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| })); | ||||
							
								
								
									
										1
									
								
								public/libraries/jquery.ui-contextmenu.min.js.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/libraries/jquery.ui-contextmenu.min.js.map
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -4,11 +4,12 @@ const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const anonymization = require('../../services/anonymization'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.post('/anonymize', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/anonymize', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await anonymization.anonymize(); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -4,9 +4,10 @@ const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const app_info = require('../../services/app_info'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     res.send(app_info); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -7,8 +7,9 @@ const utils = require('../../services/utils'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
| const auth = require('../../services/auth'); | ||||
| const log = require('../../services/log'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await sql.doInTransaction(async () => { | ||||
|         const noteIdsToDelete = await sql.getFirstColumn("SELECT note_id FROM notes WHERE is_deleted = 1"); | ||||
|         const noteIdsSql = noteIdsToDelete | ||||
| @@ -21,6 +22,10 @@ router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, n | ||||
|  | ||||
|         await sql.execute("DELETE FROM notes_tree WHERE is_deleted = 1"); | ||||
|  | ||||
|         await sql.execute("DELETE FROM notes_image WHERE is_deleted = 1"); | ||||
|  | ||||
|         await sql.execute("DELETE FROM images WHERE is_deleted = 1"); | ||||
|  | ||||
|         await sql.execute("DELETE FROM notes WHERE is_deleted = 1"); | ||||
|  | ||||
|         await sql.execute("DELETE FROM recent_notes"); | ||||
| @@ -34,14 +39,41 @@ router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, n | ||||
|     }); | ||||
|  | ||||
|     res.send({}); | ||||
| })); | ||||
|  | ||||
| router.post('/cleanup-unused-images', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
|         const unusedImageIds = await sql.getFirstColumn(` | ||||
|           SELECT images.image_id  | ||||
|           FROM images  | ||||
|             LEFT JOIN notes_image ON notes_image.image_id = images.image_id AND notes_image.is_deleted = 0 | ||||
|           WHERE | ||||
|             images.is_deleted = 0 | ||||
|             AND notes_image.note_image_id IS NULL`); | ||||
|  | ||||
|         const now = utils.nowDate(); | ||||
|  | ||||
|         for (const imageId of unusedImageIds) { | ||||
|             log.info(`Deleting unused image: ${imageId}`); | ||||
|  | ||||
|             await sql.execute("UPDATE images SET is_deleted = 1, data = null, date_modified = ? WHERE image_id = ?", | ||||
|                 [now, imageId]); | ||||
|  | ||||
|             await sync_table.addImageSync(imageId, sourceId); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
| router.post('/vacuum-database', auth.checkApiAuth, async (req, res, next) => { | ||||
|     res.send({}); | ||||
| })); | ||||
|  | ||||
| router.post('/vacuum-database', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await sql.execute("VACUUM"); | ||||
|  | ||||
|     log.info("Database has been vacuumed."); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -4,14 +4,15 @@ const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const sql = require('../../services/sql'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await deleteOld(); | ||||
|  | ||||
|     const result = await sql.getAll("SELECT * FROM event_log ORDER BY date_added DESC"); | ||||
|  | ||||
|     res.send(result); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| async function deleteOld() { | ||||
|     const cutoffId = await sql.getFirstValue("SELECT id FROM event_log ORDER BY id DESC LIMIT 1000, 1"); | ||||
|   | ||||
| @@ -8,8 +8,9 @@ const sql = require('../../services/sql'); | ||||
| const data_dir = require('../../services/data_dir'); | ||||
| const html = require('html'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('/:noteId/to/:directory', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/:noteId/to/:directory', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteId = req.params.noteId; | ||||
|     const directory = req.params.directory.replace(/[^0-9a-zA-Z_-]/gi, ''); | ||||
|  | ||||
| @@ -30,7 +31,7 @@ router.get('/:noteId/to/:directory', auth.checkApiAuth, async (req, res, next) = | ||||
|     await exportNote(noteTreeId, completeExportDir); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| async function exportNote(noteTreeId, dir) { | ||||
|     const noteTree = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]); | ||||
|   | ||||
							
								
								
									
										148
									
								
								routes/api/image.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								routes/api/image.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | ||||
| "use strict"; | ||||
|  | ||||
| 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 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'); | ||||
|  | ||||
| router.get('/:imageId/:filename', auth.checkApiAuthOrElectron, wrap(async (req, res, next) => { | ||||
|     const image = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [req.params.imageId]); | ||||
|  | ||||
|     if (!image) { | ||||
|         return res.status(404).send({}); | ||||
|     } | ||||
|     else if (image.data === null) { | ||||
|         res.set('Content-Type', 'image/png'); | ||||
|         return res.send(fs.readFileSync(RESOURCE_DIR + '/db/image-deleted.png')); | ||||
|     } | ||||
|  | ||||
|     res.set('Content-Type', 'image/' + image.format); | ||||
|  | ||||
|     res.send(image.data); | ||||
| })); | ||||
|  | ||||
| router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async (req, res, next) => { | ||||
|     const sourceId = req.headers.source_id; | ||||
|     const noteId = req.query.noteId; | ||||
|     const file = req.file; | ||||
|  | ||||
|     const note = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]); | ||||
|  | ||||
|     if (!note) { | ||||
|         return res.status(404).send(`Note ${noteId} doesn't exist.`); | ||||
|     } | ||||
|  | ||||
|     if (!["image/png", "image/jpeg", "image/gif"].includes(file.mimetype)) { | ||||
|         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", { | ||||
|             image_id: imageId, | ||||
|             format: imageFormat.ext, | ||||
|             name: fileName, | ||||
|             checksum: utils.hash(optimizedImage), | ||||
|             data: optimizedImage, | ||||
|             is_deleted: 0, | ||||
|             date_modified: now, | ||||
|             date_created: now | ||||
|         }); | ||||
|  | ||||
|         await sync_table.addImageSync(imageId, sourceId); | ||||
|  | ||||
|         const noteImageId = utils.newNoteImageId(); | ||||
|  | ||||
|         await sql.insert("notes_image", { | ||||
|             note_image_id: noteImageId, | ||||
|             note_id: noteId, | ||||
|             image_id: imageId, | ||||
|             is_deleted: 0, | ||||
|             date_modified: now, | ||||
|             date_created: now | ||||
|         }); | ||||
|  | ||||
|         await sync_table.addNoteImageSync(noteImageId, sourceId); | ||||
|     }); | ||||
|  | ||||
|     res.send({ | ||||
|         uploaded: true, | ||||
|         url: `/api/images/${imageId}/${fileName}` | ||||
|     }); | ||||
| })); | ||||
|  | ||||
| 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; | ||||
| @@ -8,8 +8,9 @@ const data_dir = require('../../services/data_dir'); | ||||
| const utils = require('../../services/utils'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const directory = req.params.directory.replace(/[^0-9a-zA-Z_-]/gi, ''); | ||||
|     const parentNoteId = req.params.parentNoteId; | ||||
|  | ||||
| @@ -18,7 +19,7 @@ router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, async (req, res, n | ||||
|     await sql.doInTransaction(async () => await importNotes(dir, parentNoteId)); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| async function importNotes(dir, parentNoteId) { | ||||
|     const parent = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [parentNoteId]); | ||||
|   | ||||
| @@ -9,8 +9,9 @@ const auth = require('../../services/auth'); | ||||
| const password_encryption = require('../../services/password_encryption'); | ||||
| const protected_session = require('../../services/protected_session'); | ||||
| const app_info = require('../../services/app_info'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.post('/sync', async (req, res, next) => { | ||||
| router.post('/sync', wrap(async (req, res, next) => { | ||||
|     const timestampStr = req.body.timestamp; | ||||
|  | ||||
|     const timestamp = utils.parseDate(timestampStr); | ||||
| @@ -44,10 +45,10 @@ router.post('/sync', async (req, res, next) => { | ||||
|     res.send({ | ||||
|         sourceId: source_id.getCurrentSourceId() | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| // this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username) | ||||
| router.post('/protected', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/protected', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const password = req.body.password; | ||||
|  | ||||
|     if (!await password_encryption.verifyPassword(password)) { | ||||
| @@ -67,6 +68,6 @@ router.post('/protected', auth.checkApiAuth, async (req, res, next) => { | ||||
|         success: true, | ||||
|         protectedSessionId: protectedSessionId | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -6,20 +6,21 @@ const auth = require('../../services/auth'); | ||||
| const options = require('../../services/options'); | ||||
| const migration = require('../../services/migration'); | ||||
| const app_info = require('../../services/app_info'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', auth.checkApiAuthForMigrationPage, async (req, res, next) => { | ||||
| router.get('', auth.checkApiAuthForMigrationPage, wrap(async (req, res, next) => { | ||||
|     res.send({ | ||||
|         db_version: parseInt(await options.getOption('db_version')), | ||||
|         app_db_version: app_info.db_version | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('', auth.checkApiAuthForMigrationPage, async (req, res, next) => { | ||||
| router.post('', auth.checkApiAuthForMigrationPage, wrap(async (req, res, next) => { | ||||
|     const migrations = await migration.migrate(); | ||||
|  | ||||
|     res.send({ | ||||
|         migrations: migrations | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -7,8 +7,9 @@ const auth = require('../../services/auth'); | ||||
| const data_encryption = require('../../services/data_encryption'); | ||||
| const protected_session = require('../../services/protected_session'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteId = req.params.noteId; | ||||
|     const history = await sql.getAll("SELECT * FROM notes_history WHERE note_id = ? order by date_modified_to desc", [noteId]); | ||||
|  | ||||
| @@ -22,9 +23,9 @@ router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
|     } | ||||
|  | ||||
|     res.send(history); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
| @@ -34,6 +35,6 @@ router.put('', auth.checkApiAuth, async (req, res, next) => { | ||||
|     }); | ||||
|  | ||||
|     res.send(); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -8,8 +8,9 @@ const notes = require('../../services/notes'); | ||||
| const log = require('../../services/log'); | ||||
| const protected_session = require('../../services/protected_session'); | ||||
| const data_encryption = require('../../services/data_encryption'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteId = req.params.noteId; | ||||
|  | ||||
|     const detail = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]); | ||||
| @@ -30,9 +31,9 @@ router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
|     res.send({ | ||||
|         detail: detail | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('/:parentNoteId/children', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/:parentNoteId/children', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const sourceId = req.headers.source_id; | ||||
|     const parentNoteId = req.params.parentNoteId; | ||||
|     const note = req.body; | ||||
| @@ -43,9 +44,9 @@ router.post('/:parentNoteId/children', auth.checkApiAuth, async (req, res, next) | ||||
|         'note_id': noteId, | ||||
|         'note_tree_id': noteTreeId | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const note = req.body; | ||||
|     const noteId = req.params.noteId; | ||||
|     const sourceId = req.headers.source_id; | ||||
| @@ -54,17 +55,17 @@ router.put('/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
|     await notes.updateNote(noteId, note, dataKey, sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.delete('/:noteTreeId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.delete('/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await sql.doInTransaction(async () => { | ||||
|         await notes.deleteNote(req.params.noteTreeId, req.headers.source_id); | ||||
|     }); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const search = '%' + req.query.search + '%'; | ||||
|  | ||||
|     const result = await sql.getAll("SELECT note_id FROM notes WHERE note_title LIKE ? OR note_text LIKE ?", [search, search]); | ||||
| @@ -76,6 +77,6 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => { | ||||
|     } | ||||
|  | ||||
|     res.send(noteIdList); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -3,31 +3,25 @@ | ||||
| const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const sql = require('../../services/sql'); | ||||
| const utils = require('../../services/utils'); | ||||
| const auth = require('../../services/auth'); | ||||
| const utils = require('../../services/utils'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| /** | ||||
|  * Code in this file deals with moving and cloning note tree rows. Relationship between note and parent note is unique | ||||
|  * for not deleted note trees. There may be multiple deleted note-parent note relationships. | ||||
|  */ | ||||
|  | ||||
| router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|     const parentNoteId = req.params.parentNoteId; | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
|     const noteToMove = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]); | ||||
|     const noteToMove = await getNoteTree(noteTreeId); | ||||
|  | ||||
|     const existing = await getExistingNoteTree(parentNoteId, noteToMove.note_id); | ||||
|  | ||||
|     if (existing && !existing.is_deleted && existing.note_tree_id !== noteTreeId) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'This note already exists in target parent note.' | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     if (!await checkTreeCycle(parentNoteId, noteToMove.note_id)) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'Moving note here would create cycle.' | ||||
|         }); | ||||
|     if (!await validateParentChild(res, parentNoteId, noteToMove.note_id, noteTreeId)) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const maxNotePos = await sql.getFirstValue('SELECT MAX(note_position) FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0', [parentNoteId]); | ||||
| @@ -43,33 +37,20 @@ router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, async (req, | ||||
|     }); | ||||
|  | ||||
|     res.send({ success: true }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|     const beforeNoteTreeId = req.params.beforeNoteTreeId; | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
|     const noteToMove = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]); | ||||
|     const beforeNote = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [beforeNoteTreeId]); | ||||
|     const noteToMove = await getNoteTree(noteTreeId); | ||||
|     const beforeNote = await getNoteTree(beforeNoteTreeId); | ||||
|  | ||||
|     const existing = await getExistingNoteTree(beforeNote.parent_note_id, noteToMove.note_id); | ||||
|  | ||||
|     if (existing && !existing.is_deleted && existing.note_tree_id !== noteTreeId) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'This note already exists in target parent note.' | ||||
|         }); | ||||
|     if (!await validateParentChild(res, beforeNote.parent_note_id, noteToMove.note_id, noteTreeId)) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (!await checkTreeCycle(beforeNote.parent_note_id, noteToMove.note_id)) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'Moving note here would create cycle.' | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     if (beforeNote) { | ||||
|     await sql.doInTransaction(async () => { | ||||
|         // we don't change date_modified so other changes are prioritized in case of conflict | ||||
|         // also we would have to sync all those modified note trees otherwise hash checks would fail | ||||
| @@ -87,37 +68,20 @@ router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, asyn | ||||
|     }); | ||||
|  | ||||
|     res.send({ success: true }); | ||||
|     } | ||||
|     else { | ||||
|         res.status(500).send("Before note " + beforeNoteTreeId + " doesn't exist."); | ||||
|     } | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|     const afterNoteTreeId = req.params.afterNoteTreeId; | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
|     const noteToMove = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]); | ||||
|     const afterNote = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [afterNoteTreeId]); | ||||
|     const noteToMove = await getNoteTree(noteTreeId); | ||||
|     const afterNote = await getNoteTree(afterNoteTreeId); | ||||
|  | ||||
|     const existing = await getExistingNoteTree(afterNote.parent_note_id, noteToMove.note_id); | ||||
|  | ||||
|     if (existing && !existing.is_deleted && existing.note_tree_id !== noteTreeId) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'This note already exists in target parent note.' | ||||
|         }); | ||||
|     if (!await validateParentChild(res, afterNote.parent_note_id, noteToMove.note_id, noteTreeId)) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (!await checkTreeCycle(afterNote.parent_note_id, noteToMove.note_id)) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'Moving note here would create cycle.' | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     if (afterNote) { | ||||
|     await sql.doInTransaction(async () => { | ||||
|         // we don't change date_modified so other changes are prioritized in case of conflict | ||||
|         // also we would have to sync all those modified note trees otherwise hash checks would fail | ||||
| @@ -133,32 +97,16 @@ router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, async | ||||
|     }); | ||||
|  | ||||
|     res.send({ success: true }); | ||||
|     } | ||||
|     else { | ||||
|         res.status(500).send("After note " + afterNoteTreeId + " doesn't exist."); | ||||
|     } | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const parentNoteId = req.params.parentNoteId; | ||||
|     const childNoteId = req.params.childNoteId; | ||||
|     const prefix = req.body.prefix; | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
|     const existing = await getExistingNoteTree(parentNoteId, childNoteId); | ||||
|  | ||||
|     if (existing && !existing.is_deleted) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'This note already exists in target parent note.' | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     if (!await checkTreeCycle(parentNoteId, childNoteId)) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'Cloning note here would create cycle.' | ||||
|         }); | ||||
|     if (!await validateParentChild(res, parentNoteId, childNoteId)) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const maxNotePos = await sql.getFirstValue('SELECT MAX(note_position) FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0', [parentNoteId]); | ||||
| @@ -184,33 +132,17 @@ router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req | ||||
|     }); | ||||
|  | ||||
|     res.send({ success: true }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteId = req.params.noteId; | ||||
|     const afterNoteTreeId = req.params.afterNoteTreeId; | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
|     const afterNote = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [afterNoteTreeId]); | ||||
|     const afterNote = await getNoteTree(afterNoteTreeId); | ||||
|  | ||||
|     if (!afterNote) { | ||||
|         return res.status(500).send("After note " + afterNoteTreeId + " doesn't exist."); | ||||
|     } | ||||
|  | ||||
|     const existing = await getExistingNoteTree(afterNote.parent_note_id, noteId); | ||||
|  | ||||
|     if (existing && !existing.is_deleted) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'This note already exists in target parent note.' | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     if (!await checkTreeCycle(afterNote.parent_note_id, noteId)) { | ||||
|         return res.send({ | ||||
|             success: false, | ||||
|             message: 'Cloning note here would create cycle.' | ||||
|         }); | ||||
|     if (!await validateParentChild(res, afterNote.parent_note_id, noteId)) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
| @@ -237,20 +169,48 @@ router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (re | ||||
|     }); | ||||
|  | ||||
|     res.send({ success: true }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| async function loadSubTreeNoteIds(parentNoteId, subTreeNoteIds) { | ||||
|     subTreeNoteIds.push(parentNoteId); | ||||
|  | ||||
|     const children = await sql.getFirstColumn("SELECT note_id FROM notes_tree WHERE parent_note_id = ?", [parentNoteId]); | ||||
|     const children = await sql.getFirstColumn("SELECT note_id FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0", [parentNoteId]); | ||||
|  | ||||
|     for (const childNoteId of children) { | ||||
|         await loadSubTreeNoteIds(childNoteId, subTreeNoteIds); | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function getNoteTree(noteTreeId) { | ||||
|     return sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]); | ||||
| } | ||||
|  | ||||
| async function validateParentChild(res, parentNoteId, childNoteId, noteTreeId = null) { | ||||
|     const existing = await getExistingNoteTree(parentNoteId, childNoteId); | ||||
|  | ||||
|     if (existing && (noteTreeId === null || existing.note_tree_id !== noteTreeId)) { | ||||
|         res.send({ | ||||
|             success: false, | ||||
|             message: 'This note already exists in the target.' | ||||
|         }); | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     if (!await checkTreeCycle(parentNoteId, childNoteId)) { | ||||
|         res.send({ | ||||
|             success: false, | ||||
|             message: 'Moving note here would create cycle.' | ||||
|         }); | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
| } | ||||
|  | ||||
| async function getExistingNoteTree(parentNoteId, childNoteId) { | ||||
|     return await sql.getFirst('SELECT * FROM notes_tree WHERE note_id = ? AND parent_note_id = ?', [childNoteId, parentNoteId]); | ||||
|     return await sql.getFirst('SELECT * FROM notes_tree WHERE note_id = ? AND parent_note_id = ? AND is_deleted = 0', [childNoteId, parentNoteId]); | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -273,7 +233,7 @@ async function checkTreeCycle(parentNoteId, childNoteId) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         const parentNoteIds = await sql.getFirstColumn("SELECT DISTINCT parent_note_id FROM notes_tree WHERE note_id = ?", [parentNoteId]); | ||||
|         const parentNoteIds = await sql.getFirstColumn("SELECT DISTINCT parent_note_id FROM notes_tree WHERE note_id = ? AND is_deleted = 0", [parentNoteId]); | ||||
|  | ||||
|         for (const pid of parentNoteIds) { | ||||
|             if (!await checkTreeCycleInner(pid)) { | ||||
| @@ -287,7 +247,7 @@ async function checkTreeCycle(parentNoteId, childNoteId) { | ||||
|     return await checkTreeCycleInner(parentNoteId); | ||||
| } | ||||
|  | ||||
| router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|     const expanded = req.params.expanded; | ||||
|  | ||||
| @@ -298,6 +258,6 @@ router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, async (req, res | ||||
|     }); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -5,11 +5,12 @@ const router = express.Router(); | ||||
| const sql = require('../../services/sql'); | ||||
| const changePassword = require('../../services/change_password'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.post('/change', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/change', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const result = await changePassword.changePassword(req.body['current_password'], req.body['new_password'], req); | ||||
|  | ||||
|     res.send(result); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -4,8 +4,9 @@ const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const sql = require('../../services/sql'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('/', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const recentChanges = await sql.getAll( | ||||
|         `SELECT  | ||||
|             notes.is_deleted AS current_is_deleted, | ||||
| @@ -19,6 +20,6 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => { | ||||
|         LIMIT 1000`); | ||||
|  | ||||
|     res.send(recentChanges); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -7,12 +7,13 @@ const auth = require('../../services/auth'); | ||||
| const utils = require('../../services/utils'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
| const options = require('../../services/options'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     res.send(await getRecentNotes()); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:noteTreeId/:notePath', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteTreeId/:notePath', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|     const notePath = req.params.notePath; | ||||
|     const sourceId = req.headers.source_id; | ||||
| @@ -31,7 +32,7 @@ router.put('/:noteTreeId/:notePath', auth.checkApiAuth, async (req, res, next) = | ||||
|     }); | ||||
|  | ||||
|     res.send(await getRecentNotes()); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| async function getRecentNotes() { | ||||
|     return await sql.getAll(` | ||||
|   | ||||
| @@ -5,24 +5,25 @@ const router = express.Router(); | ||||
| const sql = require('../../services/sql'); | ||||
| const options = require('../../services/options'); | ||||
| const auth = require('../../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| // options allowed to be updated directly in settings dialog | ||||
| const ALLOWED_OPTIONS = ['protected_session_timeout', 'history_snapshot_time_interval']; | ||||
|  | ||||
| router.get('/all', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/all', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const settings = await sql.getMap("SELECT opt_name, opt_value FROM options"); | ||||
|  | ||||
|     res.send(settings); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const settings = await sql.getMap("SELECT opt_name, opt_value FROM options WHERE opt_name IN (" | ||||
|         + ALLOWED_OPTIONS.map(x => '?').join(",") + ")", ALLOWED_OPTIONS); | ||||
|  | ||||
|     res.send(settings); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('/', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const body = req.body; | ||||
|     const sourceId = req.headers.source_id; | ||||
|  | ||||
| @@ -38,6 +39,6 @@ router.post('/', auth.checkApiAuth, async (req, res, next) => { | ||||
|     else { | ||||
|         res.send("not allowed option to set"); | ||||
|     } | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -8,8 +8,9 @@ const sql = require('../../services/sql'); | ||||
| const utils = require('../../services/utils'); | ||||
| const my_scrypt = require('../../services/my_scrypt'); | ||||
| const password_encryption = require('../../services/password_encryption'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.post('', auth.checkAppNotInitialized, async (req, res, next) => { | ||||
| router.post('', auth.checkAppNotInitialized, wrap(async (req, res, next) => { | ||||
|     const { username, password } = req.body; | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
| @@ -27,6 +28,6 @@ router.post('', auth.checkAppNotInitialized, async (req, res, next) => { | ||||
|     sql.setDbReadyAsResolved(); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -4,8 +4,9 @@ const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const auth = require('../../services/auth'); | ||||
| const sql = require('../../services/sql'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.post('/execute', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/execute', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const query = req.body.query; | ||||
|  | ||||
|     try { | ||||
| @@ -20,6 +21,6 @@ router.post('/execute', auth.checkApiAuth, async (req, res, next) => { | ||||
|             error: e.message | ||||
|         }); | ||||
|     } | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -10,19 +10,20 @@ const sql = require('../../services/sql'); | ||||
| const options = require('../../services/options'); | ||||
| const content_hash = require('../../services/content_hash'); | ||||
| const log = require('../../services/log'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('/check', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/check', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     res.send({ | ||||
|         'hashes': await content_hash.getHashes(), | ||||
|         'max_sync_id': await sql.getFirstValue('SELECT MAX(id) FROM sync') | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('/now', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/now', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     res.send(await sync.sync()); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('/fill-sync-rows', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/fill-sync-rows', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await sql.doInTransaction(async () => { | ||||
|         await sync_table.fillAllSyncRows(); | ||||
|     }); | ||||
| @@ -30,9 +31,9 @@ router.post('/fill-sync-rows', auth.checkApiAuth, async (req, res, next) => { | ||||
|     log.info("Sync rows have been filled."); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('/force-full-sync', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/force-full-sync', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await sql.doInTransaction(async () => { | ||||
|         await options.setOption('last_synced_pull', 0); | ||||
|         await options.setOption('last_synced_push', 0); | ||||
| @@ -44,9 +45,9 @@ router.post('/force-full-sync', auth.checkApiAuth, async (req, res, next) => { | ||||
|     sync.sync(); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('/force-note-sync/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.post('/force-note-sync/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteId = req.params.noteId; | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
| @@ -68,35 +69,35 @@ router.post('/force-note-sync/:noteId', auth.checkApiAuth, async (req, res, next | ||||
|     sync.sync(); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/changed', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/changed', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const lastSyncId = parseInt(req.query.lastSyncId); | ||||
|  | ||||
|     res.send(await sql.getAll("SELECT * FROM sync WHERE id > ?", [lastSyncId])); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/notes/:noteId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/notes/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteId = req.params.noteId; | ||||
|  | ||||
|     res.send({ | ||||
|         entity: await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]) | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/notes_tree/:noteTreeId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/notes_tree/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|  | ||||
|     res.send(await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId])); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/notes_history/:noteHistoryId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/notes_history/:noteHistoryId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteHistoryId = req.params.noteHistoryId; | ||||
|  | ||||
|     res.send(await sql.getFirst("SELECT * FROM notes_history WHERE note_history_id = ?", [noteHistoryId])); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/options/:optName', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/options/:optName', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const optName = req.params.optName; | ||||
|  | ||||
|     if (!options.SYNCED_OPTIONS.includes(optName)) { | ||||
| @@ -105,57 +106,86 @@ router.get('/options/:optName', auth.checkApiAuth, async (req, res, next) => { | ||||
|     else { | ||||
|         res.send(await sql.getFirst("SELECT * FROM options WHERE opt_name = ?", [optName])); | ||||
|     } | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.get('/notes_reordering/:noteTreeParentId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/notes_reordering/:noteTreeParentId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeParentId = req.params.noteTreeParentId; | ||||
|  | ||||
|     res.send({ | ||||
|         parent_note_id: noteTreeParentId, | ||||
|         ordering: await sql.getMap("SELECT note_tree_id, note_position FROM notes_tree WHERE parent_note_id = ?", [noteTreeParentId]) | ||||
|     }); | ||||
|         ordering: await sql.getMap("SELECT note_tree_id, note_position FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0", [noteTreeParentId]) | ||||
|     }); | ||||
| })); | ||||
|  | ||||
| router.get('/recent_notes/:noteTreeId', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/recent_notes/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|  | ||||
|     res.send(await sql.getFirst("SELECT * FROM recent_notes WHERE note_tree_id = ?", [noteTreeId])); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/notes', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.get('/images/:imageId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const imageId = req.params.imageId; | ||||
|     const entity = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [imageId]); | ||||
|  | ||||
|     if (entity && entity.data !== null) { | ||||
|         entity.data = entity.data.toString('base64'); | ||||
|     } | ||||
|  | ||||
|     res.send(entity); | ||||
| })); | ||||
|  | ||||
| router.get('/notes_image/:noteImageId', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteImageId = req.params.noteImageId; | ||||
|  | ||||
|     res.send(await sql.getFirst("SELECT * FROM notes_image WHERE note_image_id = ?", [noteImageId])); | ||||
| })); | ||||
|  | ||||
| router.put('/notes', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateNote(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/notes_tree', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/notes_tree', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateNoteTree(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/notes_history', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/notes_history', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateNoteHistory(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/notes_reordering', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/notes_reordering', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateNoteReordering(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/options', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/options', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateOptions(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/recent_notes', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/recent_notes', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateRecentNotes(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/images', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateImage(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| })); | ||||
|  | ||||
| router.put('/notes_image', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     await syncUpdate.updateNoteImage(req.body.entity, req.body.sourceId); | ||||
|  | ||||
|     res.send({}); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
| @@ -10,16 +10,23 @@ const protected_session = require('../../services/protected_session'); | ||||
| const data_encryption = require('../../services/data_encryption'); | ||||
| const notes = require('../../services/notes'); | ||||
| const sync_table = require('../../services/sync_table'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('/', auth.checkApiAuth, async (req, res, next) => { | ||||
|     const notes = await sql.getAll("SELECT " | ||||
|         + "notes_tree.*, " | ||||
|         + "notes.note_title, " | ||||
|         + "notes.is_protected " | ||||
|         + "FROM notes_tree " | ||||
|         + "JOIN notes ON notes.note_id = notes_tree.note_id " | ||||
|         + "WHERE notes.is_deleted = 0 AND notes_tree.is_deleted = 0 " | ||||
|         + "ORDER BY note_position"); | ||||
| router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const notes = await sql.getAll(` | ||||
|       SELECT  | ||||
|         notes_tree.*,  | ||||
|         notes.note_title,  | ||||
|         notes.is_protected | ||||
|       FROM  | ||||
|         notes_tree  | ||||
|       JOIN  | ||||
|         notes ON notes.note_id = notes_tree.note_id | ||||
|       WHERE  | ||||
|         notes.is_deleted = 0  | ||||
|         AND notes_tree.is_deleted = 0 | ||||
|       ORDER BY  | ||||
|         note_position`); | ||||
|  | ||||
|     const dataKey = protected_session.getDataKey(req); | ||||
|  | ||||
| @@ -33,9 +40,9 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => { | ||||
|         notes: notes, | ||||
|         start_note_path: await options.getOption('start_note_path') | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteId = req.params.noteId; | ||||
|     const isProtected = !!parseInt(req.params.isProtected); | ||||
|     const dataKey = protected_session.getDataKey(req); | ||||
| @@ -46,9 +53,9 @@ router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, async (r | ||||
|     }); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, async (req, res, next) => { | ||||
| router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, wrap(async (req, res, next) => { | ||||
|     const noteTreeId = req.params.noteTreeId; | ||||
|     const sourceId = req.headers.source_id; | ||||
|     const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix; | ||||
| @@ -60,6 +67,6 @@ router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, async (req, res, next) | ||||
|     }); | ||||
|  | ||||
|     res.send({}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
|   | ||||
| @@ -5,12 +5,13 @@ const router = express.Router(); | ||||
| const auth = require('../services/auth'); | ||||
| const source_id = require('../services/source_id'); | ||||
| const sql = require('../services/sql'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', auth.checkAuth, async (req, res, next) => { | ||||
| router.get('', auth.checkAuth, wrap(async (req, res, next) => { | ||||
|     res.render('index', { | ||||
|         sourceId: await source_id.generateSourceId(), | ||||
|         maxSyncIdAtLoad: await sql.getFirstValue("SELECT MAX(id) FROM sync") | ||||
|     }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
|   | ||||
| @@ -5,12 +5,13 @@ const router = express.Router(); | ||||
| const utils = require('../services/utils'); | ||||
| const options = require('../services/options'); | ||||
| const my_scrypt = require('../services/my_scrypt'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', (req, res, next) => { | ||||
| router.get('', wrap(async (req, res, next) => { | ||||
|     res.render('login', { 'failedAuth': false }); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| router.post('', async (req, res, next) => { | ||||
| router.post('', wrap(async (req, res, next) => { | ||||
|     const userName = await options.getOption('username'); | ||||
|  | ||||
|     const guessedPassword = req.body.password; | ||||
| @@ -32,7 +33,7 @@ router.post('', async (req, res, next) => { | ||||
|     else { | ||||
|         res.render('login', {'failedAuth': true}); | ||||
|     } | ||||
| }); | ||||
| })); | ||||
|  | ||||
|  | ||||
| async function verifyPassword(guessed_password) { | ||||
|   | ||||
| @@ -2,14 +2,15 @@ | ||||
|  | ||||
| const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.post('', async (req, res, next) => { | ||||
| router.post('', wrap(async (req, res, next) => { | ||||
|     req.session.regenerate(() => { | ||||
|         req.session.loggedIn = false; | ||||
|  | ||||
|         res.redirect('/'); | ||||
|     }); | ||||
|  | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
|   | ||||
| @@ -3,9 +3,10 @@ | ||||
| const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const auth = require('../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', auth.checkAuthForMigrationPage, (req, res, next) => { | ||||
| router.get('', auth.checkAuthForMigrationPage, wrap(async (req, res, next) => { | ||||
|     res.render('migration', {}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
|   | ||||
| @@ -24,6 +24,7 @@ const setupApiRoute = require('./api/setup'); | ||||
| const sqlRoute = require('./api/sql'); | ||||
| const anonymizationRoute = require('./api/anonymization'); | ||||
| const cleanupRoute = require('./api/cleanup'); | ||||
| const imageRoute = require('./api/image'); | ||||
|  | ||||
| function register(app) { | ||||
|     app.use('/', indexRoute); | ||||
| @@ -51,6 +52,7 @@ function register(app) { | ||||
|     app.use('/api/sql', sqlRoute); | ||||
|     app.use('/api/anonymization', anonymizationRoute); | ||||
|     app.use('/api/cleanup', cleanupRoute); | ||||
|     app.use('/api/images', imageRoute); | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|   | ||||
| @@ -3,9 +3,10 @@ | ||||
| const express = require('express'); | ||||
| const router = express.Router(); | ||||
| const auth = require('../services/auth'); | ||||
| const wrap = require('express-promise-wrap').wrap; | ||||
|  | ||||
| router.get('', auth.checkAppNotInitialized, (req, res, next) => { | ||||
| router.get('', auth.checkAppNotInitialized, wrap(async (req, res, next) => { | ||||
|     res.render('setup', {}); | ||||
| }); | ||||
| })); | ||||
|  | ||||
| module.exports = router; | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
| const build = require('./build'); | ||||
| const packageJson = require('../package'); | ||||
|  | ||||
| const APP_DB_VERSION = 61; | ||||
| const APP_DB_VERSION = 65; | ||||
|  | ||||
| module.exports = { | ||||
|     app_version: packageJson.version, | ||||
|   | ||||
| @@ -28,6 +28,20 @@ async function checkAuthForMigrationPage(req, res, next) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| // for electron things which need network stuff | ||||
| // currently we're doing that for file upload because handling form data seems to be difficult | ||||
| async function checkApiAuthOrElectron(req, res, next) { | ||||
|     if (!req.session.loggedIn && !utils.isElectron()) { | ||||
|         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 | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function checkApiAuth(req, res, next) { | ||||
|     if (!req.session.loggedIn) { | ||||
|         res.status(401).send("Not authorized"); | ||||
| @@ -63,5 +77,6 @@ module.exports = { | ||||
|     checkAuthForMigrationPage, | ||||
|     checkApiAuth, | ||||
|     checkApiAuthForMigrationPage, | ||||
|     checkAppNotInitialized | ||||
|     checkAppNotInitialized, | ||||
|     checkApiAuthOrElectron | ||||
| }; | ||||
| @@ -6,6 +6,7 @@ const fs = require('fs-extra'); | ||||
| const dataDir = require('./data_dir'); | ||||
| const log = require('./log'); | ||||
| const sql = require('./sql'); | ||||
| const sync_mutex = require('./sync_mutex'); | ||||
|  | ||||
| async function regularBackup() { | ||||
|     const now = new Date(); | ||||
| @@ -21,6 +22,10 @@ async function regularBackup() { | ||||
| } | ||||
|  | ||||
| async function backupNow() { | ||||
|     // we don't want to backup DB in the middle of sync with potentially inconsistent DB state | ||||
|     const releaseMutex = await sync_mutex.acquire(); | ||||
|  | ||||
|     try { | ||||
|         const now = utils.nowDate(); | ||||
|  | ||||
|         const backupFile = dataDir.BACKUP_DIR + "/" + "backup-" + utils.getDateTimeForFile() + ".db"; | ||||
| @@ -33,6 +38,10 @@ async function backupNow() { | ||||
|             await options.setOption('last_backup_date', now); | ||||
|         }); | ||||
|     } | ||||
|     finally { | ||||
|         releaseMutex(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function cleanupOldBackups() { | ||||
|     const now = new Date(); | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| module.exports = { build_date:"2018-01-02T22:46:50-05:00", build_revision: "96a44a9a0c1b90ba7b58ef37a52cadbaffdf918d" }; | ||||
| module.exports = { build_date:"2018-01-07T21:56:39-05:00", build_revision: "29eb88bac3bbc1db17bb4ed75f9b8bfe16333967" }; | ||||
|   | ||||
| @@ -3,8 +3,12 @@ | ||||
| const sql = require('./sql'); | ||||
| const log = require('./log'); | ||||
| const messaging = require('./messaging'); | ||||
| const sync_mutex = require('./sync_mutex'); | ||||
| const utils = require('./utils'); | ||||
|  | ||||
| async function runCheck(query, errorText, errorList) { | ||||
|     utils.assertArguments(query, errorText, errorList); | ||||
|  | ||||
|     const result = await sql.getFirstColumn(query); | ||||
|  | ||||
|     if (result.length > 0) { | ||||
| @@ -19,7 +23,7 @@ async function runCheck(query, errorText, errorList) { | ||||
|  | ||||
| async function checkTreeCycles(errorList) { | ||||
|     const childToParents = {}; | ||||
|     const rows = await sql.getAll("SELECT note_id, parent_note_id FROM notes_tree"); | ||||
|     const rows = await sql.getAll("SELECT note_id, parent_note_id FROM notes_tree WHERE is_deleted = 0"); | ||||
|  | ||||
|     for (const row of rows) { | ||||
|         const childNoteId = row.note_id; | ||||
| @@ -80,11 +84,9 @@ async function runSyncRowChecks(table, key, errorList) { | ||||
|         `Missing ${table} records for existing sync rows`, errorList); | ||||
| } | ||||
|  | ||||
| async function runChecks() { | ||||
| async function runAllChecks() { | ||||
|     const errorList = []; | ||||
|  | ||||
|     const startTime = new Date(); | ||||
|  | ||||
|     await runCheck(` | ||||
|           SELECT  | ||||
|             note_id  | ||||
| @@ -139,7 +141,7 @@ async function runChecks() { | ||||
|           WHERE | ||||
|             (SELECT COUNT(*) FROM notes_tree WHERE notes.note_id = notes_tree.note_id AND notes_tree.is_deleted = 0) = 0 | ||||
|             AND notes.is_deleted = 0 | ||||
|     `, ); | ||||
|     `, 'No undeleted note trees for note IDs', errorList); | ||||
|  | ||||
|     await runCheck(` | ||||
|           SELECT  | ||||
| @@ -161,10 +163,47 @@ async function runChecks() { | ||||
|             notes.note_id IS NULL`, | ||||
|         "Missing notes records for following note history ID > note ID", errorList); | ||||
|  | ||||
|     await runCheck(` | ||||
|           SELECT  | ||||
|             notes_tree.parent_note_id || ' > ' || notes_tree.note_id  | ||||
|           FROM  | ||||
|             notes_tree  | ||||
|           WHERE  | ||||
|             notes_tree.is_deleted = 0 | ||||
|           GROUP BY  | ||||
|             notes_tree.parent_note_id, | ||||
|             notes_tree.note_id | ||||
|           HAVING  | ||||
|             COUNT(*) > 1`, | ||||
|         "Duplicate undeleted parent note <-> note relationship - parent note ID > note ID", errorList); | ||||
|  | ||||
|     await runCheck(` | ||||
|           SELECT  | ||||
|             images.image_id | ||||
|           FROM  | ||||
|             images | ||||
|             LEFT JOIN notes_image ON notes_image.image_id = images.image_id | ||||
|           WHERE  | ||||
|             notes_image.note_image_id IS NULL`, | ||||
|         "Image with no note relation", errorList); | ||||
|  | ||||
|     await runCheck(` | ||||
|           SELECT  | ||||
|             notes_image.note_image_id | ||||
|           FROM  | ||||
|             notes_image | ||||
|             JOIN images USING(image_id) | ||||
|           WHERE  | ||||
|             notes_image.is_deleted = 0 | ||||
|             AND images.is_deleted = 1`, | ||||
|         "Note image is not deleted while image is deleted for note_image_id", errorList); | ||||
|  | ||||
|     await runSyncRowChecks("notes", "note_id", errorList); | ||||
|     await runSyncRowChecks("notes_history", "note_history_id", errorList); | ||||
|     await runSyncRowChecks("notes_tree", "note_tree_id", errorList); | ||||
|     await runSyncRowChecks("recent_notes", "note_tree_id", errorList); | ||||
|     await runSyncRowChecks("images", "image_id", errorList); | ||||
|     await runSyncRowChecks("notes_image", "note_image_id", errorList); | ||||
|  | ||||
|     if (errorList.length === 0) { | ||||
|         // we run this only if basic checks passed since this assumes basic data consistency | ||||
| @@ -172,7 +211,24 @@ async function runChecks() { | ||||
|         await checkTreeCycles(errorList); | ||||
|     } | ||||
|  | ||||
|     const elapsedTimeMs = new Date().getTime() - startTime.getTime(); | ||||
|     return errorList; | ||||
| } | ||||
|  | ||||
| async function runChecks() { | ||||
|     let errorList; | ||||
|     let elapsedTimeMs; | ||||
|     const releaseMutex = await sync_mutex.acquire(); | ||||
|  | ||||
|     try { | ||||
|         const startTime = new Date(); | ||||
|  | ||||
|         errorList = await runAllChecks(); | ||||
|  | ||||
|         elapsedTimeMs = new Date().getTime() - startTime.getTime(); | ||||
|     } | ||||
|     finally { | ||||
|         releaseMutex(); | ||||
|     } | ||||
|  | ||||
|     if (errorList.length > 0) { | ||||
|         log.info(`Consistency checks failed (took ${elapsedTimeMs}ms) with these errors: ` + JSON.stringify(errorList)); | ||||
|   | ||||
| @@ -19,7 +19,8 @@ async function getHashes() { | ||||
|     const optionsQuestionMarks = Array(options.SYNCED_OPTIONS.length).fill('?').join(','); | ||||
|  | ||||
|     const hashes = { | ||||
|         notes: getHash(await sql.getAll(`SELECT | ||||
|         notes: getHash(await sql.getAll(` | ||||
|             SELECT | ||||
|               note_id, | ||||
|               note_title, | ||||
|               note_text, | ||||
| @@ -29,7 +30,8 @@ async function getHashes() { | ||||
|             FROM notes | ||||
|             ORDER BY note_id`)), | ||||
|  | ||||
|         notes_tree: getHash(await sql.getAll(`SELECT | ||||
|         notes_tree: getHash(await sql.getAll(` | ||||
|             SELECT | ||||
|                note_tree_id, | ||||
|                note_id, | ||||
|                parent_note_id, | ||||
| @@ -40,7 +42,8 @@ async function getHashes() { | ||||
|              FROM notes_tree | ||||
|              ORDER BY note_tree_id`)), | ||||
|  | ||||
|         notes_history: getHash(await sql.getAll(`SELECT | ||||
|         notes_history: getHash(await sql.getAll(` | ||||
|             SELECT | ||||
|               note_history_id, | ||||
|               note_id, | ||||
|               note_title, | ||||
| @@ -50,7 +53,8 @@ async function getHashes() { | ||||
|             FROM notes_history | ||||
|             ORDER BY note_history_id`)), | ||||
|  | ||||
|         recent_notes: getHash(await sql.getAll(`SELECT | ||||
|         recent_notes: getHash(await sql.getAll(` | ||||
|            SELECT | ||||
|              note_tree_id, | ||||
|              note_path, | ||||
|              date_accessed, | ||||
| @@ -58,12 +62,27 @@ async function getHashes() { | ||||
|            FROM recent_notes | ||||
|            ORDER BY note_path`)), | ||||
|  | ||||
|         options: getHash(await sql.getAll(`SELECT  | ||||
|         options: getHash(await sql.getAll(` | ||||
|            SELECT  | ||||
|              opt_name, | ||||
|              opt_value  | ||||
|            FROM options  | ||||
|            WHERE opt_name IN (${optionsQuestionMarks})  | ||||
|                                                   ORDER BY opt_name`, options.SYNCED_OPTIONS)) | ||||
|            ORDER BY opt_name`, options.SYNCED_OPTIONS)), | ||||
|  | ||||
|         // we don't include image data on purpose because they are quite large, checksum is good enough | ||||
|         // to represent the data anyway | ||||
|         images: getHash(await sql.getAll(` | ||||
|           SELECT  | ||||
|             image_id, | ||||
|             format, | ||||
|             checksum, | ||||
|             name, | ||||
|             is_deleted, | ||||
|             date_modified, | ||||
|             date_created | ||||
|           FROM images   | ||||
|           ORDER BY image_id`)) | ||||
|     }; | ||||
|  | ||||
|     const elapseTimeMs = new Date().getTime() - startTime.getTime(); | ||||
|   | ||||
| @@ -74,7 +74,7 @@ async function protectNoteRecursively(noteId, dataKey, protect, sourceId) { | ||||
|  | ||||
|     await protectNote(note, dataKey, protect, sourceId); | ||||
|  | ||||
|     const children = await sql.getFirstColumn("SELECT note_id FROM notes_tree WHERE parent_note_id = ?", [noteId]); | ||||
|     const children = await sql.getFirstColumn("SELECT note_id FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0", [noteId]); | ||||
|  | ||||
|     for (const childNoteId of children) { | ||||
|         await protectNoteRecursively(childNoteId, dataKey, protect, sourceId); | ||||
| @@ -135,25 +135,7 @@ async function protectNoteHistory(noteId, dataKey, protect, sourceId) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function updateNote(noteId, newNote, dataKey, sourceId) { | ||||
|     if (newNote.detail.is_protected) { | ||||
|         await encryptNote(newNote, dataKey); | ||||
|     } | ||||
|  | ||||
|     const now = new Date(); | ||||
|     const nowStr = utils.nowDate(); | ||||
|  | ||||
|     const historySnapshotTimeInterval = parseInt(await options.getOption('history_snapshot_time_interval')); | ||||
|  | ||||
|     const historyCutoff = utils.dateStr(new Date(now.getTime() - historySnapshotTimeInterval * 1000)); | ||||
|  | ||||
|     const existingNoteHistoryId = await sql.getFirstValue( | ||||
|         "SELECT note_history_id FROM notes_history WHERE note_id = ? AND date_modified_to >= ?", [noteId, historyCutoff]); | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
|         const msSinceDateCreated = now.getTime() - utils.parseDate(newNote.detail.date_created).getTime(); | ||||
|  | ||||
|         if (!existingNoteHistoryId && msSinceDateCreated >= historySnapshotTimeInterval * 1000) { | ||||
| async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) { | ||||
|     const oldNote = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]); | ||||
|  | ||||
|     if (oldNote.is_protected) { | ||||
| @@ -176,6 +158,77 @@ async function updateNote(noteId, newNote, dataKey, sourceId) { | ||||
|     await sync_table.addNoteHistorySync(newNoteHistoryId, sourceId); | ||||
| } | ||||
|  | ||||
| async function saveNoteImages(noteId, noteText, sourceId) { | ||||
|     const existingNoteImages = await sql.getAll("SELECT * FROM notes_image WHERE note_id = ?", [noteId]); | ||||
|     const foundImageIds = []; | ||||
|     const now = utils.nowDate(); | ||||
|     const re = /src="\/api\/images\/([a-zA-Z0-9]+)\//g; | ||||
|     let match; | ||||
|  | ||||
|     while (match = re.exec(noteText)) { | ||||
|         const imageId = match[1]; | ||||
|         const existingNoteImage = existingNoteImages.find(ni => ni.image_id === imageId); | ||||
|  | ||||
|         if (!existingNoteImage) { | ||||
|             const noteImageId = utils.newNoteImageId(); | ||||
|  | ||||
|             await sql.insert("notes_image", { | ||||
|                 note_image_id: noteImageId, | ||||
|                 note_id: noteId, | ||||
|                 image_id: imageId, | ||||
|                 is_deleted: 0, | ||||
|                 date_modified: now, | ||||
|                 date_created: now | ||||
|             }); | ||||
|  | ||||
|             await sync_table.addNoteImageSync(noteImageId, sourceId); | ||||
|         } | ||||
|         else if (existingNoteImage.is_deleted) { | ||||
|             await sql.execute("UPDATE notes_image SET is_deleted = 0, date_modified = ? WHERE note_image_id = ?", | ||||
|                 [now, existingNoteImage.note_image_id]); | ||||
|  | ||||
|             await sync_table.addNoteImageSync(existingNoteImage.note_image_id, sourceId); | ||||
|         } | ||||
|         // else we don't need to do anything | ||||
|  | ||||
|         foundImageIds.push(imageId); | ||||
|     } | ||||
|  | ||||
|     // marking note images as deleted if they are not present on the page anymore | ||||
|     const unusedNoteImages = existingNoteImages.filter(ni => !foundImageIds.includes(ni.image_id)); | ||||
|  | ||||
|     for (const unusedNoteImage of unusedNoteImages) { | ||||
|         await sql.execute("UPDATE notes_image SET is_deleted = 1, date_modified = ? WHERE note_image_id = ?", | ||||
|             [now, unusedNoteImage.note_image_id]); | ||||
|  | ||||
|         await sync_table.addNoteImageSync(unusedNoteImage.note_image_id, sourceId); | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function updateNote(noteId, newNote, dataKey, sourceId) { | ||||
|     if (newNote.detail.is_protected) { | ||||
|         await encryptNote(newNote, dataKey); | ||||
|     } | ||||
|  | ||||
|     const now = new Date(); | ||||
|     const nowStr = utils.nowDate(); | ||||
|  | ||||
|     const historySnapshotTimeInterval = parseInt(await options.getOption('history_snapshot_time_interval')); | ||||
|  | ||||
|     const historyCutoff = utils.dateStr(new Date(now.getTime() - historySnapshotTimeInterval * 1000)); | ||||
|  | ||||
|     const existingNoteHistoryId = await sql.getFirstValue( | ||||
|         "SELECT note_history_id FROM notes_history WHERE note_id = ? AND date_modified_to >= ?", [noteId, historyCutoff]); | ||||
|  | ||||
|     await sql.doInTransaction(async () => { | ||||
|         const msSinceDateCreated = now.getTime() - utils.parseDate(newNote.detail.date_created).getTime(); | ||||
|  | ||||
|         if (!existingNoteHistoryId && msSinceDateCreated >= historySnapshotTimeInterval * 1000) { | ||||
|             await saveNoteHistory(noteId, dataKey, sourceId, nowStr); | ||||
|         } | ||||
|  | ||||
|         await saveNoteImages(noteId, newNote.detail.note_text, sourceId); | ||||
|  | ||||
|         await protectNoteHistory(noteId, dataKey, newNote.detail.is_protected); | ||||
|  | ||||
|         await sql.execute("UPDATE notes SET note_title = ?, note_text = ?, is_protected = ?, date_modified = ? WHERE note_id = ?", [ | ||||
|   | ||||
| @@ -37,9 +37,9 @@ const dbReady = new Promise((resolve, reject) => { | ||||
|                 await executeScript(notesSql); | ||||
|                 await executeScript(notesTreeSql); | ||||
|  | ||||
|                 const noteId = await getFirstValue("SELECT note_id FROM notes_tree WHERE parent_note_id = 'root' ORDER BY note_position"); | ||||
|                 const startNoteId = await getFirstValue("SELECT note_id FROM notes_tree WHERE parent_note_id = 'root' AND is_deleted = 0 ORDER BY note_position"); | ||||
|  | ||||
|                 await require('./options').initOptions(noteId); | ||||
|                 await require('./options').initOptions(startNoteId); | ||||
|                 await require('./sync_table').fillAllSyncRows(); | ||||
|             }); | ||||
|  | ||||
|   | ||||
| @@ -14,22 +14,13 @@ const fs = require('fs'); | ||||
| const app_info = require('./app_info'); | ||||
| const messaging = require('./messaging'); | ||||
| const sync_setup = require('./sync_setup'); | ||||
| const sync_mutex = require('./sync_mutex'); | ||||
|  | ||||
| let syncInProgress = false; | ||||
| let proxyToggle = true; | ||||
| let syncServerCertificate = null; | ||||
|  | ||||
| async function sync() { | ||||
|     if (syncInProgress) { | ||||
|         log.info("Sync already in progress"); | ||||
|  | ||||
|         return { | ||||
|             success: false, | ||||
|             message: "Sync already in progress" | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     syncInProgress = true; | ||||
|     const releaseMutex = await sync_mutex.acquire(); | ||||
|  | ||||
|     try { | ||||
|         if (!await sql.isDbUpToDate()) { | ||||
| @@ -74,7 +65,7 @@ async function sync() { | ||||
|         } | ||||
|     } | ||||
|     finally { | ||||
|         syncInProgress = false; | ||||
|         releaseMutex(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -152,6 +143,12 @@ async function pullSync(syncContext) { | ||||
|         else if (sync.entity_name === 'recent_notes') { | ||||
|             await syncUpdate.updateRecentNotes(resp, syncContext.sourceId); | ||||
|         } | ||||
|         else if (sync.entity_name === 'images') { | ||||
|             await syncUpdate.updateImage(resp, syncContext.sourceId); | ||||
|         } | ||||
|         else if (sync.entity_name === 'notes_image') { | ||||
|             await syncUpdate.updateNoteImage(resp, syncContext.sourceId); | ||||
|         } | ||||
|         else { | ||||
|             throw new Error(`Unrecognized entity type ${sync.entity_name} in sync #${sync.id}`); | ||||
|         } | ||||
| @@ -214,7 +211,7 @@ async function pushEntity(sync, syncContext) { | ||||
|     else if (sync.entity_name === 'notes_reordering') { | ||||
|         entity = { | ||||
|             parent_note_id: sync.entity_id, | ||||
|             ordering: await sql.getMap('SELECT note_tree_id, note_position FROM notes_tree WHERE parent_note_id = ?', [sync.entity_id]) | ||||
|             ordering: await sql.getMap('SELECT note_tree_id, note_position FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0', [sync.entity_id]) | ||||
|         }; | ||||
|     } | ||||
|     else if (sync.entity_name === 'options') { | ||||
| @@ -223,6 +220,16 @@ async function pushEntity(sync, syncContext) { | ||||
|     else if (sync.entity_name === 'recent_notes') { | ||||
|         entity = await sql.getFirst('SELECT * FROM recent_notes WHERE note_tree_id = ?', [sync.entity_id]); | ||||
|     } | ||||
|     else if (sync.entity_name === 'images') { | ||||
|         entity = await sql.getFirst('SELECT * FROM images WHERE image_id = ?', [sync.entity_id]); | ||||
|  | ||||
|         if (entity.data !== null) { | ||||
|             entity.data = entity.data.toString('base64'); | ||||
|         } | ||||
|     } | ||||
|     else if (sync.entity_name === 'notes_image') { | ||||
|         entity = await sql.getFirst('SELECT * FROM notes_image WHERE note_image_id = ?', [sync.entity_id]); | ||||
|     } | ||||
|     else { | ||||
|         throw new Error(`Unrecognized entity type ${sync.entity_name} in sync #${sync.id}`); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										8
									
								
								services/sync_mutex.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								services/sync_mutex.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| /** | ||||
|  * Sync makes process can make data intermittently inconsistent. Processes which require strong data consistency | ||||
|  * (like consistency checks) can use this mutex to make sure sync isn't currently running. | ||||
|  */ | ||||
|  | ||||
| const Mutex = require('async-mutex').Mutex; | ||||
|  | ||||
| module.exports = new Mutex(); | ||||
| @@ -28,6 +28,14 @@ async function addRecentNoteSync(noteTreeId, sourceId) { | ||||
|     await addEntitySync("recent_notes", noteTreeId, sourceId); | ||||
| } | ||||
|  | ||||
| async function addImageSync(imageId, sourceId) { | ||||
|     await addEntitySync("images", imageId, sourceId); | ||||
| } | ||||
|  | ||||
| async function addNoteImageSync(noteImageId, sourceId) { | ||||
|     await addEntitySync("notes_image", noteImageId, sourceId); | ||||
| } | ||||
|  | ||||
| async function addEntitySync(entityName, entityId, sourceId) { | ||||
|     await sql.replace("sync", { | ||||
|         entity_name: entityName, | ||||
| @@ -78,6 +86,8 @@ async function fillAllSyncRows() { | ||||
|     await fillSyncRows("notes_tree", "note_tree_id"); | ||||
|     await fillSyncRows("notes_history", "note_history_id"); | ||||
|     await fillSyncRows("recent_notes", "note_tree_id"); | ||||
|     await fillSyncRows("images", "image_id"); | ||||
|     await fillSyncRows("notes_image", "note_image_id"); | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
| @@ -87,6 +97,8 @@ module.exports = { | ||||
|     addNoteHistorySync, | ||||
|     addOptionsSync, | ||||
|     addRecentNoteSync, | ||||
|     addImageSync, | ||||
|     addNoteImageSync, | ||||
|     cleanupSyncRowsForMissingEntities, | ||||
|     fillAllSyncRows | ||||
| }; | ||||
| @@ -92,11 +92,45 @@ async function updateRecentNotes(entity, sourceId) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function updateImage(entity, sourceId) { | ||||
|     if (entity.data !== null) { | ||||
|         entity.data = Buffer.from(entity.data, 'base64'); | ||||
|     } | ||||
|  | ||||
|     const origImage = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [entity.image_id]); | ||||
|  | ||||
|     if (!origImage || origImage.date_modified <= entity.date_modified) { | ||||
|         await sql.doInTransaction(async () => { | ||||
|             await sql.replace("images", entity); | ||||
|  | ||||
|             await sync_table.addImageSync(entity.image_id, sourceId); | ||||
|         }); | ||||
|  | ||||
|         log.info("Update/sync image " + entity.image_id); | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function updateNoteImage(entity, sourceId) { | ||||
|     const origNoteImage = await sql.getFirst("SELECT * FROM notes_image WHERE note_image_id = ?", [entity.note_image_id]); | ||||
|  | ||||
|     if (!origNoteImage || origNoteImage.date_modified <= entity.date_modified) { | ||||
|         await sql.doInTransaction(async () => { | ||||
|             await sql.replace("notes_image", entity); | ||||
|  | ||||
|             await sync_table.addNoteImageSync(entity.note_image_id, sourceId); | ||||
|         }); | ||||
|  | ||||
|         log.info("Update/sync note image " + entity.note_image_id); | ||||
|     } | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|     updateNote, | ||||
|     updateNoteTree, | ||||
|     updateNoteHistory, | ||||
|     updateNoteReordering, | ||||
|     updateOptions, | ||||
|     updateRecentNotes | ||||
|     updateRecentNotes, | ||||
|     updateImage, | ||||
|     updateNoteImage | ||||
| }; | ||||
| @@ -15,6 +15,14 @@ function newNoteHistoryId() { | ||||
|     return randomString(12); | ||||
| } | ||||
|  | ||||
| function newImageId() { | ||||
|     return randomString(12); | ||||
| } | ||||
|  | ||||
| function newNoteImageId() { | ||||
|     return randomString(12); | ||||
| } | ||||
|  | ||||
| function randomString(length) { | ||||
|     return randtoken.generate(length); | ||||
| } | ||||
| @@ -79,6 +87,14 @@ function sanitizeSql(str) { | ||||
|     return str.replace(/'/g, "\\'"); | ||||
| } | ||||
|  | ||||
| function assertArguments() { | ||||
|     for (const i in arguments) { | ||||
|         if (!arguments[i]) { | ||||
|             throw new Error(`Argument idx#${i} should not be falsy: ${arguments[i]}`); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|     randomSecureToken, | ||||
|     randomString, | ||||
| @@ -88,6 +104,8 @@ module.exports = { | ||||
|     newNoteId, | ||||
|     newNoteTreeId, | ||||
|     newNoteHistoryId, | ||||
|     newImageId, | ||||
|     newNoteImageId, | ||||
|     toBase64, | ||||
|     fromBase64, | ||||
|     hmac, | ||||
| @@ -95,5 +113,6 @@ module.exports = { | ||||
|     hash, | ||||
|     isEmptyOrWhitespace, | ||||
|     getDateTimeForFile, | ||||
|     sanitizeSql | ||||
|     sanitizeSql, | ||||
|     assertArguments | ||||
| }; | ||||
| @@ -248,12 +248,24 @@ | ||||
|           <p>This action will create a new copy of the database and anonymise it (remove all note content and leave only structure and metadata) | ||||
|             for sharing online for debugging purposes without fear of leaking your personal data.</p> | ||||
|  | ||||
|           <h4>Cleanup</h4> | ||||
|           <h4>Image cleanup</h4> | ||||
|  | ||||
|           <button id="cleanup-soft-deleted-items-button" class="btn btn-danger btn-sm">Permanently cleanup soft-deleted items</button> (should be executed in all synced instances) | ||||
|           <p>This will remove all image data of images not used in any current version of note from the database (metadata will remain). | ||||
|  | ||||
|           <br/> | ||||
|           <br/> | ||||
|             This means that some images can disappear from note history.</p> | ||||
|  | ||||
|           <button id="cleanup-unused-images-button" class="btn btn-warning btn-sm">Permanently cleanup unused images</button> | ||||
|  | ||||
|           <h4>Soft-delete cleanup</h4> | ||||
|  | ||||
|           <p>This deletes all soft deleted rows from the database. This change isn't synced and should be done manually on all instances. | ||||
|             <strong>Use this only if you really know what you're doing.</strong></p> | ||||
|  | ||||
|           <button id="cleanup-soft-deleted-items-button" class="btn btn-danger btn-sm">Permanently cleanup soft-deleted items</button> | ||||
|  | ||||
|           <h4>Vacuum database</h4> | ||||
|  | ||||
|           <p>This will rebuild database which will typically result in smaller database file. No data will be actually changed.</p> | ||||
|  | ||||
|           <button id="vacuum-database-button" class="btn btn-sm">Vacuum database</button> | ||||
|         </div> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user