Compare commits

...

48 Commits

Author SHA1 Message Date
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
azivner
361d8a4216 release 0.2.0 2018-01-01 23:29:34 -05:00
azivner
ae6e222c50 fix for moving note inside not-yet folder 2018-01-01 23:28:00 -05:00
azivner
37995f1ce5 info message about copying / cutting into clipboard 2018-01-01 22:33:21 -05:00
azivner
ad7fa5e096 better conflict detection 2018-01-01 22:28:19 -05:00
azivner
3585982758 measuring and logging time to compute content hash and consistency checks 2018-01-01 19:47:50 -05:00
azivner
c776f298f2 tree cycle consistency check 2018-01-01 19:41:22 -05:00
azivner
f07c427da1 fixed race condition when changing note path in URL hash which caused flickering 2018-01-01 18:53:52 -05:00
azivner
e560072f8b drag & drop support for multi select plus some fixes 2018-01-01 18:29:06 -05:00
azivner
3f976a3821 ctrl-a selects all nodes at the current level (without children) 2018-01-01 18:11:23 -05:00
azivner
274bb32696 multi-select in note tree and clipboard operations on the selection 2018-01-01 17:59:59 -05:00
azivner
99b163a042 fix for forced note sync 2017-12-30 21:55:44 -05:00
azivner
fdcc833f6d added force note sync functionality to context menu 2017-12-30 21:44:26 -05:00
azivner
a8e45019e4 failing early when decryption fails 2017-12-30 20:14:05 -05:00
azivner
7f7028873c fixed synced issue when changing protected status of note history 2017-12-30 19:38:30 -05:00
azivner
2d2d76a715 enter on note tree can also give focus to the editor 2017-12-28 22:57:35 -05:00
azivner
69cbfaae17 release 0.1.2 2017-12-28 21:17:25 -05:00
azivner
aebce8f12b making not focused active tree item more visible 2017-12-28 20:53:31 -05:00
azivner
045ca1f0bf global CTRL-SHIFT-arrows to move in the note tree without losing focus in the note editor 2017-12-28 20:38:57 -05:00
azivner
bf2db6eac7 after loading new note make sure editor is scrolled to the top 2017-12-28 20:13:54 -05:00
azivner
cf84114f91 removed unused methods 2017-12-28 19:58:33 -05:00
azivner
6426157bb3 title in fancytree needs to be escaped for HTML special characters 2017-12-28 19:00:31 -05:00
azivner
332fc16852 fix pasting into non-expanded non-loaded node (UI only) 2017-12-27 22:44:27 -05:00
azivner
da2cd57428 CTRL+ENTER on note in tree pane switches to the editor 2017-12-27 21:16:47 -05:00
azivner
de9bab1181 fixed keyboard controlled clipboard 2017-12-27 21:12:54 -05:00
azivner
136375cf11 refactored detection of whether user is initialized 2017-12-27 18:23:24 -05:00
azivner
eabc7f80b7 release 0.1.1 2017-12-27 17:41:07 -05:00
azivner
6405d6e066 added linux ia32 build 2017-12-27 17:39:41 -05:00
azivner
f6d481a9e2 fix resource path bug 2017-12-27 16:44:15 -05:00
azivner
695f0e5879 release 0.1.0 2017-12-26 22:59:46 -05:00
azivner
ae337e4500 don't open dev tools for search page in electron 2017-12-26 22:24:47 -05:00
azivner
19ffa14f10 hide logout button in electron since it doesn't do anything 2017-12-26 22:19:42 -05:00
azivner
bf3f26fde8 using textarea instead of pre for note source since that's easier to copy to clipboard 2017-12-26 20:54:41 -05:00
44 changed files with 1314 additions and 347 deletions

View File

