Compare commits

...

32 Commits

Author SHA1 Message Date
azivner
780f462e94 release 0.3.0 2018-01-07 10:04:46 -05:00
azivner
488e657cc4 forgotten express-promise-wrap in package.json 2018-01-07 10:04:43 -05:00
azivner
8bc2a21d80 auth exception for images in electron 2018-01-07 09:59:05 -05:00
azivner
743d72a0c3 added express-promise-wrap to catch and respond to unhandled exceptions immediately, previously the requests just hanged 2018-01-07 09:35:44 -05:00
azivner
20b1357be6 gif support 2018-01-07 09:22:55 -05:00
azivner
d9f2bb37e7 PNG alpha channel is converted to white instead of default ugly black 2018-01-07 08:26:42 -05:00
azivner
97c1b3061f correct detection of optimized image format 2018-01-07 08:24:04 -05:00
azivner
c022fcf196 reloading note tree when we have note detail changes 2018-01-06 22:56:54 -05:00
azivner
b5baab056c enter on title will put focus on editor 2018-01-06 22:53:37 -05:00
azivner
edc9a1a2bf creating / updating notes_image rows 2018-01-06 22:38:53 -05:00
azivner
c0e45a73a8 relation between notes and images 2018-01-06 21:49:02 -05:00
azivner
784cd62df1 image sync 2018-01-06 15:56:00 -05:00
azivner
91cf090820 compressing PNGs with pngquant 2018-01-06 13:53:02 -05:00
azivner
d9f29cbf27 resizing and optimizing jpeg images with mozjpeg 2018-01-06 12:38:25 -05:00
azivner
23a5e38e02 added basic support for uploading and serving files 2018-01-05 23:54:02 -05:00
azivner
663bd1a8fe added sync mutex for consistency checks and backup 2018-01-04 21:37:36 -05:00
azivner
a6a687c4a6 release 0.2.2 2018-01-03 23:05:00 -05:00
azivner
f2aaf8b0a3 ctrl+click doesn't activate the node and selects only if it's not selected yet, otherwise deselects 2018-01-03 22:54:13 -05:00
azivner
01ede22504 added backspace to move to parent node 2018-01-03 22:49:53 -05:00
azivner
b6d617aefa returned missing requires 2018-01-03 22:43:01 -05:00
azivner
7921850186 added missing map file and unminified context menu js file 2018-01-03 22:36:27 -05:00
azivner
244a4562b1 not unique key between parent_note_id and note_id in schema 2018-01-03 22:15:06 -05:00
azivner
07c33979c3 consistent checking of is_deleted, some small refactorings 2018-01-03 22:13:02 -05:00
azivner
353a9b24c1 added consistency check for unique non-deleted parent note <-> note relationship 2018-01-03 21:33:19 -05:00
azivner
548ecd4171 removed unique index again - from now on the invariant is that there's unique undeleted relationship between note and parent note 2018-01-03 21:29:13 -05:00
azivner
8d9b0db316 release 0.2.1 2018-01-02 22:46:50 -05:00
azivner
96a44a9a0c added some showcase formatting to the welcome page in the demo database 2018-01-02 22:14:53 -05:00
azivner
b545100cad removed loader animation - it was causing issues with initial focus on the tree (probably by stealing focus) 2018-01-02 22:01:38 -05:00
azivner
e32289720c not hiding the elements for alt-m, just lowering opacity 2018-01-02 20:52:36 -05:00
azivner
550bb77ca9 fixed switching between note clones 2018-01-02 20:16:17 -05:00
azivner
664a87cdd5 checks against moving note to where it already exists 2018-01-02 19:56:45 -05:00
azivner
53ee1fa5ed note_id - parent_note_id index needs to be unique 2018-01-02 19:20:42 -05:00
61 changed files with 3714 additions and 484 deletions

View File

