From b16c2d19b63bf6c03fa50ef615589d17fe6f6d34 Mon Sep 17 00:00:00 2001 From: zadam Date: Sat, 19 Oct 2019 12:36:16 +0200 Subject: [PATCH] duplicate (single) note --- .../0148__make_isExpanded_not_null.sql | 22 ++++++ db/migrations/0149__space_out_positions.sql | 2 + db/schema.sql | 72 ++++++++----------- src/entities/branch.js | 2 +- src/public/javascripts/dialogs/attributes.js | 5 +- src/public/javascripts/services/tree.js | 15 +++- .../javascripts/services/tree_context_menu.js | 8 +++ src/routes/api/branches.js | 20 +++--- src/routes/api/notes.js | 9 ++- src/routes/routes.js | 1 + src/services/app_info.js | 2 +- src/services/cloning.js | 4 +- src/services/consistency_checks.js | 2 +- src/services/notes.js | 47 ++++++++++-- src/services/sql_init.js | 2 +- src/services/tree.js | 4 +- 16 files changed, 147 insertions(+), 70 deletions(-) create mode 100644 db/migrations/0148__make_isExpanded_not_null.sql create mode 100644 db/migrations/0149__space_out_positions.sql diff --git a/db/migrations/0148__make_isExpanded_not_null.sql b/db/migrations/0148__make_isExpanded_not_null.sql new file mode 100644 index 000000000..357716a23 --- /dev/null +++ b/db/migrations/0148__make_isExpanded_not_null.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS "mig_branches" ( + `branchId` TEXT NOT NULL, + `noteId` TEXT NOT NULL, + `parentNoteId` TEXT NOT NULL, + `notePosition` INTEGER NOT NULL, + `prefix` TEXT, + `isExpanded` INTEGER NOT NULL DEFAULT 0, + `isDeleted` INTEGER NOT NULL DEFAULT 0, + `utcDateModified` TEXT NOT NULL, + utcDateCreated TEXT NOT NULL, + hash TEXT DEFAULT "" NOT NULL, + PRIMARY KEY(`branchId`)); + +INSERT INTO mig_branches (branchId, noteId, parentNoteId, notePosition, prefix, isExpanded, isDeleted, utcDateModified, utcDateCreated, hash) +SELECT branchId, noteId, parentNoteId, notePosition, prefix, COALESCE(isExpanded, 0), isDeleted, utcDateModified, utcDateCreated, hash FROM branches; + +DROP TABLE branches; +ALTER TABLE mig_branches RENAME TO branches; + +CREATE INDEX `IDX_branches_noteId` ON `branches` (`noteId`); +CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` (`noteId`,`parentNoteId`); +CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId); \ No newline at end of file diff --git a/db/migrations/0149__space_out_positions.sql b/db/migrations/0149__space_out_positions.sql new file mode 100644 index 000000000..0f8bbb6e9 --- /dev/null +++ b/db/migrations/0149__space_out_positions.sql @@ -0,0 +1,2 @@ +UPDATE branches SET notePosition = notePosition * 10; +UPDATE attributes SET position = position * 10; \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index ed5627b59..d649a5870 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -29,25 +29,6 @@ CREATE TABLE IF NOT EXISTS "api_tokens" utcDateCreated TEXT NOT NULL, isDeleted INT NOT NULL DEFAULT 0, hash TEXT DEFAULT "" NOT NULL); -CREATE TABLE IF NOT EXISTS "branches" ( - `branchId` TEXT NOT NULL, - `noteId` TEXT NOT NULL, - `parentNoteId` TEXT NOT NULL, - `notePosition` INTEGER NOT NULL, - `prefix` TEXT, - `isExpanded` BOOLEAN, - `isDeleted` INTEGER NOT NULL DEFAULT 0, - `utcDateModified` TEXT NOT NULL, - utcDateCreated TEXT NOT NULL, - hash TEXT DEFAULT "" NOT NULL, - PRIMARY KEY(`branchId`) -); -CREATE TABLE IF NOT EXISTS "event_log" ( - `eventId` TEXT NOT NULL PRIMARY KEY, - `noteId` TEXT, - `comment` TEXT, - `utcDateCreated` TEXT NOT NULL -); CREATE TABLE IF NOT EXISTS "options" ( name TEXT not null PRIMARY KEY, @@ -84,21 +65,6 @@ CREATE TABLE IF NOT EXISTS "notes" ( `utcDateModified` TEXT NOT NULL, PRIMARY KEY(`noteId`) ); -CREATE TABLE IF NOT EXISTS "note_contents" ( - `noteId` TEXT NOT NULL, - `content` TEXT NULL DEFAULT NULL, - `hash` TEXT DEFAULT "" NOT NULL, - `utcDateModified` TEXT NOT NULL, - PRIMARY KEY(`noteId`) -); -CREATE TABLE recent_notes -( - noteId TEXT not null primary key, - notePath TEXT not null, - hash TEXT default "" not null, - utcDateCreated TEXT not null, - isDeleted INT -); CREATE UNIQUE INDEX `IDX_sync_entityName_entityId` ON `sync` ( `entityName`, `entityId` @@ -115,14 +81,6 @@ CREATE INDEX `IDX_note_revisions_dateModifiedFrom` ON `note_revisions` ( CREATE INDEX `IDX_note_revisions_dateModifiedTo` ON `note_revisions` ( `utcDateModifiedTo` ); -CREATE INDEX `IDX_branches_noteId` ON `branches` ( - `noteId` - ); -CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` ( - `noteId`, - `parentNoteId` - ); -CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId); CREATE INDEX IDX_attributes_name_value on attributes (name, value); CREATE INDEX IDX_attributes_name_index @@ -131,3 +89,33 @@ CREATE INDEX IDX_attributes_noteId_index on attributes (noteId); CREATE INDEX IDX_attributes_value_index on attributes (value); +CREATE TABLE IF NOT EXISTS "note_contents" ( + `noteId` TEXT NOT NULL, + `content` TEXT NULL DEFAULT NULL, + `hash` TEXT DEFAULT "" NOT NULL, + `utcDateModified` TEXT NOT NULL, + PRIMARY KEY(`noteId`) +); +CREATE TABLE recent_notes +( + noteId TEXT not null primary key, + notePath TEXT not null, + hash TEXT default "" not null, + utcDateCreated TEXT not null, + isDeleted INT +); +CREATE TABLE IF NOT EXISTS "branches" ( + `branchId` TEXT NOT NULL, + `noteId` TEXT NOT NULL, + `parentNoteId` TEXT NOT NULL, + `notePosition` INTEGER NOT NULL, + `prefix` TEXT, + `isExpanded` INTEGER NOT NULL DEFAULT 0, + `isDeleted` INTEGER NOT NULL DEFAULT 0, + `utcDateModified` TEXT NOT NULL, + utcDateCreated TEXT NOT NULL, + hash TEXT DEFAULT "" NOT NULL, + PRIMARY KEY(`branchId`)); +CREATE INDEX `IDX_branches_noteId` ON `branches` (`noteId`); +CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` (`noteId`,`parentNoteId`); +CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId); diff --git a/src/entities/branch.js b/src/entities/branch.js index deb2942e4..aa06c425b 100644 --- a/src/entities/branch.js +++ b/src/entities/branch.js @@ -42,7 +42,7 @@ class Branch extends Entity { async beforeSaving() { if (this.notePosition === undefined) { const maxNotePos = await sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [this.parentNoteId]); - this.notePosition = maxNotePos === null ? 0 : maxNotePos + 1; + this.notePosition = maxNotePos === null ? 0 : maxNotePos + 10; } if (!this.isDeleted) { diff --git a/src/public/javascripts/dialogs/attributes.js b/src/public/javascripts/dialogs/attributes.js index ee52f3ea6..651c9ddbe 100644 --- a/src/public/javascripts/dialogs/attributes.js +++ b/src/public/javascripts/dialogs/attributes.js @@ -47,14 +47,15 @@ function AttributesModel() { }; this.updateAttributePositions = function() { - let position = 0; + let position = 10; // we need to update positions by searching in the DOM, because order of the // attributes in the viewmodel (self.ownedAttributes()) stays the same $ownedAttributesBody.find('input[name="position"]').each(function() { const attribute = self.getTargetAttribute(this); - attribute().position = position++; + attribute().position = position; + position += 10; }); }; diff --git a/src/public/javascripts/services/tree.js b/src/public/javascripts/services/tree.js index 971b6aaff..df2c148a7 100644 --- a/src/public/javascripts/services/tree.js +++ b/src/public/javascripts/services/tree.js @@ -845,6 +845,18 @@ $tree.on('mousedown', '.fancytree-title', e => { } }); +async function duplicateNote(noteId, parentNoteId) { + const {note} = await server.post(`notes/${noteId}/duplicate/${parentNoteId}`); + + await reload(); + + await activateNote(note.noteId); + + const origNote = await treeCache.getNote(noteId); + infoService.showMessage(`Note "${origNote.title}" has been duplicated`); +} + + utils.bindGlobalShortcut('alt+c', () => collapseTree()); // don't use shortened form since collapseTree() accepts argument $collapseTreeButton.click(() => collapseTree()); @@ -882,5 +894,6 @@ export default { resolveNotePath, getSomeNotePath, focusTree, - scrollToActiveNote + scrollToActiveNote, + duplicateNote }; \ No newline at end of file diff --git a/src/public/javascripts/services/tree_context_menu.js b/src/public/javascripts/services/tree_context_menu.js index aa636882b..bb2e6317d 100644 --- a/src/public/javascripts/services/tree_context_menu.js +++ b/src/public/javascripts/services/tree_context_menu.js @@ -8,6 +8,7 @@ import syncService from "./sync.js"; import hoistedNoteService from './hoisted_note.js'; import noteDetailService from './note_detail.js'; import clipboard from './clipboard.js'; +import protectedSessionHolder from "./protected_session_holder.js"; class TreeContextMenu { constructor(node) { @@ -68,6 +69,8 @@ class TreeContextMenu { enabled: !clipboard.isEmpty() && note.type !== 'search' && noSelectedNotes }, { title: "Paste after", cmd: "pasteAfter", uiIcon: "clipboard", enabled: !clipboard.isEmpty() && isNotRoot && parentNote.type !== 'search' && noSelectedNotes }, + { title: "Duplicate note here", cmd: "duplicateNote", uiIcon: "empty", + enabled: noSelectedNotes && (!note.isProtected || protectedSessionHolder.isProtectedSessionAvailable()) }, { title: "----" }, { title: "Export", cmd: "export", uiIcon: "empty", enabled: note.type !== 'search' && noSelectedNotes }, @@ -152,6 +155,11 @@ class TreeContextMenu { else if (cmd === "unhoist") { hoistedNoteService.unhoist(); } + else if (cmd === "duplicateNote") { + const branch = await treeCache.getBranch(this.node.data.branchId); + + treeService.duplicateNote(this.node.data.noteId, branch.parentNoteId); + } else { ws.logError("Unknown command: " + cmd); } diff --git a/src/routes/api/branches.js b/src/routes/api/branches.js index 2c638f1e9..7538d37c2 100644 --- a/src/routes/api/branches.js +++ b/src/routes/api/branches.js @@ -14,8 +14,7 @@ const TaskContext = require('../../services/task_context'); */ async function moveBranchToParent(req) { - const branchId = req.params.branchId; - const parentNoteId = req.params.parentNoteId; + const {branchId, parentNoteId} = req.params; const noteToMove = await tree.getBranch(branchId); @@ -26,7 +25,7 @@ async function moveBranchToParent(req) { } const maxNotePos = await sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [parentNoteId]); - const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1; + const newNotePos = maxNotePos === null ? 0 : maxNotePos + 10; const branch = await repository.getBranch(branchId); branch.parentNoteId = parentNoteId; @@ -37,8 +36,7 @@ async function moveBranchToParent(req) { } async function moveBranchBeforeNote(req) { - const branchId = req.params.branchId; - const beforeBranchId = req.params.beforeBranchId; + const {branchId, beforeBranchId} = req.params; const noteToMove = await tree.getBranch(branchId); const beforeNote = await tree.getBranch(beforeBranchId); @@ -51,7 +49,7 @@ async function moveBranchBeforeNote(req) { // we don't change utcDateModified so other changes are prioritized in case of conflict // also we would have to sync all those modified branches otherwise hash checks would fail - await sql.execute("UPDATE branches SET notePosition = notePosition + 1 WHERE parentNoteId = ? AND notePosition >= ? AND isDeleted = 0", + await sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition >= ? AND isDeleted = 0", [beforeNote.parentNoteId, beforeNote.notePosition]); await sync_table.addNoteReorderingSync(beforeNote.parentNoteId); @@ -65,8 +63,7 @@ async function moveBranchBeforeNote(req) { } async function moveBranchAfterNote(req) { - const branchId = req.params.branchId; - const afterBranchId = req.params.afterBranchId; + const {branchId, afterBranchId} = req.params; const noteToMove = await tree.getBranch(branchId); const afterNote = await tree.getBranch(afterBranchId); @@ -79,22 +76,21 @@ async function moveBranchAfterNote(req) { // we don't change utcDateModified so other changes are prioritized in case of conflict // also we would have to sync all those modified branches otherwise hash checks would fail - await sql.execute("UPDATE branches SET notePosition = notePosition + 1 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", + await sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, afterNote.notePosition]); await sync_table.addNoteReorderingSync(afterNote.parentNoteId); const branch = await repository.getBranch(branchId); branch.parentNoteId = afterNote.parentNoteId; - branch.notePosition = afterNote.notePosition + 1; + branch.notePosition = afterNote.notePosition + 10; await branch.save(); return { success: true }; } async function setExpanded(req) { - const branchId = req.params.branchId; - const expanded = req.params.expanded; + const {branchId, expanded} = req.params; await sql.execute("UPDATE branches SET isExpanded = ? WHERE branchId = ?", [expanded, branchId]); // we don't sync expanded label diff --git a/src/routes/api/notes.js b/src/routes/api/notes.js index c9ee09b42..c06542111 100644 --- a/src/routes/api/notes.js +++ b/src/routes/api/notes.js @@ -180,6 +180,12 @@ async function changeTitle(req) { await note.save(); } +async function duplicateNote(req) { + const {noteId, parentNoteId} = req.params; + + return await noteService.duplicateNote(noteId, parentNoteId); +} + module.exports = { getNote, updateNote, @@ -190,5 +196,6 @@ module.exports = { setNoteTypeMime, getChildren, getRelationMap, - changeTitle + changeTitle, + duplicateNote }; \ No newline at end of file diff --git a/src/routes/routes.js b/src/routes/routes.js index 3f48807f4..0a3297248 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -135,6 +135,7 @@ function register(app) { apiRoute(GET, '/api/notes/:noteId/revision-list', noteRevisionsApiRoute.getNoteRevisionList); apiRoute(POST, '/api/notes/relation-map', notesApiRoute.getRelationMap); apiRoute(PUT, '/api/notes/:noteId/change-title', notesApiRoute.changeTitle); + apiRoute(POST, '/api/notes/:noteId/duplicate/:parentNoteId', notesApiRoute.duplicateNote); apiRoute(GET, '/api/edited-notes/:date', noteRevisionsApiRoute.getEditedNotesOnDate); diff --git a/src/services/app_info.js b/src/services/app_info.js index e490bf77a..4c372f9ce 100644 --- a/src/services/app_info.js +++ b/src/services/app_info.js @@ -4,7 +4,7 @@ const build = require('./build'); const packageJson = require('../../package'); const {TRILIUM_DATA_DIR} = require('./data_dir'); -const APP_DB_VERSION = 147; +const APP_DB_VERSION = 149; const SYNC_VERSION = 10; const CLIPPER_PROTOCOL_VERSION = "1.0"; diff --git a/src/services/cloning.js b/src/services/cloning.js index 9f25c9e58..5d415edc6 100644 --- a/src/services/cloning.js +++ b/src/services/cloning.js @@ -81,7 +81,7 @@ async function cloneNoteAfter(noteId, afterBranchId) { // we don't change utcDateModified so other changes are prioritized in case of conflict // also we would have to sync all those modified branches otherwise hash checks would fail - await sql.execute("UPDATE branches SET notePosition = notePosition + 1 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", + await sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, afterNote.notePosition]); await syncTable.addNoteReorderingSync(afterNote.parentNoteId); @@ -89,7 +89,7 @@ async function cloneNoteAfter(noteId, afterBranchId) { const branch = await new Branch({ noteId: noteId, parentNoteId: afterNote.parentNoteId, - notePosition: afterNote.notePosition + 1, + notePosition: afterNote.notePosition + 10, isExpanded: 0 }).save(); diff --git a/src/services/consistency_checks.js b/src/services/consistency_checks.js index e261ea88e..9393b6224 100644 --- a/src/services/consistency_checks.js +++ b/src/services/consistency_checks.js @@ -198,7 +198,7 @@ async function findExistencyIssues() { const branches = await repository.getEntities(`SELECT * FROM branches WHERE noteId = ? and parentNoteId = ? and isDeleted = 1`, [noteId, parentNoteId]); // it's not necessarily "original" branch, it's just the only one which will survive - const origBranch = branches.get(0); + const origBranch = branches[0]; // delete all but the first branch for (const branch of branches.slice(1)) { diff --git a/src/services/notes.js b/src/services/notes.js index 7678c7a23..fcc34f030 100644 --- a/src/services/notes.js +++ b/src/services/notes.js @@ -21,15 +21,15 @@ async function getNewNotePosition(parentNoteId, noteData) { if (noteData.target === 'into') { const maxNotePos = await sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [parentNoteId]); - newNotePos = maxNotePos === null ? 0 : maxNotePos + 1; + newNotePos = maxNotePos === null ? 0 : maxNotePos + 10; } else if (noteData.target === 'after') { const afterNote = await sql.getRow('SELECT notePosition FROM branches WHERE branchId = ?', [noteData.target_branchId]); - newNotePos = afterNote.notePosition + 1; + newNotePos = afterNote.notePosition + 10; // not updating utcDateModified to avoig having to sync whole rows - await sql.execute('UPDATE branches SET notePosition = notePosition + 1 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0', + await sql.execute('UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0', [parentNoteId, afterNote.notePosition]); await syncTableService.addNoteReorderingSync(parentNoteId); @@ -465,6 +465,44 @@ async function cleanupDeletedNotes() { await sql.execute("UPDATE note_revisions SET content = NULL WHERE note_revisions.content IS NOT NULL AND noteId IN (SELECT noteId FROM notes WHERE isDeleted = 1 AND notes.utcDateModified <= ?)", [dateUtils.utcDateStr(cutoffDate)]); } +async function duplicateNote(noteId, parentNoteId) { + const origNote = await repository.getNote(noteId); + + if (origNote.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { + throw new Error(`Cannot duplicate note=${origNote.noteId} because it is protected and protected session is not available`); + } + + // might be null if orig note is not in the target parentNoteId + const origBranch = (await origNote.getBranches()).find(branch => branch.parentNoteId === parentNoteId); + + const newNote = new Note(origNote); + newNote.noteId = undefined; // force creation of new note + newNote.title += " (dup)"; + + await newNote.save(); + await newNote.setContent(await origNote.getContent()); + + const newBranch = await new Branch({ + noteId: newNote.noteId, + parentNoteId: parentNoteId, + // here increasing just by 1 to make sure it's directly after original + notePosition: origBranch ? origBranch.notePosition + 1 : null + }).save(); + + for (const attribute of await origNote.getAttributes()) { + const attr = new Attribute(attribute); + attr.attributeId = undefined; // force creation of new attribute + attr.noteId = newNote.noteId; + + await attr.save(); + } + + return { + note: newNote, + branch: newBranch + }; +} + sqlInit.dbReady.then(() => { // first cleanup kickoff 5 minutes after startup setTimeout(cls.wrap(cleanupDeletedNotes), 5 * 60 * 1000); @@ -478,5 +516,6 @@ module.exports = { updateNote, deleteBranch, protectNoteRecursively, - scanForLinks + scanForLinks, + duplicateNote }; \ No newline at end of file diff --git a/src/services/sql_init.js b/src/services/sql_init.js index f0844c528..fe62cb8ad 100644 --- a/src/services/sql_init.js +++ b/src/services/sql_init.js @@ -109,7 +109,7 @@ async function createInitialDatabase(username, password, theme) { noteId: 'root', parentNoteId: 'none', isExpanded: true, - notePosition: 0 + notePosition: 10 }).save(); const dummyTaskContext = new TaskContext("1", 'import', false); diff --git a/src/services/tree.js b/src/services/tree.js index 7caebde9c..ca2459ac7 100644 --- a/src/services/tree.js +++ b/src/services/tree.js @@ -110,13 +110,13 @@ async function sortNotesAlphabetically(parentNoteId, directoriesFirst = false) { } }); - let position = 1; + let position = 10; for (const note of notes) { await sql.execute("UPDATE branches SET notePosition = ? WHERE branchId = ?", [position, note.branchId]); - position++; + position += 10; } await syncTableService.addNoteReorderingSync(parentNoteId);