@@ -8,9 +8,15 @@ cp -r ../trilium-node-binaries/sqlite/* node_modules/sqlite3/lib/binding/
cp -r ../trilium-node-binaries/scrypt/* node_modules/scrypt/bin/
./node_modules/.bin/electron-rebuild
./node_modules/.bin/electron-rebuild --arch=ia32
./node_modules/.bin/electron-packager . --out=dist --platform=linux,win32 --overwrite
./node_modules/.bin/electron-packager . --out=dist --platform=linux --arch=ia32 --overwrite
./node_modules/.bin/electron-rebuild --arch=x64
./node_modules/.bin/electron-packager . --out=dist --platform=linux --arch=x64 --overwrite
./node_modules/.bin/electron-packager . --out=dist --platform=win32 --arch=x64 --overwrite
# can't copy this before the packaging because the same file name is used for both linux and windows build
cp ../trilium-node-binaries/scrypt.node ./dist/trilium-win32-x64/resources/app/node_modules/scrypt/build/Release/

View File

@@ -4,8 +4,11 @@ VERSION=`jq -r ".version" package.json`
cd dist
echo "Packaging windows electron distribution..."
7z a trilium-windows-${VERSION}.7z trilium-win32-x64
echo "Packaging linux x64 electron distribution..."
7z a trilium-linux-x64-${VERSION}.7z trilium-linux-x64
echo "Packaging linux electron distribution..."
7z a trilium-linux-${VERSION}.7z trilium-linux-x64
echo "Packaging linux ia32 electron distribution..."
7z a trilium-linux-ia32-${VERSION}.7z trilium-linux-ia32
echo "Packaging windows x64 electron distribution..."
7z a trilium-windows-x64-${VERSION}.7z trilium-win32-x64

View File

@@ -44,8 +44,9 @@ bin/build.sh
bin/package.sh
LINUX_BUILD=trilium-linux-$VERSION.7z
WINDOWS_BUILD=trilium-windows-$VERSION.7z
LINUX_X64_BUILD=trilium-linux-x64-$VERSION.7z
LINUX_IA32_BUILD=trilium-linux-ia32-$VERSION.7z
WINDOWS_X64_BUILD=trilium-windows-x64-$VERSION.7z
echo "Creating release in GitHub"
@@ -53,18 +54,25 @@ github-release release \
--tag $TAG \
--name "$TAG release"
echo "Uploading linux build"
echo "Uploading linux x64 build"
github-release upload \
--tag $TAG \
--name "$LINUX_BUILD" \
--file "dist/$LINUX_BUILD"
--name "$LINUX_X64_BUILD" \
--file "dist/$LINUX_X64_BUILD"
echo "Uploading windows build"
echo "Uploading linux ia32 build"
github-release upload \
--tag $TAG \
--name "$WINDOWS_BUILD" \
--file "dist/$WINDOWS_BUILD"
--name "$LINUX_IA32_BUILD" \
--file "dist/$LINUX_IA32_BUILD"
echo "Uploading windows x64 build"
github-release upload \
--tag $TAG \
--name "$WINDOWS_X64_BUILD" \
--file "dist/$WINDOWS_X64_BUILD"
echo "Release finished!"

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

@@ -21,6 +21,7 @@ function createMainWindow() {
const win = new electron.BrowserWindow({
width: 1200,
height: 900,
title: 'Trilium Notes',
icon: path.join(__dirname, 'public/images/app-icons/png/256x256.png')
});

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

@@ -1,7 +1,7 @@
{
"name": "trilium",
"description": "Trilium Notes",
"version": "0.0.11",
"version": "0.2.2",
"scripts": {
"start": "node ./bin/www",
"test-electron": "xo",

View File

@@ -3,54 +3,72 @@
const contextMenu = (function() {
const treeEl = $("#tree");
let clipboardId = null;
let clipboardIds = [];
let clipboardMode = null;
function pasteAfter(node) {
if (clipboardMode === 'cut') {
const subjectNode = treeUtils.getNodeByKey(clipboardId);
for (const nodeKey of clipboardIds) {
const subjectNode = treeUtils.getNodeByKey(nodeKey);
treeChanges.moveAfterNode(subjectNode, node);
treeChanges.moveAfterNode([subjectNode], node);
}
clipboardIds = [];
clipboardMode = null;
}
else if (clipboardMode === 'copy') {
treeChanges.cloneNoteAfter(clipboardId, node.data.note_tree_id);
for (const noteId of clipboardIds) {
treeChanges.cloneNoteAfter(noteId, node.data.note_tree_id);
}
// copy will keep clipboardIds and clipboardMode so it's possible to paste into multiple places
}
else if (clipboardId === null) {
else if (clipboardIds.length === 0) {
// just do nothing
}
else {
throwError("Unrecognized clipboard mode=" + clipboardMode);
}
clipboardId = null;
clipboardMode = null;
}
function pasteInto(node) {
if (clipboardMode === 'cut') {
const subjectNode = treeUtils.getNodeByKey(clipboardId);
for (const nodeKey of clipboardIds) {
const subjectNode = treeUtils.getNodeByKey(nodeKey);
treeChanges.moveToNode(subjectNode, node);
treeChanges.moveToNode([subjectNode], node);
}
clipboardIds = [];
clipboardMode = null;
}
else if (clipboardMode === 'copy') {
treeChanges.cloneNoteTo(clipboardId, node.data.note_id);
for (const noteId of clipboardIds) {
treeChanges.cloneNoteTo(noteId, node.data.note_id);
}
// copy will keep clipboardIds and clipboardMode so it's possible to paste into multiple places
}
else if (clipboardIds.length === 0) {
// just do nothing
}
else {
throwError("Unrecognized clipboard mode=" + mode);
}
clipboardId = null;
clipboardMode = null;
}
function copy(node) {
clipboardId = node.data.note_id;
function copy(nodes) {
clipboardIds = nodes.map(node => node.data.note_id);
clipboardMode = 'copy';
showMessage("Note(s) have been copied into clipboard.");
}
function cut(node) {
clipboardId = node.key;
function cut(nodes) {
clipboardIds = nodes.map(node => node.key);
clipboardMode = 'cut';
showMessage("Note(s) have been cut into clipboard.");
}
const contextMenuSettings = {
@@ -71,13 +89,14 @@ const contextMenu = (function() {
{title: "Paste into <kbd>Ctrl+V</kbd>", cmd: "pasteInto", uiIcon: "ui-icon-clipboard"},
{title: "Paste after", cmd: "pasteAfter", uiIcon: "ui-icon-clipboard"},
{title: "----"},
{title: "Collapse sub-tree <kbd>Alt+-</kbd>", cmd: "collapse-sub-tree", uiIcon: "ui-icon-minus"}
{title: "Collapse sub-tree <kbd>Alt+-</kbd>", cmd: "collapse-sub-tree", uiIcon: "ui-icon-minus"},
{title: "Force note sync", cmd: "force-note-sync", uiIcon: "ui-icon-refresh"}
],
beforeOpen: (event, ui) => {
const node = $.ui.fancytree.getNode(ui.target);
// Modify menu entries depending on node status
treeEl.contextmenu("enableEntry", "pasteAfter", clipboardId !== null);
treeEl.contextmenu("enableEntry", "pasteInto", clipboardId !== null);
treeEl.contextmenu("enableEntry", "pasteAfter", clipboardIds.length > 0);
treeEl.contextmenu("enableEntry", "pasteInto", clipboardIds.length > 0);
// Activate node on right-click
node.setActive();
@@ -108,10 +127,10 @@ const contextMenu = (function() {
protected_session.protectSubTree(node.data.note_id, false);
}
else if (ui.cmd === "copy") {
copy(node);
copy(noteTree.getSelectedNodes());
}
else if (ui.cmd === "cut") {
cut(node);
cut(noteTree.getSelectedNodes());
}
else if (ui.cmd === "pasteAfter") {
pasteAfter(node);
@@ -125,6 +144,9 @@ const contextMenu = (function() {
else if (ui.cmd === "collapse-sub-tree") {
noteTree.collapseTree(node);
}
else if (ui.cmd === "force-note-sync") {
forceNoteSync(node.data.note_id);
}
else {
messaging.logError("Unknown command: " + ui.cmd);
}

View File

@@ -9,7 +9,8 @@ const noteSource = (function() {
dialogEl.dialog({
modal: true,
width: 800
width: 800,
height: 500
});
const noteText = noteEditor.getCurrentNote().detail.note_text;

View File

@@ -46,14 +46,19 @@ const dragAndDropSetup = {
// This function MUST be defined to enable dropping of items on the tree.
// data.hitMode is 'before', 'after', or 'over'.
const nodeToMove = data.otherNode;
nodeToMove.setSelected(true);
const selectedNodes = noteTree.getSelectedNodes();
if (data.hitMode === "before") {
treeChanges.moveBeforeNode(data.otherNode, node);
treeChanges.moveBeforeNode(selectedNodes, node);
}
else if (data.hitMode === "after") {
treeChanges.moveAfterNode(data.otherNode, node);
treeChanges.moveAfterNode(selectedNodes, node);
}
else if (data.hitMode === "over") {
treeChanges.moveToNode(data.otherNode, node);
treeChanges.moveToNode(selectedNodes, node);
}
else {
throw new Exception("Unknown hitMode=" + data.hitMode);

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();
});
@@ -49,7 +46,7 @@ $(document).bind('keydown', 'ctrl+f', () => {
const searchInPage = require('electron-in-page-search').default;
const remote = require('electron').remote;
const inPageSearch = searchInPage(remote.getCurrentWebContents(), { openDevToolsOfSearchWindow: true });
const inPageSearch = searchInPage(remote.getCurrentWebContents());
inPageSearch.openSearchWindow();
@@ -57,6 +54,42 @@ $(document).bind('keydown', 'ctrl+f', () => {
}
});
$(document).bind('keydown', "ctrl+shift+left", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.LEFT, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+right", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.RIGHT, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+up", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.UP, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+down", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.DOWN, true);
$("#note-detail").focus();
return false;
});
$(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
@@ -122,27 +155,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();
@@ -164,4 +176,6 @@ window.onerror = function (msg, url, lineNo, columnNo, error) {
messaging.logError(message);
return false;
};
};
$("#logout-button").toggle(!isElectron());

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

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

View File

@@ -4,6 +4,7 @@ const noteTree = (function() {
const treeEl = $("#tree");
const parentListEl = $("#parent-list");
const parentListListEl = $("#parent-list-list");
const noteDetailEl = $("#note-detail");
let startNotePath = null;
let notesTreeMap = {};
@@ -59,23 +60,6 @@ const noteTree = (function() {
return treeUtils.getNotePath(node);
}
function getCurrentNoteId() {
const node = getCurrentNode();
return node ? node.data.note_id : null;
}
function getCurrentClones() {
const noteId = getCurrentNoteId();
if (noteId) {
return getNodesByNoteId(noteId);
}
else {
return [];
}
}
function getNodesByNoteTreeId(noteTreeId) {
assertArguments(noteTreeId);
@@ -185,13 +169,15 @@ const noteTree = (function() {
const noteTreeId = getNoteTreeId(parentNoteId, noteId);
const noteTree = notesTreeMap[noteTreeId];
const title = (noteTree.prefix ? (noteTree.prefix + " - ") : "") + noteIdToTitle[noteTree.note_id];
const node = {
note_id: noteTree.note_id,
parent_note_id: noteTree.parent_note_id,
note_tree_id: noteTree.note_tree_id,
is_protected: noteTree.is_protected,
prefix: noteTree.prefix,
title: (noteTree.prefix ? (noteTree.prefix + " - ") : "") + noteIdToTitle[noteTree.note_id],
title: escapeHtml(title),
extraClasses: getExtraClasses(noteTree),
refKey: noteTree.note_id,
expanded: noteTree.is_expanded
@@ -223,8 +209,6 @@ const noteTree = (function() {
let parentNoteId = 'root';
//console.log(now(), "Run path: ", runPath);
for (const childNoteId of runPath) {
const node = getNodesByNoteId(childNoteId).find(node => node.data.parent_note_id === parentNoteId);
@@ -237,6 +221,8 @@ const noteTree = (function() {
parentNoteId = childNoteId;
}
clearSelectedNodes();
}
/**
@@ -396,6 +382,22 @@ const noteTree = (function() {
recentNotes.addRecentNote(currentNoteTreeId, currentNotePath);
}
function getSelectedNodes() {
return getTree().getSelectedNodes();
}
function clearSelectedNodes() {
for (const selectedNode of getSelectedNodes()) {
selectedNode.setSelected(false);
}
const currentNode = getCurrentNode();
if (currentNode) {
currentNode.setSelected(true);
}
}
function initFancyTree(noteTree) {
assertArguments(noteTree);
@@ -403,28 +405,62 @@ const noteTree = (function() {
"del": node => {
treeChanges.deleteNode(node);
},
"shift+up": node => {
"ctrl+up": node => {
const beforeNode = node.getPrevSibling();
if (beforeNode !== null) {
treeChanges.moveBeforeNode(node, beforeNode);
treeChanges.moveBeforeNode([node], beforeNode);
}
return false;
},
"shift+down": node => {
"ctrl+down": node => {
let afterNode = node.getNextSibling();
if (afterNode !== null) {
treeChanges.moveAfterNode(node, afterNode);
treeChanges.moveAfterNode([node], afterNode);
}
return false;
},
"shift+left": node => {
"ctrl+left": node => {
treeChanges.moveNodeUpInHierarchy(node);
return false;
},
"shift+right": node => {
"ctrl+right": node => {
let toNode = node.getPrevSibling();
if (toNode !== null) {
treeChanges.moveToNode(node, toNode);
treeChanges.moveToNode([node], toNode);
}
return false;
},
"shift+up": node => {
node.navigate($.ui.keyCode.UP, true).then(() => {
const currentNode = getCurrentNode();
if (currentNode.isSelected()) {
node.setSelected(false);
}
currentNode.setSelected(true);
});
return false;
},
"shift+down": node => {
node.navigate($.ui.keyCode.DOWN, true).then(() => {
const currentNode = getCurrentNode();
if (currentNode.isSelected()) {
node.setSelected(false);
}
currentNode.setSelected(true);
});
return false;
},
"f2": node => {
editTreePrefix.showDialog(node);
@@ -432,26 +468,56 @@ const noteTree = (function() {
"alt+-": node => {
collapseTree(node);
},
"ctrl+a": node => {
for (const child of node.getParent().getChildren()) {
child.setSelected(true);
}
return false;
},
"ctrl+c": () => {
contextMenu.copy(getSelectedNodes());
return false;
},
"ctrl+x": () => {
contextMenu.cut(getSelectedNodes());
return false;
},
"ctrl+v": node => {
contextMenu.pasteInto(node);
return false;
},
"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.
"left": node => {
node.navigate($.ui.keyCode.LEFT, true);
node.navigate($.ui.keyCode.LEFT, true).then(() => clearSelectedNodes());
return false;
},
"right": node => {
node.navigate($.ui.keyCode.RIGHT, true);
node.navigate($.ui.keyCode.RIGHT, true).then(() => clearSelectedNodes());
return false;
},
"up": node => {
node.navigate($.ui.keyCode.UP, true);
node.navigate($.ui.keyCode.UP, true).then(() => clearSelectedNodes());
return false;
},
"down": node => {
node.navigate($.ui.keyCode.DOWN, true);
node.navigate($.ui.keyCode.DOWN, true).then(() => clearSelectedNodes());
return false;
}
@@ -463,6 +529,24 @@ const noteTree = (function() {
extensions: ["hotkeys", "filter", "dnd", "clones"],
source: noteTree,
scrollParent: $("#tree"),
click: (event, data) => {
const targetType = data.targetType;
const node = data.node;
if (targetType === 'title' || targetType === 'icon') {
if (!event.ctrlKey) {
node.setActive();
node.setSelected(true);
clearSelectedNodes();
}
else {
node.setSelected(!node.isSelected());
}
return false;
}
},
activate: (event, data) => {
const node = data.node.data;
@@ -493,9 +577,6 @@ const noteTree = (function() {
// so waiting a second helps
setTimeout(scrollToCurrentNote, 1000);
}
else {
showAppIfHidden();
}
},
hotkeys: {
keydown: keybindings
@@ -513,57 +594,6 @@ const noteTree = (function() {
mode: "hide" // Grayout unmatched nodes (pass "hide" to remove unmatched node instead)
},
dnd: dragAndDropSetup,
keydown: (event, data) => {
const node = data.node;
// Eat keyboard events, when a menu is open
if ($(".contextMenu:visible").length > 0)
return false;
switch (event.which) {
// Open context menu on [Space] key (simulate right click)
case 32: // [Space]
$(node.span).trigger("mousedown", {
preventDefault: true,
button: 2
})
.trigger("mouseup", {
preventDefault: true,
pageX: node.span.offsetLeft,
pageY: node.span.offsetTop,
button: 2
});
return false;
// Handle Ctrl+C, +X and +V
case 67:
if (event.ctrlKey) { // Ctrl+C
contextMenu.copy(node);
showMessage("Note copied into clipboard.");
return false;
}
break;
case 88:
if (event.ctrlKey) { // Ctrl+X
contextMenu.cut(node);
showMessage("Note cut into clipboard.");
return false;
}
break;
case 86:
if (event.ctrlKey) { // Ctrl+V
contextMenu.pasteInto(node);
showMessage("Note pasted from clipboard into current note.");
return false;
}
break;
}
},
lazyLoad: function(event, data){
const node = data.node.data;
@@ -772,7 +802,11 @@ const noteTree = (function() {
$(window).bind('hashchange', function() {
const notePath = getNotePathFromAddress();
activateNode(notePath);
if (getCurrentNotePath() !== notePath) {
console.log("Switching to " + notePath + " because of hash change");
activateNode(notePath);
}
});
if (isElectron()) {
@@ -807,6 +841,7 @@ const noteTree = (function() {
setPrefix,
getNotePathTitle,
removeParentChildRelation,
setParentChildRelation
setParentChildRelation,
getSelectedNodes
};
})();

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

@@ -13,4 +13,10 @@ async function syncNow() {
showError("Sync failed: " + result.message);
}
}
async function forceNoteSync(noteId) {
const result = await server.post('sync/force-note-sync/' + noteId);
showMessage("Note added to sync queue.");
}

View File

@@ -1,16 +1,30 @@
"use strict";
const treeChanges = (function() {
async function moveBeforeNode(node, beforeNode) {
await server.put('notes/' + node.data.note_tree_id + '/move-before/' + beforeNode.data.note_tree_id);
async function moveBeforeNode(nodesToMove, beforeNode) {
for (const nodeToMove of nodesToMove) {
const resp = await server.put('notes/' + nodeToMove.data.note_tree_id + '/move-before/' + beforeNode.data.note_tree_id);
changeNode(node, node => node.moveTo(beforeNode, 'before'));
if (!resp.success) {
alert(resp.message);
return;
}
changeNode(nodeToMove, node => node.moveTo(beforeNode, 'before'));
}
}
async function moveAfterNode(node, afterNode) {
await server.put('notes/' + node.data.note_tree_id + '/move-after/' + afterNode.data.note_tree_id);
async function moveAfterNode(nodesToMove, afterNode) {
for (const nodeToMove of nodesToMove) {
const resp = await server.put('notes/' + nodeToMove.data.note_tree_id + '/move-after/' + afterNode.data.note_tree_id);
changeNode(node, node => node.moveTo(afterNode, 'after'));
if (!resp.success) {
alert(resp.message);
return;
}
changeNode(nodeToMove, node => node.moveTo(afterNode, 'after'));
}
}
// beware that first arg is noteId and second is noteTreeId!
@@ -25,17 +39,30 @@ const treeChanges = (function() {
await noteTree.reload();
}
async function moveToNode(node, toNode) {
await server.put('notes/' + node.data.note_tree_id + '/move-to/' + toNode.data.note_id);
async function moveToNode(nodesToMove, toNode) {
for (const nodeToMove of nodesToMove) {
const resp = await server.put('notes/' + nodeToMove.data.note_tree_id + '/move-to/' + toNode.data.note_id);
changeNode(node, node => {
node.moveTo(toNode);
if (!resp.success) {
alert(resp.message);
return;
}
toNode.setExpanded(true);
changeNode(nodeToMove, node => {
// first expand which will force lazy load and only then move the node
// if this is not expanded before moving, then lazy load won't happen because it already contains node
// this doesn't work if this isn't a folder yet, that's why we expand second time below
toNode.setExpanded(true);
toNode.folder = true;
toNode.renderTitle();
});
node.moveTo(toNode);
toNode.folder = true;
toNode.renderTitle();
// this expands the note in case it become the folder only after the move
toNode.setExpanded(true);
});
}
}
async function cloneNoteTo(childNoteId, parentNoteId, prefix) {
@@ -92,7 +119,12 @@ const treeChanges = (function() {
return;
}
await server.put('notes/' + node.data.note_tree_id + '/move-after/' + node.getParent().data.note_tree_id);
const resp = await server.put('notes/' + node.data.note_tree_id + '/move-after/' + node.getParent().data.note_tree_id);
if (!resp.success) {
alert(resp.message);
return;
}
if (!isTopLevelNode(node) && node.getParent().getChildren().length <= 1) {
node.getParent().folder = false;

View File

@@ -37,7 +37,7 @@ const treeUtils = (function() {
const title = (prefix ? (prefix + " - ") : "") + noteTitle;
node.setTitle(title);
node.setTitle(escapeHtml(title));
}
return {

View File

@@ -93,4 +93,8 @@ function isTopLevelNode(node) {
function isRootNode(node) {
return node.key === "root_1";
}
function escapeHtml(str) {
return $('<div/>').text(str).html();
}

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

@@ -74,6 +74,12 @@ span.fancytree-node.fancytree-active-clone:not(.fancytree-active) .fancytree-tit
font-weight: bold;
}
/* By default not focused active tree item is not easily visible, this makes it more visible */
span.fancytree-active:not(.fancytree-focused) .fancytree-title {
background-color: #ddd !important;
border-color: #555 !important;
}
.ui-autocomplete {
max-height: 300px;
overflow-y: auto;
@@ -169,7 +175,6 @@ div.ui-tooltip {
/* Allow to use <kbd> elements inside the title to define shortcut hints. */
.ui-menu kbd, button kbd {
float: right;
color: black;
border: none;
background-color: transparent;
@@ -178,6 +183,7 @@ div.ui-tooltip {
.ui-menu kbd {
margin-left: 30px;
float: right;
}
#note-id-display {
@@ -185,9 +191,12 @@ div.ui-tooltip {
margin-left: 10px;
}
#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)}}
#note-source {
height: 98%;
width: 100%;
overflow: scroll;
}
.suppressed {
filter: opacity(7%);
}

View File

@@ -3,15 +3,26 @@
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');
/**
* 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, async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const parentNoteId = req.params.parentNoteId;
const sourceId = req.headers.source_id;
const noteToMove = await getNoteTree(noteTreeId);
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]);
const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1;
@@ -24,7 +35,7 @@ router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, async (req,
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({});
res.send({ success: true });
});
router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
@@ -32,30 +43,30 @@ router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, asyn
const beforeNoteTreeId = req.params.beforeNoteTreeId;
const sourceId = req.headers.source_id;
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 (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 sync_table.addNoteReorderingSync(beforeNote.parent_note_id, sourceId);
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 sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({});
}
else {
res.status(500).send("Before note " + beforeNoteTreeId + " doesn't exist.");
if (!await validateParentChild(res, beforeNote.parent_note_id, noteToMove.note_id, noteTreeId)) {
return;
}
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);
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 sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({ success: true });
});
router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
@@ -63,28 +74,28 @@ router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, async
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 noteToMove = await getNoteTree(noteTreeId);
const afterNote = await getNoteTree(afterNoteTreeId);
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 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 sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({});
}
else {
res.status(500).send("After note " + afterNoteTreeId + " doesn't exist.");
if (!await validateParentChild(res, afterNote.parent_note_id, noteToMove.note_id, noteTreeId)) {
return;
}
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 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);
});
res.send({ success: true });
});
router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => {
@@ -93,20 +104,8 @@ router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req
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 checkCycle(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]);
@@ -131,9 +130,7 @@ router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req
await sql.execute("UPDATE notes_tree SET is_expanded = 1 WHERE note_id = ?", [parentNoteId]);
});
res.send({
success: true
});
res.send({ success: true });
});
router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
@@ -141,26 +138,10 @@ router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (re
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 checkCycle(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 () => {
@@ -186,31 +167,85 @@ router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (re
await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId);
});
res.send({
success: true
});
res.send({ success: true });
});
async function checkCycle(parentNoteId, childNoteId) {
if (parentNoteId === 'root') {
return true;
}
async function loadSubTreeNoteIds(parentNoteId, subTreeNoteIds) {
subTreeNoteIds.push(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 target parent note.'
});
if (parentNoteId === childNoteId) {
return false;
}
const parentNoteIds = await sql.getFirstColumn("SELECT DISTINCT parent_note_id FROM notes_tree WHERE note_id = ?", [parentNoteId]);
if (!await checkTreeCycle(parentNoteId, childNoteId)) {
res.send({
success: false,
message: 'Moving note here would create cycle.'
});
for (const pid of parentNoteIds) {
if (!await checkCycle(pid, childNoteId)) {
return false;
}
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.
*/
async function checkTreeCycle(parentNoteId, childNoteId) {
const subTreeNoteIds = [];
// we'll load the whole sub tree - because the cycle can start in one of the notes in the sub tree
await loadSubTreeNoteIds(childNoteId, subTreeNoteIds);
async function checkTreeCycleInner(parentNoteId) {
if (parentNoteId === 'root') {
return true;
}
if (subTreeNoteIds.includes(parentNoteId)) {
// while towards the root of the tree we encountered noteId which is already present in the subtree
// joining parentNoteId with childNoteId would then clearly create a cycle
return false;
}
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)) {
return false;
}
}
return true;
}
return await checkTreeCycleInner(parentNoteId);
}
router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const expanded = req.params.expanded;