@@ -1,5 +1,5 @@
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('root', 'root', 'root', 0, 0, '2017-12-22T11:41:07.000Z', '2017-12-22T11:41:07.000Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('1Heh2acXfPNt', 'Trilium Demo', '<p>Welcome to Trilium Notes!</p><p>&nbsp;</p><p>This is initial document provided by default Trilium to showcase some of its features and also give you some ideas how you might structure your notes. You can play with it, modify note content and tree structure as you wish.</p><p>&nbsp;</p><p>If you need any help, visit Trilium wesite: <a href="https://github.com/zadam/trilium">https://github.com/zadam/trilium</a></p><p>&nbsp;</p><p>Once you''re finished with experimenting and want to cleanup these pages, you can simply delete them all.</p>', 0, 0, '2017-12-23T00:46:39.304Z', '2017-12-23T04:08:45.445Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('1Heh2acXfPNt', 'Trilium Demo', '<p><strong>Welcome to Trilium Notes!</strong></p><p>&nbsp;</p><p>This is initial document provided by default Trilium to showcase some of its features and also give you some ideas how you might structure your notes. You can play with it, modify note content and tree structure as you wish.</p><p>&nbsp;</p><p>If you need any help, visit Trilium wesite: <a href="https://github.com/zadam/trilium">https://github.com/zadam/trilium</a></p><h3>Cleanup</h3><p>Once you''re finished with experimenting and want to cleanup these pages, you can simply delete them all.</p><h3>Formatting</h3><p>Trilium supports classic formatting like <i>italic</i>, <strong>bold</strong>, <i><strong>bold and italic</strong></i>. Of course you can add links like this one pointing to <a href="http://www.google.com">google.com</a></p><h4>Lists</h4><p><strong>Ordered:</strong></p><ol><li>First Item</li><li>Second item<ol><li>First sub-item</li><li>Second sub-item</li></ol></li></ol><p>&nbsp;</p><p><strong>Unordered:</strong></p><ul><li>Item</li><li>Another item<ul><li>Sub-item<ul><li>Sub-sub-item</li></ul></li></ul></li></ul><h4>Block quotes</h4><blockquote><p>Whereof one cannot speak, thereof one must be silent”</p><p> Ludwig Wittgenstein</p></blockquote><p>&nbsp;</p>', 0, 0, '2017-12-23T00:46:39.304Z', '2017-12-23T04:08:45.445Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('3RkyK9LI18dO', 'Journal', '<p>Expand note on the left pane to see content.</p>', 0, 0, '2017-12-23T01:20:04.181Z', '2017-12-23T18:07:55.377Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('L1Ox40M1aEyy', '2016', '<p>No content.</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p>', 0, 0, '2017-12-23T01:20:45.365Z', '2017-12-23T16:40:43.129Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('HJusZTbBU494', '2017', '<p>No content.</p>', 0, 0, '2017-12-23T01:20:50.709Z', '2017-12-23T16:41:03.119Z');

View File

@@ -67,9 +67,6 @@ 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 INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` (
`note_id`,
`parent_note_id`

View File

@@ -0,0 +1,6 @@
DROP INDEX IDX_notes_tree_note_id_parent_note_id;
CREATE UNIQUE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` (
`note_id`,
`parent_note_id`
);

View 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;

View 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
);

