From 16eb156033c941de308d0c1903024c99691437db Mon Sep 17 00:00:00 2001 From: azivner Date: Sat, 13 Jan 2018 18:02:41 -0500 Subject: [PATCH] refactoring of note changes / cloning --- README.md | 2 +- plugins/reddit.js | 4 +- public/javascripts/cloning.js | 33 +++ public/javascripts/context_menu.js | 4 +- public/javascripts/dialogs/add_link.js | 4 +- public/javascripts/dialogs/recent_notes.js | 4 +- public/javascripts/note_tree.js | 2 +- public/javascripts/tree_changes.js | 39 +-- routes/api/cloning.js | 84 +++++++ routes/api/notes.js | 8 - routes/api/notes_move.js | 263 --------------------- routes/api/tree_changes.js | 124 ++++++++++ routes/routes.js | 6 +- services/attributes.js | 2 + services/tree.js | 84 +++++++ views/index.ejs | 1 + 16 files changed, 349 insertions(+), 315 deletions(-) create mode 100644 public/javascripts/cloning.js create mode 100644 routes/api/cloning.js delete mode 100644 routes/api/notes_move.js create mode 100644 routes/api/tree_changes.js create mode 100644 services/tree.js diff --git a/README.md b/README.md index a645f72a3..98d450f41 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Trilium Notes is a hierarchical note taking application. Picture tells a thousan ## Builds * If you want to install Trilium on server, follow [this page](https://github.com/zadam/trilium/wiki/Installation-as-webapp) -* If you want to use Trilium on the desktop, download binary release for your platfor from [latest release](https://github.com/zadam/trilium/releases/latest), unzip the package and run ```trilium``` executable. +* If you want to use Trilium on the desktop, download binary release for your platform from [latest release](https://github.com/zadam/trilium/releases/latest), unzip the package and run ```trilium``` executable. ## Supported platforms diff --git a/plugins/reddit.js b/plugins/reddit.js index cb68b2612..d90073f7f 100644 --- a/plugins/reddit.js +++ b/plugins/reddit.js @@ -1,3 +1,5 @@ +"use strict"; + const sql = require('../services/sql'); const notes = require('../services/notes'); const axios = require('axios'); @@ -179,7 +181,7 @@ sql.dbReady.then(async () => { let importedComments = 0; for (const account of redditAccounts) { - log.info("Importing account " + account); + log.info("Reddit: Importing account " + account); importedComments += await importReddit(account); } diff --git a/public/javascripts/cloning.js b/public/javascripts/cloning.js new file mode 100644 index 000000000..6be426b7d --- /dev/null +++ b/public/javascripts/cloning.js @@ -0,0 +1,33 @@ +"use strict"; + +const cloning = (function() { + async function cloneNoteTo(childNoteId, parentNoteId, prefix) { + const resp = await server.put('notes/' + childNoteId + '/clone-to/' + parentNoteId, { + prefix: prefix + }); + + if (!resp.success) { + alert(resp.message); + return; + } + + await noteTree.reload(); + } + + // beware that first arg is noteId and second is noteTreeId! + async function cloneNoteAfter(noteId, afterNoteTreeId) { + const resp = await server.put('notes/' + noteId + '/clone-after/' + afterNoteTreeId); + + if (!resp.success) { + alert(resp.message); + return; + } + + await noteTree.reload(); + } + + return { + cloneNoteAfter, + cloneNoteTo + }; +})(); \ No newline at end of file diff --git a/public/javascripts/context_menu.js b/public/javascripts/context_menu.js index 0f1864c60..981bd8fa3 100644 --- a/public/javascripts/context_menu.js +++ b/public/javascripts/context_menu.js @@ -19,7 +19,7 @@ const contextMenu = (function() { } else if (clipboardMode === 'copy') { for (const noteId of clipboardIds) { - treeChanges.cloneNoteAfter(noteId, node.data.note_tree_id); + cloning.cloneNoteAfter(noteId, node.data.note_tree_id); } // copy will keep clipboardIds and clipboardMode so it's possible to paste into multiple places @@ -45,7 +45,7 @@ const contextMenu = (function() { } else if (clipboardMode === 'copy') { for (const noteId of clipboardIds) { - treeChanges.cloneNoteTo(noteId, node.data.note_id); + cloning.cloneNoteTo(noteId, node.data.note_id); } // copy will keep clipboardIds and clipboardMode so it's possible to paste into multiple places } diff --git a/public/javascripts/dialogs/add_link.js b/public/javascripts/dialogs/add_link.js index 67a960e27..15b3d39ab 100644 --- a/public/javascripts/dialogs/add_link.js +++ b/public/javascripts/dialogs/add_link.js @@ -78,14 +78,14 @@ const addLink = (function() { else if (linkType === 'selected-to-current') { const prefix = clonePrefixEl.val(); - treeChanges.cloneNoteTo(noteId, noteEditor.getCurrentNoteId(), prefix); + cloning.cloneNoteTo(noteId, noteEditor.getCurrentNoteId(), prefix); dialogEl.dialog("close"); } else if (linkType === 'current-to-selected') { const prefix = clonePrefixEl.val(); - treeChanges.cloneNoteTo(noteEditor.getCurrentNoteId(), noteId, prefix); + cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), noteId, prefix); dialogEl.dialog("close"); } diff --git a/public/javascripts/dialogs/recent_notes.js b/public/javascripts/dialogs/recent_notes.js index 397be4e69..db174065b 100644 --- a/public/javascripts/dialogs/recent_notes.js +++ b/public/javascripts/dialogs/recent_notes.js @@ -86,13 +86,13 @@ const recentNotes = (function() { } async function addCurrentAsChild() { - await treeChanges.cloneNoteTo(noteEditor.getCurrentNoteId(), getSelectedNoteId()); + await cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), getSelectedNoteId()); dialogEl.dialog("close"); } async function addRecentAsChild() { - await treeChanges.cloneNoteTo(getSelectedNoteId(), noteEditor.getCurrentNoteId()); + await cloning.cloneNoteTo(getSelectedNoteId(), noteEditor.getCurrentNoteId()); dialogEl.dialog("close"); } diff --git a/public/javascripts/note_tree.js b/public/javascripts/note_tree.js index 0a80a4caf..834b6895c 100644 --- a/public/javascripts/note_tree.js +++ b/public/javascripts/note_tree.js @@ -368,7 +368,7 @@ const noteTree = (function() { const expandedNum = isExpanded ? 1 : 0; - await server.put('notes/' + noteTreeId + '/expanded/' + expandedNum); + await server.put('tree/' + noteTreeId + '/expanded/' + expandedNum); } function setCurrentNotePathToHash(node) { diff --git a/public/javascripts/tree_changes.js b/public/javascripts/tree_changes.js index 0378ece5a..3a09f9810 100644 --- a/public/javascripts/tree_changes.js +++ b/public/javascripts/tree_changes.js @@ -3,7 +3,7 @@ const treeChanges = (function() { 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); + const resp = await server.put('tree/' + nodeToMove.data.note_tree_id + '/move-before/' + beforeNode.data.note_tree_id); if (!resp.success) { alert(resp.message); @@ -16,7 +16,7 @@ const treeChanges = (function() { 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); + const resp = await server.put('tree/' + nodeToMove.data.note_tree_id + '/move-after/' + afterNode.data.note_tree_id); if (!resp.success) { alert(resp.message); @@ -27,21 +27,9 @@ const treeChanges = (function() { } } - // beware that first arg is noteId and second is noteTreeId! - async function cloneNoteAfter(noteId, afterNoteTreeId) { - const resp = await server.put('notes/' + noteId + '/clone-after/' + afterNoteTreeId); - - if (!resp.success) { - alert(resp.message); - return; - } - - await noteTree.reload(); - } - 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); + const resp = await server.put('tree/' + nodeToMove.data.note_tree_id + '/move-to/' + toNode.data.note_id); if (!resp.success) { alert(resp.message); @@ -65,25 +53,12 @@ const treeChanges = (function() { } } - async function cloneNoteTo(childNoteId, parentNoteId, prefix) { - const resp = await server.put('notes/' + childNoteId + '/clone-to/' + parentNoteId, { - prefix: prefix - }); - - if (!resp.success) { - alert(resp.message); - return; - } - - await noteTree.reload(); - } - async function deleteNode(node) { if (!confirm('Are you sure you want to delete note "' + node.title + '" and all its sub-notes?')) { return; } - await server.remove('notes/' + node.data.note_tree_id); + await server.remove('tree/' + node.data.note_tree_id); if (!isTopLevelNode(node) && node.getParent().getChildren().length <= 1) { node.getParent().folder = false; @@ -119,7 +94,7 @@ const treeChanges = (function() { return; } - const resp = await server.put('notes/' + node.data.note_tree_id + '/move-after/' + node.getParent().data.note_tree_id); + const resp = await server.put('tree/' + node.data.note_tree_id + '/move-after/' + node.getParent().data.note_tree_id); if (!resp.success) { alert(resp.message); @@ -153,8 +128,6 @@ const treeChanges = (function() { moveAfterNode, moveToNode, deleteNode, - moveNodeUpInHierarchy, - cloneNoteAfter, - cloneNoteTo + moveNodeUpInHierarchy }; })(); \ No newline at end of file diff --git a/routes/api/cloning.js b/routes/api/cloning.js new file mode 100644 index 000000000..2ada25e62 --- /dev/null +++ b/routes/api/cloning.js @@ -0,0 +1,84 @@ +"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 wrap = require('express-promise-wrap').wrap; +const tree = require('../../services/tree'); + +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; + + if (!await tree.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]); + const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1; + + await sql.doInTransaction(async () => { + const noteTree = { + note_tree_id: utils.newNoteTreeId(), + note_id: childNoteId, + parent_note_id: parentNoteId, + prefix: prefix, + note_position: newNotePos, + is_expanded: 0, + date_modified: utils.nowDate(), + is_deleted: 0 + }; + + await sql.replace("notes_tree", noteTree); + + await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId); + + await sql.execute("UPDATE notes_tree SET is_expanded = 1 WHERE note_id = ?", [parentNoteId]); + }); + + res.send({ success: true }); +})); + +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 tree.getNoteTree(afterNoteTreeId); + + if (!await tree.validateParentChild(res, afterNote.parent_note_id, noteId)) { + 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); + + const noteTree = { + note_tree_id: utils.newNoteTreeId(), + note_id: noteId, + parent_note_id: afterNote.parent_note_id, + note_position: afterNote.note_position + 1, + is_expanded: 0, + date_modified: utils.nowDate(), + is_deleted: 0 + }; + + await sql.replace("notes_tree", noteTree); + + await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId); + }); + + res.send({ success: true }); +})); + +module.exports = router; \ No newline at end of file diff --git a/routes/api/notes.js b/routes/api/notes.js index 93dec96a7..1a2b83d36 100644 --- a/routes/api/notes.js +++ b/routes/api/notes.js @@ -58,14 +58,6 @@ router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => { res.send({}); })); -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, wrap(async (req, res, next) => { const search = '%' + req.query.search + '%'; diff --git a/routes/api/notes_move.js b/routes/api/notes_move.js deleted file mode 100644 index b75b17ab7..000000000 --- a/routes/api/notes_move.js +++ /dev/null @@ -1,263 +0,0 @@ -"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 wrap = require('express-promise-wrap').wrap; - -/** - * 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 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; - - const now = utils.nowDate(); - - await sql.doInTransaction(async () => { - await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?", - [parentNoteId, newNotePos, now, noteTreeId]); - - await sync_table.addNoteTreeSync(noteTreeId, sourceId); - }); - - res.send({ success: true }); -})); - -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 getNoteTree(noteTreeId); - const beforeNote = await getNoteTree(beforeNoteTreeId); - - 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, wrap(async (req, res, next) => { - const noteTreeId = req.params.noteTreeId; - const afterNoteTreeId = req.params.afterNoteTreeId; - const sourceId = req.headers.source_id; - - const noteToMove = await getNoteTree(noteTreeId); - const afterNote = await getNoteTree(afterNoteTreeId); - - 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, 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; - - 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]); - const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1; - - await sql.doInTransaction(async () => { - const noteTree = { - note_tree_id: utils.newNoteTreeId(), - note_id: childNoteId, - parent_note_id: parentNoteId, - prefix: prefix, - note_position: newNotePos, - is_expanded: 0, - date_modified: utils.nowDate(), - is_deleted: 0 - }; - - await sql.replace("notes_tree", noteTree); - - await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId); - - await sql.execute("UPDATE notes_tree SET is_expanded = 1 WHERE note_id = ?", [parentNoteId]); - }); - - res.send({ success: true }); -})); - -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 getNoteTree(afterNoteTreeId); - - if (!await validateParentChild(res, afterNote.parent_note_id, noteId)) { - 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); - - const noteTree = { - note_tree_id: utils.newNoteTreeId(), - note_id: noteId, - parent_note_id: afterNote.parent_note_id, - note_position: afterNote.note_position + 1, - is_expanded: 0, - date_modified: utils.nowDate(), - is_deleted: 0 - }; - - await sql.replace("notes_tree", noteTree); - - await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId); - }); - - 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 = ? 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. - */ -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, wrap(async (req, res, next) => { - const noteTreeId = req.params.noteTreeId; - const expanded = req.params.expanded; - - await sql.doInTransaction(async () => { - await sql.execute("UPDATE notes_tree SET is_expanded = ? WHERE note_tree_id = ?", [expanded, noteTreeId]); - - // we don't sync expanded attribute - }); - - res.send({}); -})); - -module.exports = router; \ No newline at end of file diff --git a/routes/api/tree_changes.js b/routes/api/tree_changes.js new file mode 100644 index 000000000..6dd603e71 --- /dev/null +++ b/routes/api/tree_changes.js @@ -0,0 +1,124 @@ +"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 tree = require('../../services/tree'); +const wrap = require('express-promise-wrap').wrap; + +/** + * 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 tree.getNoteTree(noteTreeId); + + if (!await tree.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; + + const now = utils.nowDate(); + + await sql.doInTransaction(async () => { + await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?", + [parentNoteId, newNotePos, now, noteTreeId]); + + await sync_table.addNoteTreeSync(noteTreeId, sourceId); + }); + + res.send({ success: true }); +})); + +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 tree.getNoteTree(noteTreeId); + const beforeNote = await tree.getNoteTree(beforeNoteTreeId); + + if (!await tree.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, wrap(async (req, res, next) => { + const noteTreeId = req.params.noteTreeId; + const afterNoteTreeId = req.params.afterNoteTreeId; + const sourceId = req.headers.source_id; + + const noteToMove = await tree.getNoteTree(noteTreeId); + const afterNote = await tree.getNoteTree(afterNoteTreeId); + + if (!await tree.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('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, wrap(async (req, res, next) => { + const noteTreeId = req.params.noteTreeId; + const expanded = req.params.expanded; + + await sql.doInTransaction(async () => { + await sql.execute("UPDATE notes_tree SET is_expanded = ? WHERE note_tree_id = ?", [expanded, noteTreeId]); + + // we don't sync expanded attribute + }); + + res.send({}); +})); + +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({}); +})); + +module.exports = router; \ No newline at end of file diff --git a/routes/routes.js b/routes/routes.js index 4e5bb4763..bcc2abe7a 100644 --- a/routes/routes.js +++ b/routes/routes.js @@ -7,7 +7,8 @@ const setupRoute = require('./setup'); // API routes const treeApiRoute = require('./api/tree'); const notesApiRoute = require('./api/notes'); -const notesMoveApiRoute = require('./api/notes_move'); +const treeChangesApiRoute = require('./api/tree_changes'); +const cloningApiRoute = require('./api/cloning'); const noteHistoryApiRoute = require('./api/note_history'); const recentChangesApiRoute = require('./api/recent_changes'); const settingsApiRoute = require('./api/settings'); @@ -36,7 +37,8 @@ function register(app) { app.use('/api/tree', treeApiRoute); app.use('/api/notes', notesApiRoute); - app.use('/api/notes', notesMoveApiRoute); + app.use('/api/tree', treeChangesApiRoute); + app.use('/api/notes', cloningApiRoute); app.use('/api/notes', attributesRoute); app.use('/api/notes-history', noteHistoryApiRoute); app.use('/api/recent-changes', recentChangesApiRoute); diff --git a/services/attributes.js b/services/attributes.js index ede044a37..e2e68f04f 100644 --- a/services/attributes.js +++ b/services/attributes.js @@ -1,3 +1,5 @@ +"use strict"; + const sql = require('./sql'); const utils = require('./utils'); const sync_table = require('./sync_table'); diff --git a/services/tree.js b/services/tree.js new file mode 100644 index 000000000..a8152b7a3 --- /dev/null +++ b/services/tree.js @@ -0,0 +1,84 @@ +"use strict"; + +const sql = require('./sql'); + +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. + */ +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); +} + +async function getNoteTree(noteTreeId) { + return sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]); +} + +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); + } +} + +module.exports = { + validateParentChild, + getNoteTree +}; \ No newline at end of file diff --git a/views/index.ejs b/views/index.ejs index 2f5ba0e2e..1e4ad69ca 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -418,6 +418,7 @@ +