View File

@@ -46,6 +46,30 @@ router.post('/force-full-sync', auth.checkApiAuth, async (req, res, next) => {
res.send({});
});
router.post('/force-note-sync/:noteId', auth.checkApiAuth, async (req, res, next) => {
const noteId = req.params.noteId;
await sql.doInTransaction(async () => {
await sync_table.addNoteSync(noteId);
for (const noteTreeId of await sql.getFirstColumn("SELECT note_tree_id FROM notes_tree WHERE is_deleted = 0 AND note_id = ?", [noteId])) {
await sync_table.addNoteTreeSync(noteTreeId);
await sync_table.addRecentNoteSync(noteTreeId);
}
for (const noteHistoryId of await sql.getFirstColumn("SELECT note_history_id FROM notes_history WHERE note_id = ?", [noteId])) {
await sync_table.addNoteHistorySync(noteHistoryId);
}
});
log.info("Forcing note sync for " + noteId);
// not awaiting for the job to finish (will probably take a long time)
sync.sync();
res.send({});
});
router.get('/changed', auth.checkApiAuth, async (req, res, next) => {
const lastSyncId = parseInt(req.query.lastSyncId);
@@ -88,7 +112,7 @@ router.get('/notes_reordering/:noteTreeParentId', auth.checkApiAuth, async (req,
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])
});
});