View 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);

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,12 @@
{
"name": "trilium",
"description": "Trilium Notes",
"version": "0.2.0",
"version": "0.3.0",
"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",

View File

@@ -6,10 +6,7 @@ jQuery.hotkeys.options.filterContentEditable = false;
jQuery.hotkeys.options.filterTextInputs = false;
$(document).bind('keydown', 'alt+m', e => {
const toggle = $(".hide-toggle");
const hidden = toggle.css('visibility') === 'hidden';
toggle.css('visibility', hidden ? 'visible' : 'hidden');
$(".hide-toggle").toggleClass("suppressed");
e.preventDefault();
});
@@ -93,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
@@ -158,27 +157,6 @@ $(document).tooltip({
}
});
let appShown = false;
function showAppIfHidden() {
if (!appShown) {
appShown = true;
$("#container").show();
// Get a reference to the loader's div
const loaderDiv = document.getElementById("loader-wrapper");
// When the transition ends remove loader's div from display
// so that we can access the map with gestures or clicks
loaderDiv.addEventListener("transitionend", function(){
loaderDiv.style.display = "none";
}, true);
// Kick off the CSS transition
loaderDiv.style.opacity = 0.0;
}
}
window.onerror = function (msg, url, lineNo, columnNo, error) {
const string = msg.toLowerCase();

View File

@@ -42,20 +42,24 @@ const link = (function() {
e.preventDefault();
const linkEl = $(e.target);
const address = linkEl.attr("note-path") ? linkEl.attr("note-path") : linkEl.attr('href');
let notePath = linkEl.attr("note-path");
if (!address) {
return;
if (!notePath) {
const address = linkEl.attr("note-path") ? linkEl.attr("note-path") : linkEl.attr('href');
if (!address) {
return;
}
if (address.startsWith('http')) {
window.open(address, '_blank');
return;
}
notePath = getNotePathFromLink(address);
}
if (address.startsWith('http')) {
window.open(address, '_blank');
return;
}
const notePath = getNotePathFromLink(address);
noteTree.activateNode(notePath);
// this is quite ugly hack, but it seems like we can't close the tooltip otherwise

View File

@@ -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') {

View File

@@ -130,8 +130,6 @@ const noteEditor = (function() {
// after loading new note make sure editor is scrolled to the top
noteDetailWrapperEl.scrollTop(0);
showAppIfHidden();
}
async function loadNote(noteId) {

View File

@@ -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;
}
@@ -573,9 +577,6 @@ const noteTree = (function() {
// so waiting a second helps
setTimeout(scrollToCurrentNote, 1000);
}
else {
showAppIfHidden();
}
},
hotkeys: {
keydown: keybindings

View File

@@ -22,9 +22,6 @@ const protected_session = (function() {
const dfd = $.Deferred();
if (requireProtectedSession && !isProtectedSessionAvailable()) {
// if this is entry point then we need to show the app even before the note is loaded
showAppIfHidden();
protectedSessionDeferred = dfd;
dialogEl.dialog({

View File

@@ -91,6 +91,7 @@ const server = (function() {
get,
post,
put,
remove
remove,
getHeaders
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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);
}
}
});
}));

File diff suppressed because one or more lines are too long

View File

@@ -197,9 +197,6 @@ div.ui-tooltip {
overflow: scroll;
}
#loader-wrapper{position:fixed;top:0;left:0;width:100%;height:100%;z-index:1000;background-color:#fff;opacity:1;transition:opacity 2s ease}
#loader{display:block;position:relative;left:50%;top:50%;width:150px;height:150px;margin:-75px 0 0 -75px;border-radius:50%;border:3px solid transparent;border-top-color:#777;-webkit-animation:spin 2s linear infinite;animation:spin 2s linear infinite}
#loader:before{content:"";position:absolute;top:5px;left:5px;right:5px;bottom:5px;border-radius:50%;border:3px solid transparent;border-top-color:#aaa;-webkit-animation:spin 3s linear infinite;animation:spin 3s linear infinite}
#loader:after{content:"";position:absolute;top:15px;left:15px;right:15px;bottom:15px;border-radius:50%;border:3px solid transparent;border-top-color:#ddd;-webkit-animation:spin 1.5s linear infinite;animation:spin 1.5s linear infinite}
@-webkit-keyframes spin{0%{-webkit-transform:rotate(0deg);-ms-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}}
@keyframes spin{0%{-webkit-transform:rotate(0deg);-ms-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}}
.suppressed {
filter: opacity(7%);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
@@ -34,14 +35,14 @@ router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, n
});
res.send({});
});
}));
router.post('/vacuum-database', auth.checkApiAuth, async (req, res, next) => {
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;

View File

@@ -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");

View File

@@ -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]);

142
routes/api/image.js Normal file
View File

