diff --git a/public/javascripts/note.js b/public/javascripts/note.js index 3670ae498..0ee125f91 100644 --- a/public/javascripts/note.js +++ b/public/javascripts/note.js @@ -113,6 +113,7 @@ function saveNoteToServer(note, callback) { } let globalCurrentNote; +let globalCurrentNoteLoadTime; function createNewTopLevelNote() { let rootNode = globalTree.fancytree("getRootNode"); @@ -193,6 +194,7 @@ function setNoteBackgroundIfEncrypted(note) { function loadNoteToEditor(noteId) { $.get(baseApiUrl + 'notes/' + noteId).then(note => { globalCurrentNote = note; + globalCurrentNoteLoadTime = Math.floor(new Date().getTime() / 1000); if (newNoteCreated) { newNoteCreated = false; diff --git a/public/javascripts/status.js b/public/javascripts/status.js index b9cab1c26..71104ef4d 100644 --- a/public/javascripts/status.js +++ b/public/javascripts/status.js @@ -1,14 +1,27 @@ function checkStatus() { $.ajax({ - url: baseApiUrl + 'status/' + globalFullLoadTime, - type: 'GET', + url: baseApiUrl + 'status', + type: 'POST', + contentType: "application/json", + data: JSON.stringify({ + treeLoadTime: globalTreeLoadTime, + currentNoteId: globalCurrentNote ? globalCurrentNote.detail.note_id : null, + currentNoteDateModified: globalCurrentNoteLoadTime + }), success: resp => { - if (resp.changed) { - window.location.reload(true); + if (resp.changedTree) { + loadTree().then(resp => { + console.log("Reloading tree because of background changes"); + + globalTree.fancytree('getTree').reload(resp.notes); + }); } - else { - $("#changesToPushCount").html(resp.changesToPushCount); + + if (resp.changedCurrentNote) { + alert("Current note has been changed in different window / computer. Please reload the application and resolve the conflict manually."); } + + $("#changesToPushCount").html(resp.changesToPushCount); }, statusCode: { 401: () => { @@ -24,4 +37,4 @@ function checkStatus() { }); } -setInterval(checkStatus, 10 * 1000); \ No newline at end of file +setInterval(checkStatus, 5 * 1000); \ No newline at end of file diff --git a/public/javascripts/tree.js b/public/javascripts/tree.js index 8bcc59c46..763846b0b 100644 --- a/public/javascripts/tree.js +++ b/public/javascripts/tree.js @@ -86,16 +86,107 @@ function setExpandedToServer(note_id, is_expanded) { let globalEncryptionSalt; let globalEncryptionSessionTimeout; let globalEncryptedDataKey; -let globalFullLoadTime; +let globalTreeLoadTime; -$(() => { - $.get(baseApiUrl + 'tree').then(resp => { +function initFancyTree(notes, startNoteId) { + globalTree.fancytree({ + autoScroll: true, + extensions: ["hotkeys", "filter", "dnd"], + source: notes, + scrollParent: $("#tree"), + activate: (event, data) => { + const node = data.node.data; + + saveNoteIfChanged(() => loadNoteToEditor(node.note_id)); + }, + expand: (event, data) => { + setExpandedToServer(data.node.key, true); + }, + collapse: (event, data) => { + setExpandedToServer(data.node.key, false); + }, + init: (event, data) => { + if (startNoteId) { + data.tree.activateKey(startNoteId); + } + + $(window).resize(); + }, + hotkeys: { + keydown: keybindings + }, + filter: { + autoApply: true, // Re-apply last filter if lazy data is loaded + autoExpand: true, // Expand all branches that contain matches while filtered + counter: false, // Show a badge with number of matching child nodes near parent icons + fuzzy: false, // Match single characters in order, e.g. 'fb' will match 'FooBar' + hideExpandedCounter: true, // Hide counter badge if parent is expanded + hideExpanders: false, // Hide expanders if all child nodes are hidden by filter + highlight: true, // Highlight matches by wrapping inside tags + leavesOnly: false, // Match end nodes only + nodata: true, // Display a 'no data' status node if result is empty + 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 + // copyPaste("copy", node); + // return false; + // } + // break; + case 86: + console.log("CTRL-V"); + + if (event.ctrlKey) { // Ctrl-V + pasteAfter(node); + return false; + } + break; + case 88: + console.log("CTRL-X"); + + if (event.ctrlKey) { // Ctrl-X + cut(node); + return false; + } + break; + } + } + }); + + globalTree.contextmenu(contextMenuSetup); +} + +function loadTree() { + return $.get(baseApiUrl + 'tree').then(resp => { const notes = resp.notes; let startNoteId = resp.start_note_id; globalEncryptionSalt = resp.password_derived_key_salt; globalEncryptionSessionTimeout = resp.encryption_session_timeout; globalEncryptedDataKey = resp.encrypted_data_key; - globalFullLoadTime = resp.full_load_time; + globalTreeLoadTime = resp.tree_load_time; // add browser ID header to all AJAX requests $.ajaxSetup({ @@ -108,94 +199,16 @@ $(() => { prepareNoteTree(notes); - globalTree.fancytree({ - autoScroll: true, - extensions: ["hotkeys", "filter", "dnd"], - source: notes, - scrollParent: $("#tree"), - activate: (event, data) => { - const node = data.node.data; + return { + notes: notes, + startNoteId: startNoteId + }; + }); +} - saveNoteIfChanged(() => loadNoteToEditor(node.note_id)); - }, - expand: (event, data) => { - setExpandedToServer(data.node.key, true); - }, - collapse: (event, data) => { - setExpandedToServer(data.node.key, false); - }, - init: (event, data) => { - if (startNoteId) { - data.tree.activateKey(startNoteId); - } - - $(window).resize(); - }, - hotkeys: { - keydown: keybindings - }, - filter: { - autoApply: true, // Re-apply last filter if lazy data is loaded - autoExpand: true, // Expand all branches that contain matches while filtered - counter: false, // Show a badge with number of matching child nodes near parent icons - fuzzy: false, // Match single characters in order, e.g. 'fb' will match 'FooBar' - hideExpandedCounter: true, // Hide counter badge if parent is expanded - hideExpanders: false, // Hide expanders if all child nodes are hidden by filter - highlight: true, // Highlight matches by wrapping inside tags - leavesOnly: false, // Match end nodes only - nodata: true, // Display a 'no data' status node if result is empty - 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 - // copyPaste("copy", node); - // return false; - // } - // break; - case 86: - console.log("CTRL-V"); - - if (event.ctrlKey) { // Ctrl-V - pasteAfter(node); - return false; - } - break; - case 88: - console.log("CTRL-X"); - - if (event.ctrlKey) { // Ctrl-X - cut(node); - return false; - } - break; - } - } - }); - - globalTree.contextmenu(contextMenuSetup); +$(() => { + loadTree().then(resp => { + initFancyTree(resp.notes, resp.startNoteId); }); }); diff --git a/routes/api/status.js b/routes/api/status.js index 58d32304c..ed9f59c50 100644 --- a/routes/api/status.js +++ b/routes/api/status.js @@ -4,20 +4,34 @@ const express = require('express'); const router = express.Router(); const sql = require('../../services/sql'); const auth = require('../../services/auth'); +const sync = require('../../services/sync'); +const audit_category = require('../../services/audit_category'); -router.get('/:full_load_time', auth.checkApiAuth, async (req, res, next) => { - const fullLoadTime = req.params.full_load_time; +router.post('', auth.checkApiAuth, async (req, res, next) => { + const treeLoadTime = req.body.treeLoadTime; + const currentNoteId = req.body.currentNoteId; + const currentNoteDateModified = req.body.currentNoteDateModified; const browserId = req.get('x-browser-id'); - const rowCount = await sql.getSingleValue("SELECT COUNT(*) FROM audit_log WHERE (browser_id IS NULL OR browser_id != ?) " + - "AND date_modified >= ?", [browserId, fullLoadTime]); + const noteTreeChangesCount = await sql.getSingleValue("SELECT COUNT(*) FROM audit_log WHERE (browser_id IS NULL OR browser_id != ?) " + + "AND date_modified >= ? AND category IN (?, ?, ?)", [browserId, treeLoadTime, + audit_category.UPDATE_TITLE, audit_category.CHANGE_PARENT, audit_category.CHANGE_POSITION]); - const lastSyncedPush = await sql.getOption('last_synced_push'); - const changesToPushCount = await sql.getSingleValue("SELECT COUNT(*) FROM sync WHERE id > ?", [lastSyncedPush]); + const currentNoteChangesCount = await sql.getSingleValue("SELECT COUNT(*) FROM audit_log WHERE (browser_id IS NULL OR browser_id != ?) " + + "AND date_modified >= ? AND note_id = ? AND category IN (?)", [browserId, currentNoteDateModified, currentNoteId, + audit_category.UPDATE_CONTENT]); + + let changesToPushCount = 0; + + if (sync.isSyncSetup) { + const lastSyncedPush = await sql.getOption('last_synced_push'); + changesToPushCount = await sql.getSingleValue("SELECT COUNT(*) FROM sync WHERE id > ?", [lastSyncedPush]); + } res.send({ - 'changed': rowCount > 0, + 'changedTree': noteTreeChangesCount > 0, + 'changedCurrentNote': currentNoteChangesCount > 0, 'changesToPushCount': changesToPushCount }); }); diff --git a/routes/api/tree.js b/routes/api/tree.js index 718cf08bc..469bbe88d 100644 --- a/routes/api/tree.js +++ b/routes/api/tree.js @@ -49,7 +49,7 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => { 'encrypted_data_key': await sql.getOption('encrypted_data_key'), 'encryption_session_timeout': await sql.getOption('encryption_session_timeout'), 'browser_id': utils.randomString(12), - 'full_load_time': utils.nowTimestamp() + 'tree_load_time': utils.nowTimestamp() }); }); diff --git a/services/sql.js b/services/sql.js index 5851ac829..854d290f5 100644 --- a/services/sql.js +++ b/services/sql.js @@ -102,17 +102,24 @@ async function remove(tableName, noteId) { } async function addAudit(category, req=null, noteId=null, changeFrom=null, changeTo=null, comment=null) { - const now = utils.nowTimestamp(); - const browserId = req == null ? null : req.get('x-browser-id'); + await addAuditWithBrowserId(category, browserId, noteId, changeFrom, changeTo, comment); +} + +async function addSyncAudit(category, sourceId, noteId) { + await addAuditWithBrowserId(category, sourceId, noteId); +} + +async function addAuditWithBrowserId(category, browserId=null, noteId=null, changeFrom=null, changeTo=null, comment=null) { + const now = utils.nowTimestamp(); log.info("audit: " + category + ", browserId=" + browserId + ", noteId=" + noteId + ", from=" + changeFrom + ", to=" + changeTo + ", comment=" + comment); const id = utils.randomString(14); await execute("INSERT INTO audit_log (id, date_modified, category, browser_id, note_id, change_from, change_to, comment)" - + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [id, now, category, browserId, noteId, changeFrom, changeTo, comment]); + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [id, now, category, browserId, noteId, changeFrom, changeTo, comment]); } async function deleteRecentAudits(category, req, noteId) { @@ -191,6 +198,7 @@ module.exports = { getOption, setOption, addAudit, + addSyncAudit, deleteRecentAudits, remove, doInTransaction, diff --git a/services/sync.js b/services/sync.js index 4ec32e761..1f6c62a3e 100644 --- a/services/sync.js +++ b/services/sync.js @@ -7,8 +7,10 @@ const migration = require('./migration'); const utils = require('./utils'); const config = require('./config'); const SOURCE_ID = require('./source_id'); +const audit_category = require('./audit_category'); const SYNC_SERVER = config['Sync']['syncServerHost']; +const isSyncSetup = !!SYNC_SERVER; let syncInProgress = false; @@ -121,6 +123,8 @@ async function pushSync(cookieJar, syncLog) { logSyncError("Unrecognized entity type " + sync.entity_name, null, syncLog); } + logSync("Pushing changes in " + sync.entity_name + " " + sync.entity_id); + await pushEntity(entity, sync.entity_name, cookieJar, syncLog); lastSyncedPush = sync.id; @@ -232,6 +236,10 @@ async function updateNote(entity, links, sourceId, syncLog) { } await sql.addNoteSync(entity.note_id, sourceId); + + // we don't distinguish between those for now + await sql.addSyncAudit(audit_category.UPDATE_CONTENT, sourceId, entity.note_id); + await sql.addSyncAudit(audit_category.UPDATE_TITLE, sourceId, entity.note_id); }); logSync("Update/sync note " + entity.note_id, syncLog); @@ -249,6 +257,8 @@ async function updateNoteTree(entity, sourceId, syncLog) { await sql.replace('notes_tree', entity); await sql.addNoteTreeSync(entity.note_id, sourceId); + + await sql.addSyncAudit(audit_category.UPDATE_TITLE, sourceId, entity.note_id); }); logSync("Update/sync note tree " + entity.note_id, syncLog); @@ -270,7 +280,7 @@ async function updateNoteHistory(entity, sourceId, syncLog) { await sql.addNoteHistorySync(entity.note_history_id, sourceId); }); - logSync("Update/sync note history " + entity.note_id, syncLog); + logSync("Update/sync note history " + entity.note_history_id, syncLog); } else { logSync("Sync conflict in note history for " + entity.note_id + ", from=" + entity.date_modified_from + ", to=" + entity.date_modified_to, syncLog); @@ -293,5 +303,6 @@ module.exports = { sync, updateNote, updateNoteTree, - updateNoteHistory + updateNoteHistory, + isSyncSetup }; \ No newline at end of file