View File

@@ -12,14 +12,20 @@ const notes = require('../../services/notes');
const sync_table = require('../../services/sync_table');
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");
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);

View File

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

View File

@@ -3,12 +3,9 @@
const migration = require('./migration');
const sql = require('./sql');
const utils = require('./utils');
const options = require('./options');
async function checkAuth(req, res, next) {
const username = await options.getOption('username');
if (!username) {
if (!await sql.isUserInitialized()) {
res.redirect("setup");
}
else if (!req.session.loggedIn && !utils.isElectron()) {
@@ -53,9 +50,7 @@ async function checkApiAuthForMigrationPage(req, res, next) {
}
async function checkAppNotInitialized(req, res, next) {
const username = await options.getOption('username');
if (username) {
if (await sql.isUserInitialized()) {
res.status(400).send("App already initialized.");
}
else {

View File

@@ -1 +1 @@
module.exports = { build_date:"2017-12-26T19:57:44-05:00", build_revision: "baab7454626b154b43144c1a07e1962ab083bde2" };
module.exports = { build_date:"2018-01-03T23:05:00-05:00", build_revision: "f2aaf8b0a3b761fb6a1ec79e7c6b95e3eb9e4db0" };

View File

@@ -4,8 +4,9 @@ const ini = require('ini');
const fs = require('fs');
const dataDir = require('./data_dir');
const path = require('path');
const resource_dir = require('./resource_dir');
const configSampleFilePath = path.resolve(__dirname, "..", "config-sample.ini");
const configSampleFilePath = path.resolve(resource_dir.RESOURCE_DIR, "config-sample.ini");
const configFilePath = dataDir.TRILIUM_DATA_DIR + '/config.ini';

View File

@@ -17,6 +17,46 @@ 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 WHERE is_deleted = 0");
for (const row of rows) {
const childNoteId = row.note_id;
const parentNoteId = row.parent_note_id;
if (!childToParents[childNoteId]) {
childToParents[childNoteId] = [];
}
childToParents[childNoteId].push(parentNoteId);
}
function checkTreeCycle(noteId, path, errorList) {
if (noteId === 'root') {
return;
}
for (const parentNoteId of childToParents[noteId]) {
if (path.includes(parentNoteId)) {
errorList.push(`Tree cycle detected at parent-child relationship: ${parentNoteId} - ${noteId}, whole path: ${path}`);
}
else {
const newPath = path.slice();
newPath.push(noteId);
checkTreeCycle(parentNoteId, newPath, errorList);
}
}
}
const noteIds = Object.keys(childToParents);
for (const noteId of noteIds) {
checkTreeCycle(noteId, [], errorList);
}
}
async function runSyncRowChecks(table, key, errorList) {
await runCheck(`
SELECT
@@ -43,6 +83,8 @@ async function runSyncRowChecks(table, key, errorList) {
async function runChecks() {
const errorList = [];
const startTime = new Date();
await runCheck(`
SELECT
note_id
@@ -119,16 +161,40 @@ 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 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);
if (errorList.length === 0) {
// we run this only if basic checks passed since this assumes basic data consistency
await checkTreeCycles(errorList);
}
const elapsedTimeMs = new Date().getTime() - startTime.getTime();
if (errorList.length > 0) {
log.info(`Consistency checks failed (took ${elapsedTimeMs}ms) with these errors: ` + JSON.stringify(errorList));
messaging.sendMessageToAllClients({type: 'consistency-checks-failed'});
}
else {
log.info("All consistency checks passed.");
log.info(`All consistency checks passed (took ${elapsedTimeMs}ms)`);
}
}

View File

@@ -1,6 +1,7 @@
const sql = require('./sql');
const utils = require('./utils');
const options = require('./options');
const log = require('./log');
function getHash(rows) {
let hash = '';
@@ -13,9 +14,11 @@ function getHash(rows) {
}
async function getHashes() {
const startTime = new Date();
const optionsQuestionMarks = Array(options.SYNCED_OPTIONS.length).fill('?').join(',');
return {
const hashes = {
notes: getHash(await sql.getAll(`SELECT
note_id,
note_title,
@@ -62,6 +65,12 @@ async function getHashes() {
WHERE opt_name IN (${optionsQuestionMarks})
ORDER BY opt_name`, options.SYNCED_OPTIONS))
};
const elapseTimeMs = new Date().getTime() - startTime.getTime();
log.info(`Content hash computation took ${elapseTimeMs}ms`);
return hashes;
}
module.exports = {

View File

@@ -1,6 +1,7 @@
"use strict";
const crypto = require('crypto');
const log = require('./log');
function arraysIdentical(a, b) {
let i = a.length;
@@ -72,7 +73,15 @@ function decrypt(key, iv, cipherText) {
function decryptString(dataKey, iv, cipherText) {
const buffer = decrypt(dataKey, iv, cipherText);
return buffer.toString('utf-8');
const str = buffer.toString('utf-8');
if (str === 'false') {
log.error("Could not decrypt string. Buffer: " + buffer);
throw new Error("Could not decrypt string.");
}
return str;
}
function noteTitleIv(iv) {

View File

@@ -54,6 +54,8 @@ async function sendMessage(client, message) {
async function sendMessageToAllClients(message) {
const jsonStr = JSON.stringify(message);
log.info("Sending message to all clients: " + jsonStr);
webSocketServer.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(jsonStr);

View File

@@ -3,14 +3,7 @@ const sql = require('./sql');
const options = require('./options');
const fs = require('fs-extra');
const log = require('./log');
const path = require('path');
const MIGRATIONS_DIR = path.resolve(__dirname, "..", "migrations");
if (!fs.existsSync(MIGRATIONS_DIR)) {
log.error("Could not find migration directory: " + MIGRATIONS_DIR);
process.exit(1);
}
const resource_dir = require('./resource_dir');
async function migrate() {
const migrations = [];
@@ -20,7 +13,7 @@ async function migrate() {
const currentDbVersion = parseInt(await options.getOption('db_version'));
fs.readdirSync(MIGRATIONS_DIR).forEach(file => {
fs.readdirSync(resource_dir.MIGRATIONS_DIR).forEach(file => {
const match = file.match(/([0-9]{4})__([a-zA-Z0-9_ ]+)\.(sql|js)/);
if (match) {
@@ -53,7 +46,7 @@ async function migrate() {
await sql.doInTransaction(async () => {
if (mig.type === 'sql') {
const migrationSql = fs.readFileSync(MIGRATIONS_DIR + "/" + mig.file).toString('utf8');
const migrationSql = fs.readFileSync(resource_dir.MIGRATIONS_DIR + "/" + mig.file).toString('utf8');
console.log("Migration with SQL script: " + migrationSql);
@@ -62,7 +55,7 @@ async function migrate() {
else if (mig.type === 'js') {
console.log("Migration with JS module");
const migrationModule = require("../" + MIGRATIONS_DIR + "/" + mig.file);
const migrationModule = require("../" + resource_dir.MIGRATIONS_DIR + "/" + mig.file);
await migrationModule(db);
}
else {

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

25
services/resource_dir.js Normal file
View File

@@ -0,0 +1,25 @@
const log = require('./log');
const path = require('path');
const fs = require('fs');
const RESOURCE_DIR = path.resolve(__dirname, "..");
const MIGRATIONS_DIR = path.resolve(RESOURCE_DIR, "migrations");
if (!fs.existsSync(MIGRATIONS_DIR)) {
log.error("Could not find migration directory: " + MIGRATIONS_DIR);
process.exit(1);
}
const DB_INIT_DIR = path.resolve(RESOURCE_DIR, "db");
if (!fs.existsSync(DB_INIT_DIR)) {
log.error("Could not find DB initialization directory: " + DB_INIT_DIR);
process.exit(1);
}
module.exports = {
RESOURCE_DIR,
MIGRATIONS_DIR,
DB_INIT_DIR
};

View File

@@ -4,8 +4,8 @@ const log = require('./log');
const dataDir = require('./data_dir');
const fs = require('fs');
const sqlite = require('sqlite');
const utils = require('./utils');
const app_info = require('./app_info');
const resource_dir = require('./resource_dir');
async function createConnection() {
return await sqlite.open(dataDir.DOCUMENT_PATH, {Promise});
@@ -28,18 +28,18 @@ const dbReady = new Promise((resolve, reject) => {
if (tableResults.length !== 1) {
log.info("Connected to db, but schema doesn't exist. Initializing schema ...");
const schema = fs.readFileSync('db/schema.sql', 'UTF-8');
const notesSql = fs.readFileSync('db/main_notes.sql', 'UTF-8');
const notesTreeSql = fs.readFileSync('db/main_notes_tree.sql', 'UTF-8');
const schema = fs.readFileSync(resource_dir.DB_INIT_DIR + '/schema.sql', 'UTF-8');
const notesSql = fs.readFileSync(resource_dir.DB_INIT_DIR + '/main_notes.sql', 'UTF-8');
const notesTreeSql = fs.readFileSync(resource_dir.DB_INIT_DIR + '/main_notes_tree.sql', 'UTF-8');
await doInTransaction(async () => {
await executeScript(schema);
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();
});
@@ -49,9 +49,7 @@ const dbReady = new Promise((resolve, reject) => {
// the database
}
else {
const username = await getFirstValue("SELECT opt_value FROM options WHERE opt_name = 'username'");
if (!username) {
if (!await isUserInitialized()) {
log.info("Login/password not initialized. DB not ready.");
return;
@@ -235,8 +233,15 @@ async function isDbUpToDate() {
return upToDate;
}
async function isUserInitialized() {
const username = await getFirstValue("SELECT opt_value FROM options WHERE opt_name = 'username'");
return !!username;
}
module.exports = {
dbReady,
isUserInitialized,
insert,
replace,
getFirstValue,

View File

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

View File

@@ -24,8 +24,8 @@ async function addOptionsSync(optName, sourceId) {
await addEntitySync("options", optName, sourceId);
}
async function addRecentNoteSync(notePath, sourceId) {
await addEntitySync("recent_notes", notePath, sourceId);
async function addRecentNoteSync(noteTreeId, sourceId) {
await addEntitySync("recent_notes", noteTreeId, sourceId);
}
async function addEntitySync(entityName, entityId, sourceId) {

View File

@@ -40,7 +40,9 @@ async function updateNoteHistory(entity, sourceId) {
const orig = await sql.getFirstOrNull("SELECT * FROM notes_history WHERE note_history_id = ?", [entity.note_history_id]);
await sql.doInTransaction(async () => {
if (orig === null || orig.date_modified_to < entity.date_modified_to) {
// we update note history even if date modified to is the same because the only thing which might have changed
// is the protected status (and correnspondingly note_title and note_text) which doesn't affect the date_modified_to
if (orig === null || orig.date_modified_to <= entity.date_modified_to) {
await sql.replace('notes_history', entity);
await sync_table.addNoteHistorySync(entity.note_history_id, sourceId);

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">
@@ -29,7 +27,7 @@
<button class="btn btn-xs" onclick="settings.showDialog();">Settings</button>
<form action="logout" method="POST" style="display: inline;">
<form action="logout" id="logout-button" method="POST" style="display: inline;">
<input type="submit" class="btn btn-xs" value="Logout">
</form>
</div>
@@ -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>
@@ -172,11 +170,11 @@
<div id="protected-session-password-dialog" title="Protected session" style="display: none;">
<form id="protected-session-password-form">
<div class="form-group">
<label for="protected-session-password">To proceed with requested action you need to enter protected session by entering password:</label>
<label for="protected-session-password">To proceed with requested action you need to start protected session by entering password:</label>
<input id="protected-session-password" class="form-control" type="password">
</div>
<button class="btn btn-sm">Enter protected session <kbd>enter</kbd></button>
<button class="btn btn-sm">Start protected session <kbd>enter</kbd></button>
</form>
</div>
@@ -327,7 +325,7 @@
</div>
<div id="note-source-dialog" title="Note source" style="display: none; padding: 20px;">
<pre id="note-source"></pre>
<textarea id="note-source" readonly="readonly"></textarea>
</div>
<div id="tooltip" style="display: none;"></div>
@@ -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>

View File

@@ -48,6 +48,5 @@
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
<link href="libraries/bootstrap/css/bootstrap.css" rel="stylesheet">
<script src="libraries/bootstrap/js/bootstrap.js"></script>
</body>
</html>