@@ -0,0 +1,142 @@
"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;
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({});
}
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;

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -3,22 +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);
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]);
@@ -34,104 +37,76 @@ 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);
if (!await checkTreeCycle(beforeNote.parent_note_id, noteToMove.note_id)) {
return res.send({
success: false,
message: 'Moving note here would create cycle.'
});
if (!await validateParentChild(res, beforeNote.parent_note_id, noteToMove.note_id, noteTreeId)) {
return;
}
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
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position >= ? AND is_deleted = 0",
[beforeNote.parent_note_id, beforeNote.note_position]);
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
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position >= ? AND is_deleted = 0",
[beforeNote.parent_note_id, beforeNote.note_position]);
await sync_table.addNoteReorderingSync(beforeNote.parent_note_id, sourceId);
await sync_table.addNoteReorderingSync(beforeNote.parent_note_id, sourceId);
const now = utils.nowDate();
const now = utils.nowDate();
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[beforeNote.parent_note_id, beforeNote.note_position, now, noteTreeId]);
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[beforeNote.parent_note_id, beforeNote.note_position, now, noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({ success: true });
}
else {
res.status(500).send("Before note " + beforeNoteTreeId + " doesn't exist.");
}
});
res.send({ success: true });
}));
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);
if (!await checkTreeCycle(afterNote.parent_note_id, noteToMove.note_id)) {
return res.send({
success: false,
message: 'Moving note here would create cycle.'
});
if (!await validateParentChild(res, afterNote.parent_note_id, noteToMove.note_id, noteTreeId)) {
return;
}
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
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position > ? AND is_deleted = 0",
[afterNote.parent_note_id, afterNote.note_position]);
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
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position > ? AND is_deleted = 0",
[afterNote.parent_note_id, afterNote.note_position]);
await sync_table.addNoteReorderingSync(afterNote.parent_note_id, sourceId);
await sync_table.addNoteReorderingSync(afterNote.parent_note_id, sourceId);
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[afterNote.parent_note_id, afterNote.note_position + 1, utils.nowDate(), noteTreeId]);
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[afterNote.parent_note_id, afterNote.note_position + 1, utils.nowDate(), noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({ success: true });
}
else {
res.status(500).send("After note " + afterNoteTreeId + " doesn't exist.");
}
});
res.send({ success: true });
}));
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 sql.getFirst('SELECT * FROM notes_tree WHERE note_id = ? AND parent_note_id = ?', [childNoteId, parentNoteId]);
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]);
@@ -157,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.");
}
if (!await checkTreeCycle(afterNote.parent_note_id, noteId)) {
return res.send({
success: false,
message: 'Cloning note here would create cycle.'
});
}
const existing = await sql.getFirstValue('SELECT * FROM notes_tree WHERE note_id = ? AND parent_note_id = ?', [noteId, afterNote.parent_note_id]);
if (existing && !existing.is_deleted) {
return res.send({
success: false,
message: 'This note already exists in target parent note.'
});
if (!await validateParentChild(res, afterNote.parent_note_id, noteId)) {
return;
}
await sql.doInTransaction(async () => {
@@ -210,18 +169,50 @@ 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 = ? AND is_deleted = 0', [childNoteId, parentNoteId]);
}
/**
* Tree cycle can be created when cloning or when moving existing clone. This method should detect both cases.
*/
@@ -242,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)) {
@@ -256,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;
@@ -267,6 +258,6 @@ router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, async (req, res
});
res.send({});
});
}));
module.exports = router;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(`

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -3,7 +3,7 @@
const build = require('./build');
const packageJson = require('../package');
const APP_DB_VERSION = 60;
const APP_DB_VERSION = 65;
module.exports = {
app_version: packageJson.version,

View File

@@ -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
};

View File

@@ -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,17 +22,25 @@ async function regularBackup() {
}
async function backupNow() {
const now = utils.nowDate();
// we don't want to backup DB in the middle of sync with potentially inconsistent DB state
const releaseMutex = await sync_mutex.acquire();
const backupFile = dataDir.BACKUP_DIR + "/" + "backup-" + utils.getDateTimeForFile() + ".db";
try {
const now = utils.nowDate();
fs.copySync(dataDir.DOCUMENT_PATH, backupFile);
const backupFile = dataDir.BACKUP_DIR + "/" + "backup-" + utils.getDateTimeForFile() + ".db";
log.info("Created backup at " + backupFile);
fs.copySync(dataDir.DOCUMENT_PATH, backupFile);
await sql.doInTransaction(async () => {
await options.setOption('last_backup_date', now);
});
log.info("Created backup at " + backupFile);
await sql.doInTransaction(async () => {
await options.setOption('last_backup_date', now);
});
}
finally {
releaseMutex();
}
}
async function cleanupOldBackups() {

View File

@@ -1 +1 @@
module.exports = { build_date:"2018-01-01T23:29:34-05:00", build_revision: "ae6e222c506c170ecd24d758328e0678f158bb47" };
module.exports = { build_date:"2018-01-07T10:04:46-05:00", build_revision: "488e657cc43fab879662a6da23b5a69dd7591b06" };

View File

@@ -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,36 @@ 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 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 +200,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));

View File

@@ -19,51 +19,70 @@ async function getHashes() {
const optionsQuestionMarks = Array(options.SYNCED_OPTIONS.length).fill('?').join(',');
const hashes = {
notes: getHash(await sql.getAll(`SELECT
note_id,
note_title,
note_text,
date_modified,
is_protected,
is_deleted
FROM notes
ORDER BY note_id`)),
notes: getHash(await sql.getAll(`
SELECT
note_id,
note_title,
note_text,
date_modified,
is_protected,
is_deleted
FROM notes
ORDER BY note_id`)),
notes_tree: getHash(await sql.getAll(`SELECT
note_tree_id,
note_id,
parent_note_id,
note_position,
date_modified,
is_deleted,
prefix
FROM notes_tree
ORDER BY note_tree_id`)),
notes_tree: getHash(await sql.getAll(`
SELECT
note_tree_id,
note_id,
parent_note_id,
note_position,
date_modified,
is_deleted,
prefix
FROM notes_tree
ORDER BY note_tree_id`)),
notes_history: getHash(await sql.getAll(`SELECT
note_history_id,
note_id,
note_title,
note_text,
date_modified_from,
date_modified_to
FROM notes_history
ORDER BY note_history_id`)),
notes_history: getHash(await sql.getAll(`
SELECT
note_history_id,
note_id,
note_title,
note_text,
date_modified_from,
date_modified_to
FROM notes_history
ORDER BY note_history_id`)),
recent_notes: getHash(await sql.getAll(`SELECT
note_tree_id,
note_path,
date_accessed,
is_deleted
FROM recent_notes
ORDER BY note_path`)),
recent_notes: getHash(await sql.getAll(`
SELECT
note_tree_id,
note_path,
date_accessed,
is_deleted
FROM recent_notes
ORDER BY note_path`)),
options: getHash(await sql.getAll(`SELECT
opt_name,
opt_value
FROM options
WHERE opt_name IN (${optionsQuestionMarks})
ORDER BY opt_name`, options.SYNCED_OPTIONS))
options: getHash(await sql.getAll(`
SELECT
opt_name,
opt_value
FROM options
WHERE opt_name IN (${optionsQuestionMarks})
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();

View File

@@ -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,6 +135,76 @@ async function protectNoteHistory(noteId, dataKey, protect, sourceId) {
}
}
async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
const oldNote = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (oldNote.is_protected) {
decryptNote(oldNote, dataKey);
}
const newNoteHistoryId = utils.newNoteHistoryId();
await sql.insert('notes_history', {
note_history_id: newNoteHistoryId,
note_id: noteId,
// title and text should be decrypted now
note_title: oldNote.note_title,
note_text: oldNote.note_text,
is_protected: 0, // will be fixed in the protectNoteHistory() call
date_modified_from: oldNote.date_modified,
date_modified_to: nowStr
});
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);
@@ -154,28 +224,11 @@ async function updateNote(noteId, newNote, dataKey, sourceId) {
const msSinceDateCreated = now.getTime() - utils.parseDate(newNote.detail.date_created).getTime();
if (!existingNoteHistoryId && msSinceDateCreated >= historySnapshotTimeInterval * 1000) {
const oldNote = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (oldNote.is_protected) {
decryptNote(oldNote, dataKey);
}
const newNoteHistoryId = utils.newNoteHistoryId();
await sql.insert('notes_history', {
note_history_id: newNoteHistoryId,
note_id: noteId,
// title and text should be decrypted now
note_title: oldNote.note_title,
note_text: oldNote.note_text,
is_protected: 0, // will be fixed in the protectNoteHistory() call
date_modified_from: oldNote.date_modified,
date_modified_to: nowStr
});
await sync_table.addNoteHistorySync(newNoteHistoryId, sourceId);
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 = ?", [

View File

@@ -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();
});

View File

@@ -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
View 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();

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -5,9 +5,7 @@
<title>Trilium Notes</title>
</head>
<body>
<div id="loader-wrapper"><div id="loader"></div></div>
<div id="container" style="display: none;">
<div id="container" style="display:none;">
<div id="header" class="hide-toggle">
<div id="header-title">
<img src="images/app-icons/png/24x24.png">
@@ -67,7 +65,7 @@
<div id="tree" class="hide-toggle" style="grid-area: tree; overflow: auto;">
</div>
<div id="parent-list">
<div id="parent-list" class="hide-toggle">
<p><strong>Note locations:</strong></p>
<ul id="parent-list-list"></ul>
@@ -399,5 +397,11 @@
<script src="javascripts/link.js"></script>
<script src="javascripts/sync.js"></script>
<script src="javascripts/messaging.js"></script>
<script type="text/javascript">
// we hide container initally because otherwise it is rendered first without CSS and then flickers into
// final form which is pretty ugly.
$("#container").show();
</script>
</body>
</html>