1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

All: Resolves #712: New: Support for note history (#1415)

* Started revisions support

* More rev changes

* More rev changes

* More revs changes

* Fixed deletion algorithm

* More tests and moved updated time to separate field

* Display info when restoring note

* Better handling of existing notes

* wip

* Further improvements and fixed tests

* Better handling of changes created via sync

* Enable chokidar again

* Testing special case

* Further improved logic to handle notes that existed before the revision service

* Added tests

* Better handling of encrypted revisions

* Improved handling of deleted note revisions by moving logic to collectRevision

* Improved handling of old notes by moving logic to collectRevision()

* Handle case when deleting revisions while one is still encrypted

* UI tweaks

* Added revision service to mobile app

* Fixed config screens on mobile and desktop

* Enabled revisions on CLI app
This commit is contained in:
Laurent Cozic 2019-05-06 21:35:29 +01:00 committed by GitHub
parent 9e2982992a
commit 08af9de190
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1873 additions and 243 deletions

View File

@ -23,6 +23,7 @@ const fs = require('fs-extra');
const { cliUtils } = require('./cli-utils.js'); const { cliUtils } = require('./cli-utils.js');
const Cache = require('lib/Cache'); const Cache = require('lib/Cache');
const WelcomeUtils = require('lib/WelcomeUtils'); const WelcomeUtils = require('lib/WelcomeUtils');
const RevisionService = require('lib/services/RevisionService');
class Application extends BaseApplication { class Application extends BaseApplication {
@ -423,6 +424,8 @@ class Application extends BaseApplication {
ResourceService.runInBackground(); ResourceService.runInBackground();
RevisionService.instance().runInBackground();
this.dispatch({ this.dispatch({
type: 'TAG_UPDATE_ALL', type: 'TAG_UPDATE_ALL',
items: tags, items: tags,

View File

@ -22,6 +22,7 @@ const Tag = require('lib/models/Tag.js');
const NoteTag = require('lib/models/NoteTag.js'); const NoteTag = require('lib/models/NoteTag.js');
const MasterKey = require('lib/models/MasterKey'); const MasterKey = require('lib/models/MasterKey');
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const Revision = require('lib/models/Revision.js');
const { Logger } = require('lib/logger.js'); const { Logger } = require('lib/logger.js');
const { FsDriverNode } = require('lib/fs-driver-node.js'); const { FsDriverNode } = require('lib/fs-driver-node.js');
const { shimInit } = require('lib/shim-init-node.js'); const { shimInit } = require('lib/shim-init-node.js');
@ -43,6 +44,7 @@ BaseItem.loadClass('Resource', Resource);
BaseItem.loadClass('Tag', Tag); BaseItem.loadClass('Tag', Tag);
BaseItem.loadClass('NoteTag', NoteTag); BaseItem.loadClass('NoteTag', NoteTag);
BaseItem.loadClass('MasterKey', MasterKey); BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision);
Setting.setConstant('appId', 'net.cozic.joplin-cli'); Setting.setConstant('appId', 'net.cozic.joplin-cli');
Setting.setConstant('appType', 'cli'); Setting.setConstant('appType', 'cli');

View File

@ -210,7 +210,7 @@
}, },
"readable-stream": { "readable-stream": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": { "requires": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
@ -560,6 +560,11 @@
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E=" "integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E="
}, },
"diff-match-patch": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.4.tgz",
"integrity": "sha512-Uv3SW8bmH9nAtHKaKSanOQmj2DnlH65fUpcrMdfdaOxUG02QQ4YGZ8AE7kKOMisF7UqvOlGKVYWRvezdncW9lg=="
},
"domexception": { "domexception": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",

View File

@ -33,6 +33,7 @@
"base-64": "^0.1.0", "base-64": "^0.1.0",
"compare-version": "^0.1.2", "compare-version": "^0.1.2",
"diacritics": "^1.3.0", "diacritics": "^1.3.0",
"diff-match-patch": "^1.0.4",
"es6-promise-pool": "^2.5.0", "es6-promise-pool": "^2.5.0",
"follow-redirects": "^1.2.4", "follow-redirects": "^1.2.4",
"form-data": "^2.1.4", "form-data": "^2.1.4",

View File

@ -31,6 +31,7 @@ npm test tests-build/models_Folder.js
npm test tests-build/models_ItemChange.js npm test tests-build/models_ItemChange.js
npm test tests-build/models_Note.js npm test tests-build/models_Note.js
npm test tests-build/models_Resource.js npm test tests-build/models_Resource.js
npm test tests-build/models_Revision.js
npm test tests-build/models_Setting.js npm test tests-build/models_Setting.js
npm test tests-build/models_Tag.js npm test tests-build/models_Tag.js
npm test tests-build/pathUtils.js npm test tests-build/pathUtils.js
@ -38,6 +39,7 @@ npm test tests-build/services_InteropService.js
npm test tests-build/services_ResourceService.js npm test tests-build/services_ResourceService.js
npm test tests-build/services_rest_Api.js npm test tests-build/services_rest_Api.js
npm test tests-build/services_SearchEngine.js npm test tests-build/services_SearchEngine.js
npm test tests-build/services_Revision.js
npm test tests-build/StringUtils.js npm test tests-build/StringUtils.js
npm test tests-build/synchronizer.js npm test tests-build/synchronizer.js
npm test tests-build/urlUtils.js npm test tests-build/urlUtils.js

View File

@ -25,8 +25,7 @@ describe('Encryption', function() {
beforeEach(async (done) => { beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1); await setupDatabaseAndSynchronizer(1);
//await setupDatabaseAndSynchronizer(2); await switchClient(1);
//await switchClient(1);
service = new EncryptionService(); service = new EncryptionService();
BaseItem.encryptionService_ = service; BaseItem.encryptionService_ = service;
Setting.setValue('encryption.enabled', true); Setting.setValue('encryption.enabled', true);

View File

@ -1,7 +1,7 @@
require('app-module-path').addPath(__dirname); require('app-module-path').addPath(__dirname);
const { time } = require('lib/time-utils.js'); const { time } = require('lib/time-utils.js');
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js'); const { asyncTest, fileContentEqual, revisionService, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
const SearchEngine = require('lib/services/SearchEngine'); const SearchEngine = require('lib/services/SearchEngine');
const ResourceService = require('lib/services/ResourceService'); const ResourceService = require('lib/services/ResourceService');
const ItemChangeUtils = require('lib/services/ItemChangeUtils'); const ItemChangeUtils = require('lib/services/ItemChangeUtils');
@ -34,19 +34,17 @@ describe('models_ItemChange', function() {
const resourceService = new ResourceService(); const resourceService = new ResourceService();
await searchEngine.syncTables(); await searchEngine.syncTables();
// If we run this now, it should not delete any change because // If we run this now, it should not delete any change because
// the resource service has not yet processed the change // the resource service has not yet processed the change
await ItemChangeUtils.deleteProcessedChanges(); await ItemChangeUtils.deleteProcessedChanges();
expect(await ItemChange.lastChangeId()).toBe(1); expect(await ItemChange.lastChangeId()).toBe(1);
await resourceService.indexNoteResources(); await resourceService.indexNoteResources();
// Now that the resource service has processed the change,
// the change can be deleted.
await ItemChangeUtils.deleteProcessedChanges(); await ItemChangeUtils.deleteProcessedChanges();
expect(await ItemChange.lastChangeId()).toBe(1);
await revisionService().collectRevisions();
await ItemChangeUtils.deleteProcessedChanges();
expect(await ItemChange.lastChangeId()).toBe(0); expect(await ItemChange.lastChangeId()).toBe(0);
})); }));

View File

@ -0,0 +1,71 @@
require('app-module-path').addPath(__dirname);
const { time } = require('lib/time-utils.js');
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const NoteTag = require('lib/models/NoteTag.js');
const Tag = require('lib/models/Tag.js');
const Revision = require('lib/models/Revision.js');
const BaseModel = require('lib/BaseModel.js');
const { shim } = require('lib/shim');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
describe('models_Revision', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should create patches of text and apply it', asyncTest(async () => {
const note1 = await Note.save({ body: 'my note\nsecond line' });
const patch = Revision.createTextPatch(note1.body, 'my new note\nsecond line');
const merged = Revision.applyTextPatch(note1.body, patch);
expect(merged).toBe('my new note\nsecond line');
}));
it('should create patches of objects and apply it', asyncTest(async () => {
const oldObject = {
one: '123',
two: '456',
three: '789',
};
const newObject = {
one: '123',
three: '999',
}
const patch = Revision.createObjectPatch(oldObject, newObject);
const merged = Revision.applyObjectPatch(oldObject, patch);
expect(JSON.stringify(merged)).toBe(JSON.stringify(newObject));
}));
it('should move target revision to the top', asyncTest(async () => {
const revs = [
{ id: '123' },
{ id: '456' },
{ id: '789' },
];
let newRevs;
newRevs = Revision.moveRevisionToTop({ id: '456' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('789');
expect(newRevs[2].id).toBe('456');
newRevs = Revision.moveRevisionToTop({ id: '789' }, revs);
expect(newRevs[0].id).toBe('123');
expect(newRevs[1].id).toBe('456');
expect(newRevs[2].id).toBe('789');
}));
});

View File

@ -0,0 +1,372 @@
require('app-module-path').addPath(__dirname);
const { time } = require('lib/time-utils.js');
const { asyncTest, fileContentEqual, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
const Folder = require('lib/models/Folder.js');
const Setting = require('lib/models/Setting.js');
const Note = require('lib/models/Note.js');
const NoteTag = require('lib/models/NoteTag.js');
const ItemChange = require('lib/models/ItemChange.js');
const Tag = require('lib/models/Tag.js');
const Revision = require('lib/models/Revision.js');
const BaseModel = require('lib/BaseModel.js');
const RevisionService = require('lib/services/RevisionService.js');
const { shim } = require('lib/shim');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
describe('services_Revision', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should create diff and rebuild notes', asyncTest(async () => {
const service = new RevisionService();
const n1_v1 = await Note.save({ title: '', author: 'testing' });
await service.collectRevisions();
await Note.save({ id: n1_v1.id, title: 'hello', author: 'testing' });
await service.collectRevisions();
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome', author: '' });
await service.collectRevisions();
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
expect(revisions.length).toBe(2);
expect(revisions[1].parent_id).toBe(revisions[0].id);
const rev1 = await service.revisionNote(revisions, 0);
expect(rev1.title).toBe('hello');
expect(rev1.author).toBe('testing');
const rev2 = await service.revisionNote(revisions, 1);
expect(rev2.title).toBe('hello welcome');
expect(rev2.author).toBe('');
await time.sleep(0.5);
await service.deleteOldRevisions(400);
const revisions2 = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
expect(revisions2.length).toBe(0);
}));
it('should delete old revisions (1 note, 2 rev)', asyncTest(async () => {
const service = new RevisionService();
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await service.collectRevisions();
await time.sleep(1);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await service.collectRevisions();
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id)).length).toBe(2);
await service.deleteOldRevisions(1000);
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
expect(revisions.length).toBe(1);
const rev1 = await service.revisionNote(revisions, 0);
expect(rev1.title).toBe('hello welcome');
}));
it('should delete old revisions (1 note, 3 rev)', asyncTest(async () => {
const service = new RevisionService();
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'one' });
await service.collectRevisions();
await time.sleep(1);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'one two' });
await service.collectRevisions();
await time.sleep(1);
const n1_v3 = await Note.save({ id: n1_v1.id, title: 'one two three' });
await service.collectRevisions();
{
await service.deleteOldRevisions(2000);
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
expect(revisions.length).toBe(2);
const rev1 = await service.revisionNote(revisions, 0);
expect(rev1.title).toBe('one two');
const rev2 = await service.revisionNote(revisions, 1);
expect(rev2.title).toBe('one two three');
}
{
await service.deleteOldRevisions(1000);
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
expect(revisions.length).toBe(1);
const rev1 = await service.revisionNote(revisions, 0);
expect(rev1.title).toBe('one two three');
}
}));
it('should delete old revisions (2 notes, 2 rev)', asyncTest(async () => {
const service = new RevisionService();
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'note 1' });
const n2_v0 = await Note.save({ title: '' });
const n2_v1 = await Note.save({ id: n2_v0.id, title: 'note 2' });
await service.collectRevisions();
await time.sleep(1);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'note 1 (v2)' });
const n2_v2 = await Note.save({ id: n2_v1.id, title: 'note 2 (v2)' });
await service.collectRevisions();
expect((await Revision.all()).length).toBe(4);
await service.deleteOldRevisions(1000);
{
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1_v1.id);
expect(revisions.length).toBe(1);
const rev1 = await service.revisionNote(revisions, 0);
expect(rev1.title).toBe('note 1 (v2)');
}
{
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n2_v1.id);
expect(revisions.length).toBe(1);
const rev1 = await service.revisionNote(revisions, 0);
expect(rev1.title).toBe('note 2 (v2)');
}
}));
it('should handle conflicts', asyncTest(async () => {
const service = new RevisionService();
// A conflict happens in this case:
// - Device 1 creates note1 (rev1)
// - Device 2 syncs and get note1
// - Device 1 modifies note1 (rev2)
// - Device 2 modifies note1 (rev3)
// When reconstructing the notes based on the revisions, we need to make sure it follow the right
// "path". For example, to reconstruct the note at rev2 it would be:
// rev1 => rev2
// To reconstruct the note at rev3 it would be:
// rev1 => rev3
// And not, for example, rev1 => rev2 => rev3
const n1_v1 = await Note.save({ title: 'hello' });
const noteId = n1_v1.id;
const rev1 = await service.createNoteRevision(n1_v1);
const n1_v2 = await Note.save({ id: noteId, title: 'hello Paul' });
const rev2 = await service.createNoteRevision(n1_v2, rev1.id);
const n1_v3 = await Note.save({ id: noteId, title: 'hello John' });
const rev3 = await service.createNoteRevision(n1_v3, rev1.id);
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
expect(revisions.length).toBe(3);
expect(revisions[1].parent_id).toBe(rev1.id);
expect(revisions[2].parent_id).toBe(rev1.id);
const revNote1 = await service.revisionNote(revisions, 0);
const revNote2 = await service.revisionNote(revisions, 1);
const revNote3 = await service.revisionNote(revisions, 2);
expect(revNote1.title).toBe('hello');
expect(revNote2.title).toBe('hello Paul');
expect(revNote3.title).toBe('hello John');
}));
it('should create a revision for notes that existed before the revision service, the first time it is saved', asyncTest(async () => {
const n1 = await Note.save({ title: 'hello' });
const noteId = n1.id;
await sleep(0.1);
// Simulate the revision service being installed now. There N1 is like an old
// note that had been created before the service existed.
Setting.setValue('revisionService.installedTime', Date.now());
// A revision is created the first time a note is overwritten with new content, and
// if this note doesn't already have an existing revision.
// This is mostly to handle old notes that existed before the revision service. If these
// old notes are changed, there's a chance it's accidental or due to some bug, so we
// want to preserve a revision just in case.
{
await Note.save({ id: noteId, title: 'hello 2' });
await revisionService().collectRevisions(); // Rev for old note created + Rev for new note
const all = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
expect(all.length).toBe(2);
const revNote1 = await revisionService().revisionNote(all, 0);
const revNote2 = await revisionService().revisionNote(all, 1);
expect(revNote1.title).toBe('hello');
expect(revNote2.title).toBe('hello 2');
}
// If the note is saved a third time, we don't automatically create a revision. One
// will be created x minutes later when the service collects revisions.
{
await Note.save({ id: noteId, title: 'hello 3' });
const all = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
expect(all.length).toBe(2);
}
}));
it('should create a revision for notes that get deleted (recyle bin)', asyncTest(async () => {
const n1 = await Note.save({ title: 'hello' });
const noteId = n1.id;
await Note.delete(noteId);
await revisionService().collectRevisions();
const all = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
expect(all.length).toBe(1);
const rev1 = await revisionService().revisionNote(all, 0);
expect(rev1.title).toBe('hello');
}));
it('should not create a revision for notes that get deleted if there is already a revision', asyncTest(async () => {
const n1 = await Note.save({ title: 'hello' });
await revisionService().collectRevisions();
const noteId = n1.id;
await Note.save({ id: noteId, title: 'hello Paul' });
await revisionService().collectRevisions(); // REV 1
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
await Note.delete(noteId);
// At this point there is no need to create a new revision for the deleted note
// because we already have the latest version as REV 1
await revisionService().collectRevisions();
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
}));
it('should not create a revision for new note the first time they are saved', asyncTest(async () => {
const n1 = await Note.save({ title: 'hello' });
{
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(revisions.length).toBe(0);
}
await revisionService().collectRevisions();
{
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(revisions.length).toBe(0);
}
}));
it('should abort collecting revisions when one of them is encrypted', asyncTest(async () => {
const n1 = await Note.save({ title: 'hello' }); // CHANGE 1
await revisionService().collectRevisions();
await Note.save({ id: n1.id, title: 'hello Ringo' }); // CHANGE 2
await revisionService().collectRevisions();
await Note.save({ id: n1.id, title: 'hello George' }); // CHANGE 3
await revisionService().collectRevisions();
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(revisions.length).toBe(2);
const encryptedRevId = revisions[0].id;
// Simulate receiving an encrypted revision
await Revision.save({ id: encryptedRevId, encryption_applied: 1 });
await Note.save({ id: n1.id, title: 'hello Paul' }); // CHANGE 4
await revisionService().collectRevisions();
// Although change 4 is a note update, check that it has not been processed
// by the collector, due to one of the revisions being encrypted.
expect(await ItemChange.lastChangeId()).toBe(4);
expect(Setting.value('revisionService.lastProcessedChangeId')).toBe(3);
// Simulate the revision being decrypted by DecryptionService
await Revision.save({ id: encryptedRevId, encryption_applied: 0 });
await revisionService().collectRevisions();
// Now that the revision has been decrypted, all the changes can be processed
expect(await ItemChange.lastChangeId()).toBe(4);
expect(Setting.value('revisionService.lastProcessedChangeId')).toBe(4);
}));
it('should not delete old revisions if one of them is still encrypted (1)', asyncTest(async () => {
// Test case 1: Two revisions and the first one is encrypted.
// Calling deleteOldRevisions() with low TTL, which means all revisions
// should be deleted, but they won't be due to the encrypted one.
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // REV 1
await time.sleep(0.1);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await revisionService().collectRevisions(); // REV 2
await time.sleep(0.1);
expect((await Revision.all()).length).toBe(2);
const revisions = await Revision.all();
await Revision.save({ id: revisions[0].id, encryption_applied: 1 });
await revisionService().deleteOldRevisions(0);
expect((await Revision.all()).length).toBe(2);
await Revision.save({ id: revisions[0].id, encryption_applied: 0 });
await revisionService().deleteOldRevisions(0);
expect((await Revision.all()).length).toBe(0);
}));
it('should not delete old revisions if one of them is still encrypted (2)', asyncTest(async () => {
// Test case 2: Two revisions and the first one is encrypted.
// Calling deleteOldRevisions() with higher TTL, which means the oldest
// revision should be deleted, but it won't be due to the encrypted one.
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // REV 1
await time.sleep(0.5);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await revisionService().collectRevisions(); // REV 2
expect((await Revision.all()).length).toBe(2);
const revisions = await Revision.all();
await Revision.save({ id: revisions[0].id, encryption_applied: 1 });
await revisionService().deleteOldRevisions(500);
expect((await Revision.all()).length).toBe(2);
}));
it('should not delete old revisions if one of them is still encrypted (3)', asyncTest(async () => {
// Test case 2: Two revisions and the second one is encrypted.
// Calling deleteOldRevisions() with higher TTL, which means the oldest
// revision should be deleted, but it won't be due to the encrypted one.
const n1_v0 = await Note.save({ title: '' });
const n1_v1 = await Note.save({ id: n1_v0.id, title: 'hello' });
await revisionService().collectRevisions(); // REV 1
await time.sleep(0.5);
const n1_v2 = await Note.save({ id: n1_v1.id, title: 'hello welcome' });
await revisionService().collectRevisions(); // REV 2
expect((await Revision.all()).length).toBe(2);
const revisions = await Revision.all();
await Revision.save({ id: revisions[1].id, encryption_applied: 1 });
await revisionService().deleteOldRevisions(500);
expect((await Revision.all()).length).toBe(2);
await Revision.save({ id: revisions[1].id, encryption_applied: 0 });
await revisionService().deleteOldRevisions(500);
expect((await Revision.all()).length).toBe(1);
}));
});

View File

@ -1,7 +1,7 @@
require('app-module-path').addPath(__dirname); require('app-module-path').addPath(__dirname);
const { time } = require('lib/time-utils.js'); const { time } = require('lib/time-utils.js');
const { setupDatabase, allSyncTargetItemsEncrypted, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, checkThrowAsync, asyncTest } = require('test-utils.js'); const { setupDatabase, allSyncTargetItemsEncrypted, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, checkThrowAsync, asyncTest } = require('test-utils.js');
const { shim } = require('lib/shim.js'); const { shim } = require('lib/shim.js');
const fs = require('fs-extra'); const fs = require('fs-extra');
const Folder = require('lib/models/Folder.js'); const Folder = require('lib/models/Folder.js');
@ -13,6 +13,7 @@ const { Database } = require('lib/database.js');
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const MasterKey = require('lib/models/MasterKey'); const MasterKey = require('lib/models/MasterKey');
const BaseItem = require('lib/models/BaseItem.js'); const BaseItem = require('lib/models/BaseItem.js');
const Revision = require('lib/models/Revision.js');
const BaseModel = require('lib/BaseModel.js'); const BaseModel = require('lib/BaseModel.js');
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js'); const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
const WelcomeUtils = require('lib/WelcomeUtils'); const WelcomeUtils = require('lib/WelcomeUtils');
@ -23,19 +24,40 @@ process.on('unhandledRejection', (reason, p) => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000 + 30000; // The first test is slow because the database needs to be built jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000 + 30000; // The first test is slow because the database needs to be built
async function allItems() { async function allNotesFolders() {
let folders = await Folder.all(); let folders = await Folder.all();
let notes = await Note.all(); let notes = await Note.all();
return folders.concat(notes); return folders.concat(notes);
} }
async function localItemsSameAsRemote(locals, expect) { async function remoteItemsByTypes(types) {
const list = await fileApi().list();
if (list.has_more) throw new Error('Not implemented!!!');
const files = list.items;
const output = [];
for (const file of files) {
const remoteContent = await fileApi().get(file.path);
const content = await BaseItem.unserialize(remoteContent);
if (types.indexOf(content.type_) < 0) continue;
output.push(content);
}
return output;
}
async function remoteNotesAndFolders() {
return remoteItemsByTypes([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER]);
}
async function remoteNotesFoldersResources() {
return remoteItemsByTypes([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER, BaseModel.TYPE_RESOURCE]);
}
async function localNotesFoldersSameAsRemote(locals, expect) {
let error = null; let error = null;
try { try {
let files = await fileApi().list(); const nf = await remoteNotesAndFolders();
files = files.items; expect(locals.length).toBe(nf.length);
expect(locals.length).toBe(files.length);
for (let i = 0; i < locals.length; i++) { for (let i = 0; i < locals.length; i++) {
let dbItem = locals[i]; let dbItem = locals[i];
@ -45,12 +67,6 @@ async function localItemsSameAsRemote(locals, expect) {
expect(!!remote).toBe(true); expect(!!remote).toBe(true);
if (!remote) continue; if (!remote) continue;
// if (syncTargetId() == SyncTargetRegistry.nameToId('filesystem')) {
// expect(remote.updated_time).toBe(Math.floor(dbItem.updated_time / 1000) * 1000);
// } else {
// expect(remote.updated_time).toBe(dbItem.updated_time);
// }
let remoteContent = await fileApi().get(path); let remoteContent = await fileApi().get(path);
remoteContent = dbItem.type_ == BaseModel.TYPE_NOTE ? await Note.unserialize(remoteContent) : await Folder.unserialize(remoteContent); remoteContent = dbItem.type_ == BaseModel.TYPE_NOTE ? await Note.unserialize(remoteContent) : await Folder.unserialize(remoteContent);
@ -82,11 +98,11 @@ describe('Synchronizer', function() {
let folder = await Folder.save({ title: "folder1" }); let folder = await Folder.save({ title: "folder1" });
await Note.save({ title: "un", parent_id: folder.id }); await Note.save({ title: "un", parent_id: folder.id });
let all = await allItems(); let all = await allNotesFolders();
await synchronizer().start(); await synchronizer().start();
await localItemsSameAsRemote(all, expect); await localNotesFoldersSameAsRemote(all, expect);
})); }));
it('should update remote items', asyncTest(async () => { it('should update remote items', asyncTest(async () => {
@ -96,10 +112,10 @@ describe('Synchronizer', function() {
await Note.save({ title: "un UPDATE", id: note.id }); await Note.save({ title: "un UPDATE", id: note.id });
let all = await allItems(); let all = await allNotesFolders();
await synchronizer().start(); await synchronizer().start();
await localItemsSameAsRemote(all, expect); await localNotesFoldersSameAsRemote(all, expect);
})); }));
it('should create local items', asyncTest(async () => { it('should create local items', asyncTest(async () => {
@ -111,9 +127,9 @@ describe('Synchronizer', function() {
await synchronizer().start(); await synchronizer().start();
let all = await allItems(); let all = await allNotesFolders();
await localItemsSameAsRemote(all, expect); await localNotesFoldersSameAsRemote(all, expect);
})); }));
it('should update local items', asyncTest(async () => { it('should update local items', asyncTest(async () => {
@ -138,9 +154,9 @@ describe('Synchronizer', function() {
await synchronizer().start(); await synchronizer().start();
let all = await allItems(); let all = await allNotesFolders();
await localItemsSameAsRemote(all, expect); await localNotesFoldersSameAsRemote(all, expect);
})); }));
it('should resolve note conflicts', asyncTest(async () => { it('should resolve note conflicts', asyncTest(async () => {
@ -232,11 +248,9 @@ describe('Synchronizer', function() {
await synchronizer().start(); await synchronizer().start();
let files = await fileApi().list(); const remotes = await remoteNotesAndFolders();
files = files.items; expect(remotes.length).toBe(1);
expect(remotes[0].id).toBe(folder1.id);
expect(files.length).toBe(1);
expect(files[0].path).toBe(Folder.systemPath(folder1));
let deletedItems = await BaseItem.deletedItems(syncTargetId()); let deletedItems = await BaseItem.deletedItems(syncTargetId());
expect(deletedItems.length).toBe(0); expect(deletedItems.length).toBe(0);
@ -279,7 +293,7 @@ describe('Synchronizer', function() {
await switchClient(1); await switchClient(1);
context1 = await synchronizer().start({ context: context1 }); context1 = await synchronizer().start({ context: context1 });
let items = await allItems(); let items = await allNotesFolders();
expect(items.length).toBe(2); expect(items.length).toBe(2);
let deletedItems = await BaseItem.deletedItems(syncTargetId()); let deletedItems = await BaseItem.deletedItems(syncTargetId());
expect(deletedItems.length).toBe(0); expect(deletedItems.length).toBe(0);
@ -302,8 +316,8 @@ describe('Synchronizer', function() {
await synchronizer().start(); await synchronizer().start();
let all = await allItems(); let all = await allNotesFolders();
await localItemsSameAsRemote(all, expect); await localNotesFoldersSameAsRemote(all, expect);
})); }));
it('should delete local folder', asyncTest(async () => { it('should delete local folder', asyncTest(async () => {
@ -320,8 +334,8 @@ describe('Synchronizer', function() {
await switchClient(1); await switchClient(1);
await synchronizer().start({ context: context1 }); await synchronizer().start({ context: context1 });
let items = await allItems(); let items = await allNotesFolders();
await localItemsSameAsRemote(items, expect); await localNotesFoldersSameAsRemote(items, expect);
})); }));
it('should resolve conflict if remote folder has been deleted, but note has been added to folder locally', asyncTest(async () => { it('should resolve conflict if remote folder has been deleted, but note has been added to folder locally', asyncTest(async () => {
@ -338,7 +352,7 @@ describe('Synchronizer', function() {
let note = await Note.save({ title: "note1", parent_id: folder1.id }); let note = await Note.save({ title: "note1", parent_id: folder1.id });
await synchronizer().start(); await synchronizer().start();
let items = await allItems(); let items = await allNotesFolders();
expect(items.length).toBe(1); expect(items.length).toBe(1);
expect(items[0].title).toBe('note1'); expect(items[0].title).toBe('note1');
expect(items[0].is_conflict).toBe(1); expect(items[0].is_conflict).toBe(1);
@ -360,11 +374,11 @@ describe('Synchronizer', function() {
await Note.delete(note.id); await Note.delete(note.id);
await synchronizer().start(); await synchronizer().start();
let items = await allItems(); let items = await allNotesFolders();
expect(items.length).toBe(1); expect(items.length).toBe(1);
expect(items[0].title).toBe('folder'); expect(items[0].title).toBe('folder');
await localItemsSameAsRemote(items, expect); await localNotesFoldersSameAsRemote(items, expect);
})); }));
it('should cross delete all folders', asyncTest(async () => { it('should cross delete all folders', asyncTest(async () => {
@ -393,13 +407,13 @@ describe('Synchronizer', function() {
await synchronizer().start(); await synchronizer().start();
let items2 = await allItems(); let items2 = await allNotesFolders();
await switchClient(1); await switchClient(1);
await synchronizer().start(); await synchronizer().start();
let items1 = await allItems(); let items1 = await allNotesFolders();
expect(items1.length).toBe(0); expect(items1.length).toBe(0);
expect(items1.length).toBe(items2.length); expect(items1.length).toBe(items2.length);
@ -462,7 +476,7 @@ describe('Synchronizer', function() {
await synchronizer().start(); await synchronizer().start();
let items = await allItems(); let items = await allNotesFolders();
expect(items.length).toBe(1); expect(items.length).toBe(1);
})); }));
@ -680,7 +694,7 @@ describe('Synchronizer', function() {
let disabledItems = await BaseItem.syncDisabledItems(syncTargetId()); let disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
expect(disabledItems.length).toBe(0); expect(disabledItems.length).toBe(0);
await Note.save({ id: noteId, title: "un mod", }); await Note.save({ id: noteId, title: "un mod", });
synchronizer().testingHooks_ = ['rejectedByTarget']; synchronizer().testingHooks_ = ['notesRejectedByTarget'];
await synchronizer().start(); await synchronizer().start();
synchronizer().testingHooks_ = []; synchronizer().testingHooks_ = [];
await synchronizer().start(); // Another sync to check that this item is now excluded from sync await synchronizer().start(); // Another sync to check that this item is now excluded from sync
@ -834,7 +848,7 @@ describe('Synchronizer', function() {
let resource1 = (await Resource.all())[0]; let resource1 = (await Resource.all())[0];
let resourcePath1 = Resource.fullPath(resource1); let resourcePath1 = Resource.fullPath(resource1);
await synchronizer().start(); await synchronizer().start();
expect((await fileApi().list()).items.length).toBe(3); expect((await remoteNotesFoldersResources()).length).toBe(3);
await switchClient(2); await switchClient(2);
@ -901,11 +915,10 @@ describe('Synchronizer', function() {
let allResources = await Resource.all(); let allResources = await Resource.all();
expect(allResources.length).toBe(1); expect(allResources.length).toBe(1);
let all = await fileApi().list(); let all = await fileApi().list();
expect(all.items.length).toBe(3); expect((await remoteNotesFoldersResources()).length).toBe(3);
await Resource.delete(resource1.id); await Resource.delete(resource1.id);
await synchronizer().start(); await synchronizer().start();
all = await fileApi().list(); expect((await remoteNotesFoldersResources()).length).toBe(2);
expect(all.items.length).toBe(2);
await switchClient(1); await switchClient(1);
@ -1036,11 +1049,11 @@ describe('Synchronizer', function() {
it('should create remote items with UTF-8 content', asyncTest(async () => { it('should create remote items with UTF-8 content', asyncTest(async () => {
let folder = await Folder.save({ title: "Fahrräder" }); let folder = await Folder.save({ title: "Fahrräder" });
await Note.save({ title: "Fahrräder", body: "Fahrräder", parent_id: folder.id }); await Note.save({ title: "Fahrräder", body: "Fahrräder", parent_id: folder.id });
let all = await allItems(); let all = await allNotesFolders();
await synchronizer().start(); await synchronizer().start();
await localItemsSameAsRemote(all, expect); await localNotesFoldersSameAsRemote(all, expect);
})); }));
it("should update remote items but not pull remote changes", asyncTest(async () => { it("should update remote items but not pull remote changes", asyncTest(async () => {
@ -1058,7 +1071,7 @@ describe('Synchronizer', function() {
await Note.save({ title: "un UPDATE", id: note.id }); await Note.save({ title: "un UPDATE", id: note.id });
await synchronizer().start({ syncSteps: ["update_remote"] }); await synchronizer().start({ syncSteps: ["update_remote"] });
let all = await allItems(); let all = await allNotesFolders();
expect(all.length).toBe(2); expect(all.length).toBe(2);
await switchClient(2); await switchClient(2);
@ -1110,4 +1123,133 @@ describe('Synchronizer', function() {
expect(tags.length).toBe(2); expect(tags.length).toBe(2);
})); }));
it("should not save revisions when updating a note via sync", asyncTest(async () => {
// When a note is updated, a revision of the original is created.
// Here, on client 1, the note is updated for the first time, however since it is
// via sync, we don't create a revision - that revision has already been created on client
// 2 and is going to be synced.
const n1 = await Note.save({ title: 'testing' });
await synchronizer().start();
await switchClient(2);
await synchronizer().start();
await Note.save({ id: n1.id, title: 'mod from client 2' });
await revisionService().collectRevisions();
const allRevs1 = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(allRevs1.length).toBe(1);
await synchronizer().start();
await switchClient(1);
await synchronizer().start();
const allRevs2 = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(allRevs2.length).toBe(1);
expect(allRevs2[0].id).toBe(allRevs1[0].id);
}));
it("should not save revisions when deleting a note via sync", asyncTest(async () => {
const n1 = await Note.save({ title: 'testing' });
await synchronizer().start();
await switchClient(2);
await synchronizer().start();
await Note.delete(n1.id);
await revisionService().collectRevisions(); // REV 1
{
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(allRevs.length).toBe(1);
}
await synchronizer().start();
await switchClient(1);
await synchronizer().start(); // The local note gets deleted here, however a new rev is *not* created
{
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(allRevs.length).toBe(1);
}
const notes = await Note.all();
expect(notes.length).toBe(0);
}));
it("should not save revisions when an item_change has been generated as a result of a sync", asyncTest(async () => {
// When a note is modified an item_change object is going to be created. This
// is used for example to tell the search engine, when note should be indexed. It is
// also used by the revision service to tell what note should get a new revision.
// When a note is modified via sync, this item_change object is also created. The issue
// is that we don't want to create revisions for these particular item_changes, because
// such revision has already been created on another client (whatever client initially
// modified the note), and that rev is going to be synced.
//
// So in the end we need to make sure that we don't create these unecessary additional revisions.
const n1 = await Note.save({ title: 'testing' });
await synchronizer().start();
await switchClient(2);
await synchronizer().start();
await Note.save({ id: n1.id, title: 'mod from client 2' });
await revisionService().collectRevisions();
await synchronizer().start();
await switchClient(1);
await synchronizer().start();
{
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(allRevs.length).toBe(1);
}
await revisionService().collectRevisions();
{
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(allRevs.length).toBe(1);
}
}));
it("should handle case when new rev is created on client, then older rev arrives later via sync", asyncTest(async () => {
// - C1 creates note 1
// - C1 modifies note 1 - REV1 created
// - C1 sync
// - C2 sync
// - C2 receives note 1
// - C2 modifies note 1 - REV2 created (but not based on REV1)
// - C2 receives REV1
//
// In that case, we need to make sure that REV1 and REV2 are both valid and can be retrieved.
// Even though REV1 was created before REV2, REV2 is *not* based on REV1. This is not ideal
// due to unecessary data being saved, but a possible edge case and we simply need to check
// all the data is valid.
const n1 = await Note.save({ title: 'note' });
await Note.save({ id: n1.id, title: 'note REV1' });
await revisionService().collectRevisions(); // REV1
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
await synchronizer().start();
await switchClient(2);
synchronizer().testingHooks_ = ['skipRevisions'];
await synchronizer().start();
synchronizer().testingHooks_ = [];
await Note.save({ id: n1.id, title: 'note REV2' });
await revisionService().collectRevisions(); // REV2
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
await synchronizer().start(); // Sync the rev that had been skipped above with skipRevisions
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
expect(revisions.length).toBe(2);
expect((await revisionService().revisionNote(revisions, 0)).title).toBe('note REV1');
expect((await revisionService().revisionNote(revisions, 1)).title).toBe('note REV2');
}));
}); });

View File

@ -8,6 +8,7 @@ const ItemChange = require('lib/models/ItemChange.js');
const Resource = require('lib/models/Resource.js'); const Resource = require('lib/models/Resource.js');
const Tag = require('lib/models/Tag.js'); const Tag = require('lib/models/Tag.js');
const NoteTag = require('lib/models/NoteTag.js'); const NoteTag = require('lib/models/NoteTag.js');
const Revision = require('lib/models/Revision.js');
const { Logger } = require('lib/logger.js'); const { Logger } = require('lib/logger.js');
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const MasterKey = require('lib/models/MasterKey'); const MasterKey = require('lib/models/MasterKey');
@ -31,12 +32,14 @@ const SyncTargetDropbox = require('lib/SyncTargetDropbox.js');
const EncryptionService = require('lib/services/EncryptionService.js'); const EncryptionService = require('lib/services/EncryptionService.js');
const DecryptionWorker = require('lib/services/DecryptionWorker.js'); const DecryptionWorker = require('lib/services/DecryptionWorker.js');
const ResourceService = require('lib/services/ResourceService.js'); const ResourceService = require('lib/services/ResourceService.js');
const RevisionService = require('lib/services/RevisionService.js');
const WebDavApi = require('lib/WebDavApi'); const WebDavApi = require('lib/WebDavApi');
const DropboxApi = require('lib/DropboxApi'); const DropboxApi = require('lib/DropboxApi');
let databases_ = []; let databases_ = [];
let synchronizers_ = []; let synchronizers_ = [];
let encryptionServices_ = []; let encryptionServices_ = [];
let revisionServices_ = [];
let decryptionWorkers_ = []; let decryptionWorkers_ = [];
let resourceServices_ = []; let resourceServices_ = [];
let fileApi_ = null; let fileApi_ = null;
@ -82,6 +85,7 @@ BaseItem.loadClass('Resource', Resource);
BaseItem.loadClass('Tag', Tag); BaseItem.loadClass('Tag', Tag);
BaseItem.loadClass('NoteTag', NoteTag); BaseItem.loadClass('NoteTag', NoteTag);
BaseItem.loadClass('MasterKey', MasterKey); BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision);
Setting.setConstant('appId', 'net.cozic.joplin-cli'); Setting.setConstant('appId', 'net.cozic.joplin-cli');
Setting.setConstant('appType', 'cli'); Setting.setConstant('appType', 'cli');
@ -118,6 +122,7 @@ async function switchClient(id) {
BaseItem.encryptionService_ = encryptionServices_[id]; BaseItem.encryptionService_ = encryptionServices_[id];
Resource.encryptionService_ = encryptionServices_[id]; Resource.encryptionService_ = encryptionServices_[id];
BaseItem.revisionService_ = revisionServices_[id];
Setting.setConstant('resourceDir', resourceDir(id)); Setting.setConstant('resourceDir', resourceDir(id));
@ -129,21 +134,28 @@ async function clearDatabase(id = null) {
await ItemChange.waitForAllSaved(); await ItemChange.waitForAllSaved();
let queries = [ const tableNames = [
'DELETE FROM notes', 'notes',
'DELETE FROM folders', 'folders',
'DELETE FROM resources', 'resources',
'DELETE FROM tags', 'tags',
'DELETE FROM note_tags', 'note_tags',
'DELETE FROM master_keys', 'master_keys',
'DELETE FROM item_changes', 'item_changes',
'DELETE FROM note_resources', 'note_resources',
'DELETE FROM settings', 'settings',
'DELETE FROM deleted_items', 'deleted_items',
'DELETE FROM sync_items', 'sync_items',
'DELETE FROM notes_normalized', 'notes_normalized',
'revisions',
]; ];
const queries = [];
for (const n of tableNames) {
queries.push('DELETE FROM ' + n);
queries.push('DELETE FROM sqlite_sequence WHERE name="' + n + '"'); // Reset autoincremented IDs
}
await databases_[id].transactionExecBatch(queries); await databases_[id].transactionExecBatch(queries);
} }
@ -168,6 +180,7 @@ async function setupDatabase(id = null) {
}; };
databases_[id] = new JoplinDatabase(new DatabaseDriverNode()); databases_[id] = new JoplinDatabase(new DatabaseDriverNode());
databases_[id].setLogger(logger);
await databases_[id].open({ name: filePath }); await databases_[id].open({ name: filePath });
BaseModel.db_ = databases_[id]; BaseModel.db_ = databases_[id];
@ -200,6 +213,7 @@ async function setupDatabaseAndSynchronizer(id = null) {
} }
encryptionServices_[id] = new EncryptionService(); encryptionServices_[id] = new EncryptionService();
revisionServices_[id] = new RevisionService();
decryptionWorkers_[id] = new DecryptionWorker(); decryptionWorkers_[id] = new DecryptionWorker();
decryptionWorkers_[id].setEncryptionService(encryptionServices_[id]); decryptionWorkers_[id].setEncryptionService(encryptionServices_[id]);
resourceServices_[id] = new ResourceService(); resourceServices_[id] = new ResourceService();
@ -222,6 +236,11 @@ function encryptionService(id = null) {
return encryptionServices_[id]; return encryptionServices_[id];
} }
function revisionService(id = null) {
if (id === null) id = currentClient_;
return revisionServices_[id];
}
function decryptionWorker(id = null) { function decryptionWorker(id = null) {
if (id === null) id = currentClient_; if (id === null) id = currentClient_;
return decryptionWorkers_[id]; return decryptionWorkers_[id];
@ -354,4 +373,4 @@ async function allSyncTargetItemsEncrypted() {
return totalCount === encryptedCount; return totalCount === encryptedCount;
} }
module.exports = { resourceService, allSyncTargetItemsEncrypted, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest }; module.exports = { resourceService, allSyncTargetItemsEncrypted, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest };

View File

@ -81,7 +81,7 @@ class ElectronAppWrapper {
})) }))
// Uncomment this to view errors if the application does not start // Uncomment this to view errors if the application does not start
// if (this.env_ === 'dev') this.win_.webContents.openDevTools(); if (this.env_ === 'dev') this.win_.webContents.openDevTools();
this.win_.on('close', (event) => { this.win_.on('close', (event) => {
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true) // If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)

View File

@ -29,6 +29,7 @@ const { bridge } = require('electron').remote.require('./bridge');
const Menu = bridge().Menu; const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem; const MenuItem = bridge().MenuItem;
const PluginManager = require('lib/services/PluginManager'); const PluginManager = require('lib/services/PluginManager');
const RevisionService = require('lib/services/RevisionService');
const pluginClasses = [ const pluginClasses = [
require('./plugins/GotoAnything.min'), require('./plugins/GotoAnything.min'),
@ -1031,6 +1032,11 @@ class Application extends BaseApplication {
ExternalEditWatcher.instance().setLogger(reg.logger()); ExternalEditWatcher.instance().setLogger(reg.logger());
ExternalEditWatcher.instance().dispatch = this.store().dispatch; ExternalEditWatcher.instance().dispatch = this.store().dispatch;
RevisionService.instance().runInBackground();
// Make it available to the console window - useful to call revisionService.collectRevisions()
window.revisionService = RevisionService.instance();
} }
} }

View File

@ -265,9 +265,12 @@ class ConfigScreenComponent extends React.Component {
updateSettingValue(key, event.target.value); updateSettingValue(key, event.target.value);
}; };
const label = [md.label()];
if (md.unitLabel) label.push('(' + md.unitLabel() + ')');
return ( return (
<div key={key} style={rowStyle}> <div key={key} style={rowStyle}>
<div style={labelStyle}><label>{md.label()}</label></div> <div style={labelStyle}><label>{label.join(' ')}</label></div>
<input type="number" style={controlStyle} value={this.state.settings[key]} onChange={(event) => {onNumChange(event)}} min={md.minimum} max={md.maximum} step={md.step}/> <input type="number" style={controlStyle} value={this.state.settings[key]} onChange={(event) => {onNumChange(event)}} min={md.minimum} max={md.maximum} step={md.step}/>
{ descriptionComp } { descriptionComp }
</div> </div>

View File

@ -0,0 +1,39 @@
const React = require('react');
const { connect } = require('react-redux');
const { reg } = require('lib/registry.js');
const { themeStyle } = require('../theme.js');
const { _ } = require('lib/locale.js');
const { bridge } = require('electron').remote.require('./bridge');
class HelpButtonComponent extends React.Component {
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick() {
if (this.props.onClick) this.props.onClick();
}
render() {
const theme = themeStyle(this.props.theme);
let style = Object.assign({}, this.props.style, {color: theme.color, textDecoration: 'none'});
const helpIconStyle = {flex:0, width: 16, height: 16, marginLeft: 10};
const extraProps = {};
if (this.props.tip) extraProps['data-tip'] = this.props.tip;
return <a href="#" style={style} onClick={this.onClick} {...extraProps}><i style={helpIconStyle} className={"fa fa-question-circle"}></i></a>
}
}
const mapStateToProps = (state) => {
return {
theme: state.settings.theme,
};
};
const HelpButton = connect(mapStateToProps)(HelpButtonComponent);
module.exports = HelpButton;

View File

@ -227,6 +227,7 @@ class MainScreenComponent extends React.Component {
notePropertiesDialogOptions: { notePropertiesDialogOptions: {
noteId: command.noteId, noteId: command.noteId,
visible: true, visible: true,
onRevisionLinkClick: command.onRevisionLinkClick,
}, },
}); });
} else if (command.name === 'toggleVisiblePanes') { } else if (command.name === 'toggleVisiblePanes') {
@ -474,6 +475,7 @@ class MainScreenComponent extends React.Component {
theme={this.props.theme} theme={this.props.theme}
noteId={notePropertiesDialogOptions.noteId} noteId={notePropertiesDialogOptions.noteId}
onClose={this.notePropertiesDialog_close} onClose={this.notePropertiesDialog_close}
onRevisionLinkClick={notePropertiesDialogOptions.onRevisionLinkClick}
/> } /> }
<PromptDialog <PromptDialog

View File

@ -17,6 +17,7 @@ class NotePropertiesDialog extends React.Component {
this.okButton_click = this.okButton_click.bind(this); this.okButton_click = this.okButton_click.bind(this);
this.cancelButton_click = this.cancelButton_click.bind(this); this.cancelButton_click = this.cancelButton_click.bind(this);
this.onKeyDown = this.onKeyDown.bind(this); this.onKeyDown = this.onKeyDown.bind(this);
this.revisionsLink_click = this.revisionsLink_click.bind(this);
this.okButton = React.createRef(); this.okButton = React.createRef();
this.state = { this.state = {
@ -31,6 +32,7 @@ class NotePropertiesDialog extends React.Component {
user_updated_time: _('Updated'), user_updated_time: _('Updated'),
location: _('Location'), location: _('Location'),
source_url: _('URL'), source_url: _('URL'),
revisionsLink: _('Note History'),
}; };
} }
@ -79,6 +81,7 @@ class NotePropertiesDialog extends React.Component {
formNote.location = note.latitude + ', ' + note.longitude; formNote.location = note.latitude + ', ' + note.longitude;
} }
formNote.revisionsLink = note.id;
formNote.id = note.id; formNote.id = note.id;
return formNote; return formNote;
@ -102,26 +105,6 @@ class NotePropertiesDialog extends React.Component {
this.styles_ = {}; this.styles_ = {};
this.styleKey_ = styleKey; this.styleKey_ = styleKey;
// this.styles_.modalLayer = {
// zIndex: 9999,
// display: 'flex',
// position: 'absolute',
// top: 0,
// left: 0,
// width: '100%',
// height: '100%',
// backgroundColor: 'rgba(0,0,0,0.6)',
// alignItems: 'flex-start',
// justifyContent: 'center',
// };
// this.styles_.dialogBox = {
// backgroundColor: theme.backgroundColor,
// padding: 16,
// boxShadow: '6px 6px 20px rgba(0,0,0,0.5)',
// marginTop: 20,
// }
this.styles_.controlBox = { this.styles_.controlBox = {
marginBottom: '1em', marginBottom: '1em',
color: 'black', //This will apply for the calendar color: 'black', //This will apply for the calendar
@ -153,8 +136,6 @@ class NotePropertiesDialog extends React.Component {
borderColor: theme.dividerColor, borderColor: theme.dividerColor,
}; };
// this.styles_.dialogTitle = Object.assign({}, theme.h1Style, { marginBottom: '1.2em' });
return this.styles_; return this.styles_;
} }
@ -181,6 +162,11 @@ class NotePropertiesDialog extends React.Component {
this.closeDialog(false); this.closeDialog(false);
} }
revisionsLink_click() {
this.closeDialog(false);
if (this.props.onRevisionLinkClick) this.props.onRevisionLinkClick();
}
onKeyDown(event) { onKeyDown(event) {
if (event.keyCode === 13) { if (event.keyCode === 13) {
this.closeDialog(true); this.closeDialog(true);
@ -300,11 +286,13 @@ class NotePropertiesDialog extends React.Component {
url = Note.geoLocationUrlFromLatLong(ll.latitude, ll.longitude); url = Note.geoLocationUrlFromLatLong(ll.latitude, ll.longitude);
} }
controlComp = <a href="#" onClick={() => bridge().openExternal(url)} style={theme.urlStyle}>{displayedValue}</a> controlComp = <a href="#" onClick={() => bridge().openExternal(url)} style={theme.urlStyle}>{displayedValue}</a>
} else if (key === 'revisionsLink') {
controlComp = <a href="#" onClick={this.revisionsLink_click} style={theme.urlStyle}>{_('Previous versions of this note')}</a>
} else { } else {
controlComp = <div style={Object.assign({}, theme.textStyle, {display: 'inline-block'})}>{displayedValue}</div> controlComp = <div style={Object.assign({}, theme.textStyle, {display: 'inline-block'})}>{displayedValue}</div>
} }
if (key !== 'id') { if (key !== 'id' && key !== 'revisionsLink') {
editCompHandler = () => {this.editPropertyButtonClick(key, value)}; editCompHandler = () => {this.editPropertyButtonClick(key, value)};
editCompIcon = 'fa-edit'; editCompIcon = 'fa-edit';
} }

View File

@ -0,0 +1,175 @@
const React = require('react');
const { connect } = require('react-redux');
const { themeStyle } = require('../theme.js');
const { _ } = require('lib/locale.js');
const NoteTextViewer = require('./NoteTextViewer.min');
const HelpButton = require('./HelpButton.min');
const BaseModel = require('lib/BaseModel');
const Revision = require('lib/models/Revision');
const Setting = require('lib/models/Setting');
const RevisionService = require('lib/services/RevisionService');
const shared = require('lib/components/shared/note-screen-shared.js');
const MdToHtml = require('lib/MdToHtml');
const { time } = require('lib/time-utils.js');
const ReactTooltip = require('react-tooltip');
const { substrWithEllipsis } = require('lib/string-utils');
class NoteRevisionViewerComponent extends React.PureComponent {
constructor() {
super();
this.state = {
revisions: [],
currentRevId: '',
note: null,
restoring: false,
};
this.viewerRef_ = React.createRef();
this.viewer_domReady = this.viewer_domReady.bind(this);
this.revisionList_onChange = this.revisionList_onChange.bind(this);
this.importButton_onClick = this.importButton_onClick.bind(this);
this.backButton_click = this.backButton_click.bind(this);
}
style() {
const theme = themeStyle(this.props.theme);
let style = {
root: {
backgroundColor: theme.backgroundColor,
display: 'flex',
flex: 1,
flexDirection: 'column',
},
titleInput: Object.assign({}, theme.inputStyle, { flex: 1 }),
revisionList: Object.assign({}, theme.dropdownList, { marginLeft: 10, flex: 0.5 }),
};
return style;
}
async viewer_domReady() {
// this.viewerRef_.current.wrappedInstance.openDevTools();
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, this.props.noteId);
this.setState({
revisions: revisions,
currentRevId: revisions.length ? revisions[revisions.length - 1].id : '',
}, () => {
this.reloadNote();
});
}
async importButton_onClick() {
if (!this.state.note) return;
this.setState({ restoring: true });
await RevisionService.instance().importRevisionNote(this.state.note);
this.setState({ restoring: false });
alert(_('The note "%s" has been successfully restored to the notebook "%s".', substrWithEllipsis(this.state.note.title, 0, 32), RevisionService.instance().restoreFolderTitle()));
}
backButton_click() {
if (this.props.onBack) this.props.onBack();
}
revisionList_onChange(event) {
const value = event.target.value;
if (!value) {
if (this.props.onBack) this.props.onBack();
} else {
this.setState({
currentRevId: value,
}, () => {
this.reloadNote();
});
}
}
async reloadNote() {
let noteBody = '';
if (!this.state.revisions.length || !this.state.currentRevId) {
noteBody = _('This note has no history');
this.setState({ note: null });
} else {
const revIndex = BaseModel.modelIndexById(this.state.revisions, this.state.currentRevId);
const note = await RevisionService.instance().revisionNote(this.state.revisions, revIndex);
if (!note) return;
noteBody = note.body;
this.setState({ note: note });
}
const theme = themeStyle(this.props.theme);
const mdToHtml = new MdToHtml({
resourceBaseUrl: 'file://' + Setting.value('resourceDir') + '/',
});
const result = mdToHtml.render(noteBody, theme, {
codeTheme: theme.codeThemeCss,
userCss: this.props.customCss ? this.props.customCss : '',
resources: await shared.attachedResources(noteBody),
});
this.viewerRef_.current.wrappedInstance.send('setHtml', result.html, { cssFiles: result.cssFiles });
}
render() {
const theme = themeStyle(this.props.theme);
const style = this.style();
const revisionListItems = [];
const revs = this.state.revisions.slice().reverse();
for (let i = 0; i < revs.length; i++) {
const rev = revs[i];
revisionListItems.push(<option
key={rev.id}
value={rev.id}
>{time.formatMsToLocal(rev.updated_time)}</option>);
}
const restoreButtonTitle = _('Restore');
const helpMessage = _('Click "%s" to restore the note. It will be copied in the notebook named "%s". The current version of the note will not be replaced or modified.', restoreButtonTitle, RevisionService.instance().restoreFolderTitle());
const titleInput = (
<div style={{display:'flex', flexDirection: 'row', alignItems:'center', marginBottom: 10, borderWidth: 1, borderBottomStyle: 'solid', borderColor: theme.dividerColor, paddingBottom:10}}>
<button onClick={this.backButton_click} style={Object.assign({}, theme.buttonStyle, { marginRight: 10, height: theme.inputStyle.height })}>{'⬅ ' + _('Back')}</button>
<input readOnly type="text" style={style.titleInput} value={this.state.note ? this.state.note.title : ''}/>
<select disabled={!this.state.revisions.length} value={this.state.currentRevId} style={style.revisionList} onChange={this.revisionList_onChange}>
{revisionListItems}
</select>
<button disabled={!this.state.revisions.length || this.state.restoring} onClick={this.importButton_onClick} style={Object.assign({}, theme.buttonStyle, { marginLeft: 10, height: theme.inputStyle.height })}>{restoreButtonTitle}</button>
<HelpButton tip={helpMessage} id="noteRevisionHelpButton" onClick={this.helpButton_onClick}/>
</div>
);
const viewer = <NoteTextViewer
viewerStyle={{display:'flex', flex:1}}
ref={this.viewerRef_}
onDomReady={this.viewer_domReady}
/>
return (
<div style={style.root}>
{titleInput}
{viewer}
<ReactTooltip place="bottom" delayShow={300} className="help-tooltip"/>
</div>
);
}
}
const mapStateToProps = (state) => {
return {
theme: state.settings.theme,
};
};
const NoteRevisionViewer = connect(mapStateToProps)(NoteRevisionViewerComponent);
module.exports = NoteRevisionViewer;

View File

@ -38,6 +38,7 @@ const { clipboard } = require('electron');
const SearchEngine = require('lib/services/SearchEngine'); const SearchEngine = require('lib/services/SearchEngine');
const ModelCache = require('lib/services/ModelCache'); const ModelCache = require('lib/services/ModelCache');
const NoteTextViewer = require('./NoteTextViewer.min'); const NoteTextViewer = require('./NoteTextViewer.min');
const NoteRevisionViewer = require('./NoteRevisionViewer.min');
require('brace/mode/markdown'); require('brace/mode/markdown');
// https://ace.c9.io/build/kitchen-sink.html // https://ace.c9.io/build/kitchen-sink.html
@ -70,6 +71,7 @@ class NoteTextComponent extends React.Component {
editorScrollTop: 0, editorScrollTop: 0,
newNote: null, newNote: null,
noteTags: [], noteTags: [],
showRevisions: false,
// If the current note was just created, and the title has never been // If the current note was just created, and the title has never been
// changed by the user, this variable contains that note ID. Used // changed by the user, this variable contains that note ID. Used
@ -268,6 +270,7 @@ class NoteTextComponent extends React.Component {
this.titleField_keyDown = this.titleField_keyDown.bind(this); this.titleField_keyDown = this.titleField_keyDown.bind(this);
this.webview_ipcMessage = this.webview_ipcMessage.bind(this); this.webview_ipcMessage = this.webview_ipcMessage.bind(this);
this.webview_domReady = this.webview_domReady.bind(this); this.webview_domReady = this.webview_domReady.bind(this);
this.noteRevisionViewer_onBack = this.noteRevisionViewer_onBack.bind(this);
} }
// Note: // Note:
@ -530,7 +533,8 @@ class NoteTextComponent extends React.Component {
webviewReady: webviewReady, webviewReady: webviewReady,
folder: parentFolder, folder: parentFolder,
lastKeys: [], lastKeys: [],
noteTags: noteTags noteTags: noteTags,
showRevisions: false,
}; };
if (!note) { if (!note) {
@ -619,6 +623,13 @@ class NoteTextComponent extends React.Component {
return shared.refreshNoteMetadata(this, force); return shared.refreshNoteMetadata(this, force);
} }
async noteRevisionViewer_onBack() {
this.setState({ showRevisions: false });
this.lastSetHtml_ = '';
this.scheduleReloadNote(this.props);
}
title_changeText(event) { title_changeText(event) {
shared.noteComponent_change(this, 'title', event.target.value); shared.noteComponent_change(this, 'title', event.target.value);
this.setState({ newAndNoTitleChangeNoteId: null }); this.setState({ newAndNoTitleChangeNoteId: null });
@ -1529,6 +1540,7 @@ class NoteTextComponent extends React.Component {
type: 'WINDOW_COMMAND', type: 'WINDOW_COMMAND',
name: 'commandNoteProperties', name: 'commandNoteProperties',
noteId: n.id, noteId: n.id,
onRevisionLinkClick: () => { this.setState({ showRevisions: true}) },
}); });
}, },
}); });
@ -1610,6 +1622,18 @@ class NoteTextComponent extends React.Component {
const innerWidth = rootStyle.width - rootStyle.paddingLeft - rootStyle.paddingRight - borderWidth; const innerWidth = rootStyle.width - rootStyle.paddingLeft - rootStyle.paddingRight - borderWidth;
if (this.state.showRevisions && note && note.id) {
rootStyle.paddingRight = rootStyle.paddingLeft;
rootStyle.paddingTop = rootStyle.paddingLeft;
rootStyle.paddingBottom = rootStyle.paddingLeft;
rootStyle.display = 'inline-flex';
return (
<div style={rootStyle}>
<NoteRevisionViewer noteId={note.id} customCss={this.props.customCss} onBack={this.noteRevisionViewer_onBack}/>
</div>
);
}
if (this.props.selectedNoteIds.length > 1) { if (this.props.selectedNoteIds.length > 1) {
return this.renderMultiNotes(rootStyle); return this.renderMultiNotes(rootStyle);
} else if (!note || !!note.encryption_applied) { //|| (note && !this.props.newNote && this.props.noteId && note.id !== this.props.noteId)) { // note.id !== props.noteId is when the note has not been loaded yet, and the previous one is still in the state } else if (!note || !!note.encryption_applied) { //|| (note && !this.props.newNote && this.props.noteId && note.id !== this.props.noteId)) { // note.id !== props.noteId is when the note has not been loaded yet, and the previous one is still in the state
@ -1710,7 +1734,7 @@ class NoteTextComponent extends React.Component {
viewerStyle.borderLeft = 'none'; viewerStyle.borderLeft = 'none';
} }
if (this.state.webviewReady) { if (this.state.webviewReady && this.webviewRef_.current) {
let html = this.state.bodyHtml; let html = this.state.bodyHtml;
const htmlHasChanged = this.lastSetHtml_ !== html; const htmlHasChanged = this.lastSetHtml_ !== html;

View File

@ -18,11 +18,11 @@ class NoteTextViewerComponent extends React.Component {
} }
webview_domReady(event) { webview_domReady(event) {
this.props.onDomReady(event); if (this.props.onDomReady) this.props.onDomReady(event);
} }
webview_ipcMessage(event) { webview_ipcMessage(event) {
this.props.onIpcMessage(event); if (this.props.onIpcMessage) this.props.onIpcMessage(event);
} }
initWebview() { initWebview() {
@ -67,13 +67,21 @@ class NoteTextViewerComponent extends React.Component {
} }
} }
componentDidUpdate() { tryInit() {
if (!this.initialized_ && this.webviewRef_.current) { if (!this.initialized_ && this.webviewRef_.current) {
this.initWebview(); this.initWebview();
this.initialized_ = true; this.initialized_ = true;
} }
} }
componentDidMount() {
this.tryInit();
}
componentDidUpdate() {
this.tryInit();
}
componentWillUnmount() { componentWillUnmount() {
this.destroyWebview(); this.destroyWebview();
} }

View File

@ -221,7 +221,7 @@ class SideBarComponent extends React.Component {
} }
} }
} else if (command.name === 'synchronize') { } else if (command.name === 'synchronize') {
this.sync_click(); if (!this.props.syncStarted) this.sync_click();
} else { } else {
commandProcessed = false; commandProcessed = false;
} }

View File

@ -21,6 +21,7 @@ const Tag = require('lib/models/Tag.js');
const NoteTag = require('lib/models/NoteTag.js'); const NoteTag = require('lib/models/NoteTag.js');
const MasterKey = require('lib/models/MasterKey'); const MasterKey = require('lib/models/MasterKey');
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const Revision = require('lib/models/Revision.js');
const { Logger } = require('lib/logger.js'); const { Logger } = require('lib/logger.js');
const { FsDriverNode } = require('lib/fs-driver-node.js'); const { FsDriverNode } = require('lib/fs-driver-node.js');
const { shimInit } = require('lib/shim-init-node.js'); const { shimInit } = require('lib/shim-init-node.js');
@ -42,6 +43,7 @@ BaseItem.loadClass('Resource', Resource);
BaseItem.loadClass('Tag', Tag); BaseItem.loadClass('Tag', Tag);
BaseItem.loadClass('NoteTag', NoteTag); BaseItem.loadClass('NoteTag', NoteTag);
BaseItem.loadClass('MasterKey', MasterKey); BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision);
Setting.setConstant('appId', 'net.cozic.joplin-desktop'); Setting.setConstant('appId', 'net.cozic.joplin-desktop');
Setting.setConstant('appType', 'desktop'); Setting.setConstant('appType', 'desktop');

View File

@ -23,9 +23,9 @@
"optional": true "optional": true
}, },
"7zip-bin-win": { "7zip-bin-win": {
"version": "2.1.1", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin-win/-/7zip-bin-win-2.1.1.tgz", "resolved": "https://registry.npmjs.org/7zip-bin-win/-/7zip-bin-win-2.2.0.tgz",
"integrity": "sha512-6VGEW7PXGroTsoI2QW3b0ea95HJmbVBHvfANKLLMzSzFA1zKqVX5ybNuhmeGpf6vA0x8FJTt6twpprDANsY5WQ==", "integrity": "sha512-uPHXapEmUtlUKTBx4asWMlxtFUWXzEY0KVEgU7QKhgO2LJzzM3kYxM6yOyUZTtYE6mhK4dDn3FDut9SCQWHzgg==",
"optional": true "optional": true
}, },
"@types/node": { "@types/node": {
@ -1214,6 +1214,11 @@
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
"dev": true "dev": true
}, },
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"cli-boxes": { "cli-boxes": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz",
@ -2357,25 +2362,29 @@
"dependencies": { "dependencies": {
"abbrev": { "abbrev": {
"version": "1.1.1", "version": "1.1.1",
"bundled": true, "resolved": false,
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true, "resolved": false,
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
"bundled": true, "resolved": false,
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"are-we-there-yet": { "are-we-there-yet": {
"version": "1.1.4", "version": "1.1.4",
"bundled": true, "resolved": false,
"integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2385,13 +2394,15 @@
}, },
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "resolved": false,
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2401,37 +2412,43 @@
}, },
"chownr": { "chownr": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "resolved": false,
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "resolved": false,
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"debug": { "debug": {
"version": "2.6.9", "version": "2.6.9",
"bundled": true, "resolved": false,
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2440,25 +2457,29 @@
}, },
"deep-extend": { "deep-extend": {
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "resolved": false,
"integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"delegates": { "delegates": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"detect-libc": { "detect-libc": {
"version": "1.0.3", "version": "1.0.3",
"bundled": true, "resolved": false,
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"fs-minipass": { "fs-minipass": {
"version": "1.2.5", "version": "1.2.5",
"bundled": true, "resolved": false,
"integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2467,13 +2488,15 @@
}, },
"fs.realpath": { "fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"gauge": { "gauge": {
"version": "2.7.4", "version": "2.7.4",
"bundled": true, "resolved": false,
"integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2489,7 +2512,8 @@
}, },
"glob": { "glob": {
"version": "7.1.2", "version": "7.1.2",
"bundled": true, "resolved": false,
"integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2503,13 +2527,15 @@
}, },
"has-unicode": { "has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"iconv-lite": { "iconv-lite": {
"version": "0.4.21", "version": "0.4.21",
"bundled": true, "resolved": false,
"integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2518,7 +2544,8 @@
}, },
"ignore-walk": { "ignore-walk": {
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "resolved": false,
"integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2527,7 +2554,8 @@
}, },
"inflight": { "inflight": {
"version": "1.0.6", "version": "1.0.6",
"bundled": true, "resolved": false,
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2537,19 +2565,22 @@
}, },
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"bundled": true, "resolved": false,
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
"bundled": true, "resolved": false,
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"is-fullwidth-code-point": { "is-fullwidth-code-point": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2558,13 +2589,15 @@
}, },
"isarray": { "isarray": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "resolved": false,
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2573,13 +2606,15 @@
}, },
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"bundled": true, "resolved": false,
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"minipass": { "minipass": {
"version": "2.2.4", "version": "2.2.4",
"bundled": true, "resolved": false,
"integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2589,7 +2624,8 @@
}, },
"minizlib": { "minizlib": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "resolved": false,
"integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2598,7 +2634,8 @@
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "resolved": false,
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2607,13 +2644,15 @@
}, },
"ms": { "ms": {
"version": "2.0.0", "version": "2.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"needle": { "needle": {
"version": "2.2.0", "version": "2.2.0",
"bundled": true, "resolved": false,
"integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2624,7 +2663,8 @@
}, },
"node-pre-gyp": { "node-pre-gyp": {
"version": "0.10.0", "version": "0.10.0",
"bundled": true, "resolved": false,
"integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2642,7 +2682,8 @@
}, },
"nopt": { "nopt": {
"version": "4.0.1", "version": "4.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2652,13 +2693,15 @@
}, },
"npm-bundled": { "npm-bundled": {
"version": "1.0.3", "version": "1.0.3",
"bundled": true, "resolved": false,
"integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"npm-packlist": { "npm-packlist": {
"version": "1.1.10", "version": "1.1.10",
"bundled": true, "resolved": false,
"integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2668,7 +2711,8 @@
}, },
"npmlog": { "npmlog": {
"version": "4.1.2", "version": "4.1.2",
"bundled": true, "resolved": false,
"integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2680,19 +2724,22 @@
}, },
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
"bundled": true, "resolved": false,
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"once": { "once": {
"version": "1.4.0", "version": "1.4.0",
"bundled": true, "resolved": false,
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2701,19 +2748,22 @@
}, },
"os-homedir": { "os-homedir": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"os-tmpdir": { "os-tmpdir": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"osenv": { "osenv": {
"version": "0.1.5", "version": "0.1.5",
"bundled": true, "resolved": false,
"integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2723,19 +2773,22 @@
}, },
"path-is-absolute": { "path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"process-nextick-args": { "process-nextick-args": {
"version": "2.0.0", "version": "2.0.0",
"bundled": true, "resolved": false,
"integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"rc": { "rc": {
"version": "1.2.7", "version": "1.2.7",
"bundled": true, "resolved": false,
"integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2747,7 +2800,8 @@
"dependencies": { "dependencies": {
"minimist": { "minimist": {
"version": "1.2.0", "version": "1.2.0",
"bundled": true, "resolved": false,
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
"dev": true, "dev": true,
"optional": true "optional": true
} }
@ -2755,7 +2809,8 @@
}, },
"readable-stream": { "readable-stream": {
"version": "2.3.6", "version": "2.3.6",
"bundled": true, "resolved": false,
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2770,7 +2825,8 @@
}, },
"rimraf": { "rimraf": {
"version": "2.6.2", "version": "2.6.2",
"bundled": true, "resolved": false,
"integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2779,43 +2835,50 @@
}, },
"safe-buffer": { "safe-buffer": {
"version": "5.1.1", "version": "5.1.1",
"bundled": true, "resolved": false,
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"bundled": true, "resolved": false,
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"sax": { "sax": {
"version": "1.2.4", "version": "1.2.4",
"bundled": true, "resolved": false,
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"semver": { "semver": {
"version": "5.5.0", "version": "5.5.0",
"bundled": true, "resolved": false,
"integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"set-blocking": { "set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"bundled": true, "resolved": false,
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.2", "version": "3.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"string-width": { "string-width": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2826,7 +2889,8 @@
}, },
"string_decoder": { "string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"bundled": true, "resolved": false,
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2835,7 +2899,8 @@
}, },
"strip-ansi": { "strip-ansi": {
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2844,13 +2909,15 @@
}, },
"strip-json-comments": { "strip-json-comments": {
"version": "2.0.1", "version": "2.0.1",
"bundled": true, "resolved": false,
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"tar": { "tar": {
"version": "4.4.1", "version": "4.4.1",
"bundled": true, "resolved": false,
"integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2865,13 +2932,15 @@
}, },
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"wide-align": { "wide-align": {
"version": "1.1.2", "version": "1.1.2",
"bundled": true, "resolved": false,
"integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"requires": { "requires": {
@ -2880,13 +2949,15 @@
}, },
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.2", "version": "3.0.2",
"bundled": true, "resolved": false,
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
"dev": true, "dev": true,
"optional": true "optional": true
} }
@ -4899,14 +4970,36 @@
} }
}, },
"react": { "react": {
"version": "16.4.0", "version": "16.8.1",
"resolved": "https://registry.npmjs.org/react/-/react-16.4.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-16.8.1.tgz",
"integrity": "sha512-K0UrkLXSAekf5nJu89obKUM7o2vc6MMN9LYoKnCa+c+8MJRAT120xzPLENcWSRc7GYKIg0LlgJRDorrufdglQQ==", "integrity": "sha512-wLw5CFGPdo7p/AgteFz7GblI2JPOos0+biSoxf1FPsGxWQZdN/pj6oToJs1crn61DL3Ln7mN86uZ4j74p31ELQ==",
"requires": { "requires": {
"fbjs": "^0.8.16",
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"prop-types": "^15.6.0" "prop-types": "^15.6.2",
"scheduler": "^0.13.1"
},
"dependencies": {
"prop-types": {
"version": "15.7.2",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
"integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.8.1"
},
"dependencies": {
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
}
}
}
} }
}, },
"react-ace": { "react-ace": {
@ -4950,6 +5043,11 @@
"prop-types": "^15.6.0" "prop-types": "^15.6.0"
} }
}, },
"react-is": {
"version": "16.8.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA=="
},
"react-onclickoutside": { "react-onclickoutside": {
"version": "6.7.1", "version": "6.7.1",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz",
@ -4975,6 +5073,15 @@
} }
} }
}, },
"react-tooltip": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-3.10.0.tgz",
"integrity": "sha512-GGdxJvM1zSFztkTP7gCQbLTstWr1OOoMpJ5WZUGhimj0nhRY+MPz+92MpEnKmj0cftJ9Pd/M6FfSl0sfzmZWkg==",
"requires": {
"classnames": "^2.2.5",
"prop-types": "^15.6.0"
}
},
"read-chunk": { "read-chunk": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-2.1.0.tgz", "resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-2.1.0.tgz",
@ -5303,6 +5410,15 @@
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
}, },
"scheduler": {
"version": "0.13.6",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz",
"integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
},
"semver": { "semver": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",

View File

@ -88,6 +88,7 @@
"chokidar": "^3.0.0", "chokidar": "^3.0.0",
"compare-versions": "^3.2.1", "compare-versions": "^3.2.1",
"diacritics": "^1.3.0", "diacritics": "^1.3.0",
"diff-match-patch": "^1.0.4",
"electron-context-menu": "^0.9.1", "electron-context-menu": "^0.9.1",
"electron-is-dev": "^0.3.0", "electron-is-dev": "^0.3.0",
"electron-window-state": "^4.1.1", "electron-window-state": "^4.1.1",
@ -132,6 +133,7 @@
"react-datetime": "^2.14.0", "react-datetime": "^2.14.0",
"react-dom": "^16.4.0", "react-dom": "^16.4.0",
"react-redux": "^5.0.7", "react-redux": "^5.0.7",
"react-tooltip": "^3.10.0",
"read-chunk": "^2.1.0", "read-chunk": "^2.1.0",
"readability-node": "^0.1.0", "readability-node": "^0.1.0",
"redux": "^3.7.2", "redux": "^3.7.2",

View File

@ -6,6 +6,7 @@ const SearchEngine = require('lib/services/SearchEngine');
const BaseModel = require('lib/BaseModel'); const BaseModel = require('lib/BaseModel');
const Tag = require('lib/models/Tag'); const Tag = require('lib/models/Tag');
const { ItemList } = require('../gui/ItemList.min'); const { ItemList } = require('../gui/ItemList.min');
const HelpButton = require('../gui/HelpButton.min');
const { substrWithEllipsis, surroundKeywords } = require('lib/string-utils.js'); const { substrWithEllipsis, surroundKeywords } = require('lib/string-utils.js');
const PLUGIN_NAME = 'gotoAnything'; const PLUGIN_NAME = 'gotoAnything';
@ -61,8 +62,6 @@ class Dialog extends React.PureComponent {
row: {overflow: 'hidden', height:itemHeight, display: 'flex', justifyContent: 'center', flexDirection: 'column', paddingLeft: 10, paddingRight: 10}, row: {overflow: 'hidden', height:itemHeight, display: 'flex', justifyContent: 'center', flexDirection: 'column', paddingLeft: 10, paddingRight: 10},
help: Object.assign({}, theme.textStyle, { marginBottom: 10 }), help: Object.assign({}, theme.textStyle, { marginBottom: 10 }),
inputHelpWrapper: {display: 'flex', flexDirection: 'row', alignItems: 'center'}, inputHelpWrapper: {display: 'flex', flexDirection: 'row', alignItems: 'center'},
helpIcon: {flex:0, width: 16, height: 16, marginLeft: 10},
helpButton: {color: theme.color, textDecoration: 'none'},
}; };
const rowTextStyle = { const rowTextStyle = {
@ -321,7 +320,7 @@ class Dialog extends React.PureComponent {
{helpComp} {helpComp}
<div style={style.inputHelpWrapper}> <div style={style.inputHelpWrapper}>
<input autoFocus type="text" style={style.input} ref={this.inputRef} value={this.state.query} onChange={this.input_onChange} onKeyDown={this.input_onKeyDown}/> <input autoFocus type="text" style={style.input} ref={this.inputRef} value={this.state.query} onChange={this.input_onChange} onKeyDown={this.input_onKeyDown}/>
<a href="#" style={style.helpButton} onClick={this.helpButton_onClick}><i style={style.helpIcon} className={"fa fa-question-circle"}></i></a> <HelpButton onClick={this.helpButton_onClick}/>
</div> </div>
{this.renderList()} {this.renderList()}
</div> </div>

View File

@ -113,3 +113,12 @@ table td, table th {
.note-property-box .rdt { .note-property-box .rdt {
display: inline-block; display: inline-block;
} }
.help-tooltip {
font-family: sans-serif;
max-width: 200px;
}
:disabled {
opacity: 0.6;
}

View File

@ -34,6 +34,9 @@ globalStyle.icon = {
globalStyle.lineInput = { globalStyle.lineInput = {
fontFamily: globalStyle.fontFamily, fontFamily: globalStyle.fontFamily,
maxHeight: 22,
height: 22,
paddingLeft: 5,
}; };
globalStyle.headerStyle = { globalStyle.headerStyle = {
@ -43,6 +46,7 @@ globalStyle.headerStyle = {
globalStyle.inputStyle = { globalStyle.inputStyle = {
border: '1px solid', border: '1px solid',
height: 24, height: 24,
maxHeight: 24,
paddingLeft: 5, paddingLeft: 5,
paddingRight: 5, paddingRight: 5,
boxSizing: 'border-box', boxSizing: 'border-box',
@ -54,13 +58,14 @@ globalStyle.containerStyle = {
}; };
globalStyle.buttonStyle = { globalStyle.buttonStyle = {
marginRight: 10, // marginRight: 10,
border: '1px solid', border: '1px solid',
minHeight: 30, minHeight: 26,
minWidth: 80, minWidth: 80,
maxWidth: 160, maxWidth: 160,
paddingLeft: 12, paddingLeft: 12,
paddingRight: 12, paddingRight: 12,
boxShadow: '0px 1px 1px rgba(0,0,0,0.3)',
}; };
const lightStyle = { const lightStyle = {
@ -226,6 +231,8 @@ function addExtraStyles(style) {
style.dialogTitle = Object.assign({}, style.h1Style, { marginBottom: '1.2em' }); style.dialogTitle = Object.assign({}, style.h1Style, { marginBottom: '1.2em' });
style.dropdownList = Object.assign({}, style.inputStyle);
return style; return style;
} }

View File

@ -35,6 +35,7 @@ const SyncTargetDropbox = require('lib/SyncTargetDropbox.js');
const EncryptionService = require('lib/services/EncryptionService'); const EncryptionService = require('lib/services/EncryptionService');
const ResourceFetcher = require('lib/services/ResourceFetcher'); const ResourceFetcher = require('lib/services/ResourceFetcher');
const SearchEngineUtils = require('lib/services/SearchEngineUtils'); const SearchEngineUtils = require('lib/services/SearchEngineUtils');
const RevisionService = require('lib/services/RevisionService');
const DecryptionWorker = require('lib/services/DecryptionWorker'); const DecryptionWorker = require('lib/services/DecryptionWorker');
const BaseService = require('lib/services/BaseService'); const BaseService = require('lib/services/BaseService');
const SearchEngine = require('lib/services/SearchEngine'); const SearchEngine = require('lib/services/SearchEngine');
@ -577,6 +578,8 @@ class BaseApplication {
time.setDateFormat(Setting.value('dateFormat')); time.setDateFormat(Setting.value('dateFormat'));
time.setTimeFormat(Setting.value('timeFormat')); time.setTimeFormat(Setting.value('timeFormat'));
BaseItem.revisionService_ = RevisionService.instance();
BaseService.logger_ = this.logger_; BaseService.logger_ = this.logger_;
EncryptionService.instance().setLogger(this.logger_); EncryptionService.instance().setLogger(this.logger_);
BaseItem.encryptionService_ = EncryptionService.instance(); BaseItem.encryptionService_ = EncryptionService.instance();

View File

@ -539,6 +539,7 @@ BaseModel.typeEnum_ = [
['TYPE_ITEM_CHANGE', 10], ['TYPE_ITEM_CHANGE', 10],
['TYPE_NOTE_RESOURCE', 11], ['TYPE_NOTE_RESOURCE', 11],
['TYPE_RESOURCE_LOCAL_STATE', 12], ['TYPE_RESOURCE_LOCAL_STATE', 12],
['TYPE_REVISION', 13],
]; ];
for (let i = 0; i < BaseModel.typeEnum_.length; i++) { for (let i = 0; i < BaseModel.typeEnum_.length; i++) {

View File

@ -10,7 +10,7 @@ const Setting = require('lib/models/Setting.js');
const shared = require('lib/components/shared/config-shared.js'); const shared = require('lib/components/shared/config-shared.js');
const SyncTargetRegistry = require('lib/SyncTargetRegistry'); const SyncTargetRegistry = require('lib/SyncTargetRegistry');
const { reg } = require('lib/registry.js'); const { reg } = require('lib/registry.js');
import VersionInfo from 'react-native-version-info'; const VersionInfo = require('react-native-version-info').default;
class ConfigScreenComponent extends BaseScreenComponent { class ConfigScreenComponent extends BaseScreenComponent {
@ -163,10 +163,14 @@ class ConfigScreenComponent extends BaseScreenComponent {
</View> </View>
); );
} else if (md.type == Setting.TYPE_INT) { } else if (md.type == Setting.TYPE_INT) {
const unitLabel = md.unitLabel ? md.unitLabel(value) : value;
return ( return (
<View key={key} style={this.styles().settingContainer}> <View key={key} style={this.styles().settingContainer}>
<Text key="label" style={this.styles().settingText}>{md.label()}</Text> <Text key="label" style={this.styles().settingText}>{md.label()}</Text>
<Slider key="control" style={this.styles().settingControl} value={value} onValueChange={(value) => updateSettingValue(key, value)} /> <View style={{display:'flex', flexDirection: 'column', alignItems: 'center', flex:1}}>
<Slider key="control" style={{width:'100%'}} step={md.step} minimumValue={md.minimum} maximumValue={md.maximum} value={value} onValueChange={(value) => updateSettingValue(key, value)} />
<Text>{unitLabel}</Text>
</View>
</View> </View>
); );
} else if (md.type == Setting.TYPE_STRING) { } else if (md.type == Setting.TYPE_STRING) {

View File

@ -263,7 +263,7 @@ class JoplinDatabase extends Database {
// must be set in the synchronizer too. // must be set in the synchronizer too.
// Note: v16 and v17 don't do anything. They were used to debug an issue. // Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]; const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion); let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@ -515,6 +515,35 @@ class JoplinDatabase extends Database {
END;`); END;`);
} }
if (targetVersion == 19) {
const newTableSql = `
CREATE TABLE revisions (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL DEFAULT "",
item_type INT NOT NULL,
item_id TEXT NOT NULL,
item_updated_time INT NOT NULL,
title_diff TEXT NOT NULL DEFAULT "",
body_diff TEXT NOT NULL DEFAULT "",
metadata_diff TEXT NOT NULL DEFAULT "",
encryption_cipher_text TEXT NOT NULL DEFAULT "",
encryption_applied INT NOT NULL DEFAULT 0,
updated_time INT NOT NULL,
created_time INT NOT NULL
);
`;
queries.push(this.sqlStringToLines(newTableSql)[0]);
queries.push('CREATE INDEX revisions_parent_id ON revisions (parent_id)');
queries.push('CREATE INDEX revisions_item_type ON revisions (item_type)');
queries.push('CREATE INDEX revisions_item_id ON revisions (item_id)');
queries.push('CREATE INDEX revisions_item_updated_time ON revisions (item_updated_time)');
queries.push('CREATE INDEX revisions_updated_time ON revisions (updated_time)');
queries.push('ALTER TABLE item_changes ADD COLUMN source INT NOT NULL DEFAULT 1');
queries.push('ALTER TABLE item_changes ADD COLUMN before_change_item TEXT NOT NULL DEFAULT ""');
}
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] }); queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
try { try {

View File

@ -160,6 +160,7 @@ class BaseItem extends BaseModel {
} }
static async batchDelete(ids, options = null) { static async batchDelete(ids, options = null) {
if (!options) options = {};
let trackDeleted = true; let trackDeleted = true;
if (options && options.trackDeleted !== null && options.trackDeleted !== undefined) trackDeleted = options.trackDeleted; if (options && options.trackDeleted !== null && options.trackDeleted !== undefined) trackDeleted = options.trackDeleted;
@ -219,6 +220,9 @@ class BaseItem extends BaseModel {
if (['created_time', 'updated_time', 'sync_time', 'user_updated_time', 'user_created_time'].indexOf(propName) >= 0) { if (['created_time', 'updated_time', 'sync_time', 'user_updated_time', 'user_created_time'].indexOf(propName) >= 0) {
if (!propValue) return ''; if (!propValue) return '';
propValue = moment.unix(propValue / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z'; propValue = moment.unix(propValue / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
} else if (['title_diff', 'body_diff'].indexOf(propName) >= 0) {
if (!propValue) return '';
propValue = JSON.stringify(propValue);
} else if (propValue === null || propValue === undefined) { } else if (propValue === null || propValue === undefined) {
propValue = ''; propValue = '';
} }
@ -234,6 +238,9 @@ class BaseItem extends BaseModel {
if (['created_time', 'updated_time', 'user_created_time', 'user_updated_time'].indexOf(propName) >= 0) { if (['created_time', 'updated_time', 'user_created_time', 'user_updated_time'].indexOf(propName) >= 0) {
if (!propValue) return 0; if (!propValue) return 0;
propValue = moment(propValue, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x'); propValue = moment(propValue, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x');
} else if (['title_diff', 'body_diff'].indexOf(propName) >= 0) {
if (!propValue) return '';
propValue = JSON.parse(propValue);
} else { } else {
propValue = Database.formatValue(ItemClass.fieldType(propName), propValue); propValue = Database.formatValue(ItemClass.fieldType(propName), propValue);
} }
@ -291,19 +298,16 @@ class BaseItem extends BaseModel {
return this.encryptionService_; return this.encryptionService_;
} }
static revisionService() {
if (!this.revisionService_) throw new Error('BaseItem.revisionService_ is not set!!');
return this.revisionService_;
}
static async serializeForSync(item) { static async serializeForSync(item) {
const ItemClass = this.itemClass(item); const ItemClass = this.itemClass(item);
let shownKeys = ItemClass.fieldNames(); let shownKeys = ItemClass.fieldNames();
shownKeys.push('type_'); shownKeys.push('type_');
// if (ItemClass.syncExcludedKeys) {
// const keys = ItemClass.syncExcludedKeys();
// for (let i = 0; i < keys.length; i++) {
// const idx = shownKeys.indexOf(keys[i]);
// shownKeys.splice(idx, 1);
// }
// }
const serialized = await ItemClass.serialize(item, shownKeys); const serialized = await ItemClass.serialize(item, shownKeys);
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported()) { if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported()) {
@ -597,7 +601,7 @@ class BaseItem extends BaseModel {
static updateSyncTimeQueries(syncTarget, item, syncTime, syncDisabled = false, syncDisabledReason = '') { static updateSyncTimeQueries(syncTarget, item, syncTime, syncDisabled = false, syncDisabledReason = '') {
const itemType = item.type_; const itemType = item.type_;
const itemId = item.id; const itemId = item.id;
if (!itemType || !itemId || syncTime === undefined) throw new Error('Invalid parameters in updateSyncTimeQueries()'); if (!itemType || !itemId || syncTime === undefined) throw new Error(sprintf('Invalid parameters in updateSyncTimeQueries(): %d, %s, %d', syncTarget, JSON.stringify(item), syncTime));
return [ return [
{ {
@ -704,6 +708,7 @@ class BaseItem extends BaseModel {
} }
BaseItem.encryptionService_ = null; BaseItem.encryptionService_ = null;
BaseItem.revisionService_ = null;
// Also update: // Also update:
// - itemsThatNeedSync() // - itemsThatNeedSync()
@ -716,6 +721,7 @@ BaseItem.syncItemDefinitions_ = [
{ type: BaseModel.TYPE_TAG, className: 'Tag' }, { type: BaseModel.TYPE_TAG, className: 'Tag' },
{ type: BaseModel.TYPE_NOTE_TAG, className: 'NoteTag' }, { type: BaseModel.TYPE_NOTE_TAG, className: 'NoteTag' },
{ type: BaseModel.TYPE_MASTER_KEY, className: 'MasterKey' }, { type: BaseModel.TYPE_MASTER_KEY, className: 'MasterKey' },
{ type: BaseModel.TYPE_REVISION, className: 'Revision' },
]; ];
module.exports = BaseItem; module.exports = BaseItem;

View File

@ -11,7 +11,10 @@ class ItemChange extends BaseModel {
return BaseModel.TYPE_ITEM_CHANGE; return BaseModel.TYPE_ITEM_CHANGE;
} }
static async add(itemType, itemId, type) { static async add(itemType, itemId, type, changeSource = null, beforeChangeItemJson = null) {
if (changeSource === null) changeSource = ItemChange.SOURCE_UNSPECIFIED;
if (!beforeChangeItemJson) beforeChangeItemJson = '';
ItemChange.saveCalls_.push(true); ItemChange.saveCalls_.push(true);
// Using a mutex so that records can be added to the database in the // Using a mutex so that records can be added to the database in the
@ -21,7 +24,7 @@ class ItemChange extends BaseModel {
try { try {
await this.db().transactionExecBatch([ await this.db().transactionExecBatch([
{ sql: 'DELETE FROM item_changes WHERE item_id = ?', params: [itemId] }, { sql: 'DELETE FROM item_changes WHERE item_id = ?', params: [itemId] },
{ sql: 'INSERT INTO item_changes (item_type, item_id, type, created_time) VALUES (?, ?, ?, ?)', params: [itemType, itemId, type, Date.now()] }, { sql: 'INSERT INTO item_changes (item_type, item_id, type, source, created_time, before_change_item) VALUES (?, ?, ?, ?, ?, ?)', params: [itemType, itemId, type, changeSource, Date.now(), beforeChangeItemJson] },
]); ]);
} finally { } finally {
release(); release();
@ -61,4 +64,7 @@ ItemChange.TYPE_CREATE = 1;
ItemChange.TYPE_UPDATE = 2; ItemChange.TYPE_UPDATE = 2;
ItemChange.TYPE_DELETE = 3; ItemChange.TYPE_DELETE = 3;
ItemChange.SOURCE_UNSPECIFIED = 1;
ItemChange.SOURCE_SYNC = 2;
module.exports = ItemChange; module.exports = ItemChange;

View File

@ -525,14 +525,32 @@ class Note extends BaseItem {
return this.save(newNote); return this.save(newNote);
} }
static async noteIsOlderThan(noteId, date) {
const n = await this.db().selectOne('SELECT updated_time FROM notes WHERE id = ?', [noteId]);
if (!n) throw new Error('No such note: ' + noteId);
return n.updated_time < date;
}
static async save(o, options = null) { static async save(o, options = null) {
let isNew = this.isNew(o, options); let isNew = this.isNew(o, options);
if (isNew && !o.source) o.source = Setting.value('appName'); if (isNew && !o.source) o.source = Setting.value('appName');
if (isNew && !o.source_application) o.source_application = Setting.value('appId'); if (isNew && !o.source_application) o.source_application = Setting.value('appId');
// We only keep the previous note content for "old notes" (see Revision Service for more info)
// In theory, we could simply save all the previous note contents, and let the revision service
// decide what to keep and what to ignore, but in practice keeping the previous content is a bit
// heavy - the note needs to be reloaded here, the JSON blob needs to be saved, etc.
// So the check for old note here is basically an optimisation.
let beforeNoteJson = null;
if (!isNew && this.revisionService().isOldNote(o.id)) {
beforeNoteJson = await Note.load(o.id);
if (beforeNoteJson) beforeNoteJson = JSON.stringify(beforeNoteJson);
}
const note = await super.save(o, options); const note = await super.save(o, options);
ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE); const changeSource = options && options.changeSource ? options.changeSource : null;
ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson);
this.dispatch({ this.dispatch({
type: 'NOTE_UPDATE_ONE', type: 'NOTE_UPDATE_ONE',
@ -550,16 +568,29 @@ class Note extends BaseItem {
} }
static async batchDelete(ids, options = null) { static async batchDelete(ids, options = null) {
const result = await super.batchDelete(ids, options); ids = ids.slice();
for (let i = 0; i < ids.length; i++) {
ItemChange.add(BaseModel.TYPE_NOTE, ids[i], ItemChange.TYPE_DELETE);
this.dispatch({ while (ids.length) {
type: 'NOTE_DELETE', const processIds = ids.splice(0, 50);
id: ids[i],
}); const notes = await Note.byIds(processIds);
const beforeChangeItems = {};
for (const note of notes) {
beforeChangeItems[note.id] = JSON.stringify(note);
}
const result = await super.batchDelete(processIds, options);
const changeSource = options && options.changeSource ? options.changeSource : null;
for (let i = 0; i < processIds.length; i++) {
const id = processIds[i];
ItemChange.add(BaseModel.TYPE_NOTE, id, ItemChange.TYPE_DELETE, changeSource, beforeChangeItems[id]);
this.dispatch({
type: 'NOTE_DELETE',
id: id,
});
}
} }
return result;
} }
static dueNotes() { static dueNotes() {

View File

@ -0,0 +1,248 @@
const BaseModel = require('lib/BaseModel.js');
const BaseItem = require('lib/models/BaseItem.js');
const NoteTag = require('lib/models/NoteTag.js');
const Note = require('lib/models/Note.js');
const { time } = require('lib/time-utils.js');
const { _ } = require('lib/locale');
const DiffMatchPatch = require('diff-match-patch');
const ArrayUtils = require('lib/ArrayUtils.js');
const JoplinError = require('lib/JoplinError');
const { sprintf } = require('sprintf-js');
const dmp = new DiffMatchPatch();
class Revision extends BaseItem {
static tableName() {
return 'revisions';
}
static modelType() {
return BaseModel.TYPE_REVISION;
}
static createTextPatch(oldText, newText) {
return dmp.patch_toText(dmp.patch_make(oldText, newText));
}
static applyTextPatch(text, patch) {
patch = dmp.patch_fromText(patch);
const result = dmp.patch_apply(patch, text);
if (!result || !result.length) throw new Error('Could not apply patch');
return result[0];
}
static createObjectPatch(oldObject, newObject) {
if (!oldObject) oldObject = {};
const output = {
new: {},
deleted: [],
};
for (let k in newObject) {
if (!newObject.hasOwnProperty(k)) continue;
if (oldObject[k] === newObject[k]) continue;
output.new[k] = newObject[k];
}
for (let k in oldObject) {
if (!oldObject.hasOwnProperty(k)) continue;
if (!(k in newObject)) output.deleted.push(k);
}
return JSON.stringify(output);
}
static applyObjectPatch(object, patch) {
patch = JSON.parse(patch);
const output = Object.assign({}, object);
for (let k in patch.new) {
output[k] = patch.new[k];
}
for (let i = 0; i < patch.deleted.length; i++) {
delete output[patch.deleted[i]];
}
return output;
}
static async countRevisions(itemType, itemId) {
const r = await this.db().selectOne('SELECT count(*) as total FROM revisions WHERE item_type = ? AND item_id = ?', [
itemType,
itemId,
]);
return r ? r.total : 0;
}
static latestRevision(itemType, itemId) {
return this.modelSelectOne('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time DESC LIMIT 1', [
itemType,
itemId,
]);
}
static allByType(itemType, itemId) {
return this.modelSelectAll('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time ASC', [
itemType,
itemId,
]);
}
static async itemsWithRevisions(itemType, itemIds) {
if (!itemIds.length) return [];
const rows = await this.db().selectAll('SELECT distinct item_id FROM revisions WHERE item_type = ? AND item_id IN ("' + itemIds.join('","') + '")', [
itemType,
]);
return rows.map(r => r.item_id);
}
static async itemsWithNoRevisions(itemType, itemIds) {
const withRevs = await this.itemsWithRevisions(itemType, itemIds);
const output = [];
for (let i = 0; i < itemIds.length; i++) {
if (withRevs.indexOf(itemIds[i]) < 0) output.push(itemIds[i]);
}
return ArrayUtils.unique(output);
}
static moveRevisionToTop(revision, revs) {
let targetIndex = -1;
for (let i = revs.length - 1; i >= 0; i--) {
const rev = revs[i];
if (rev.id === revision.id) {
targetIndex = i;
break;
}
}
if (targetIndex < 0) throw new Error('Could not find revision: ' + revision.id);
if (targetIndex !== revs.length - 1) {
revs = revs.slice();
const toTop = revs[targetIndex];
revs.splice(targetIndex, 1);
revs.push(toTop);
}
return revs;
}
// Note: revs must be sorted by update_time ASC (as returned by allByType)
static async mergeDiffs(revision, revs = null) {
if (!('encryption_applied' in revision) || !!revision.encryption_applied) throw new JoplinError('Target revision is encrypted', 'revision_encrypted');
if (!revs) {
revs = await this.modelSelectAll('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? AND item_updated_time <= ? ORDER BY item_updated_time ASC', [
revision.item_type,
revision.item_id,
revision.item_updated_time,
]);
} else {
revs = revs.slice();
}
// Handle rare case where two revisions have been created at exactly the same millisecond
// Also handle even rarer case where a rev and its parent have been created at the
// same milliseconds. All code below expects target revision to be on top.
revs = this.moveRevisionToTop(revision, revs);
const output = {
title: '',
body: '',
metadata: {},
};
// Build up the list of revisions that are parents of the target revision.
const revIndexes = [revs.length - 1];
let parentId = revision.parent_id;
for (let i = revs.length - 2; i >= 0; i--) {
const rev = revs[i];
if (rev.id !== parentId) continue;
parentId = rev.parent_id;
revIndexes.push(i);
}
revIndexes.reverse();
for (const revIndex of revIndexes) {
const rev = revs[revIndex];
if (!!rev.encryption_applied) throw new JoplinError(sprintf('Revision "%s" is encrypted', rev.id), 'revision_encrypted');
output.title = this.applyTextPatch(output.title, rev.title_diff);
output.body = this.applyTextPatch(output.body, rev.body_diff);
output.metadata = this.applyObjectPatch(output.metadata, rev.metadata_diff);
}
return output;
}
static async deleteOldRevisions(ttl) {
// When deleting old revisions, we need to make sure that the oldest surviving revision
// is a "merged" one (as opposed to a diff from a now deleted revision). So every time
// we deleted a revision, we need to find if there's a corresponding surviving revision
// and modify that revision into a "merged" one.
const cutOffDate = Date.now() - ttl;
const revisions = await this.modelSelectAll('SELECT * FROM revisions WHERE item_updated_time < ? ORDER BY item_updated_time DESC', [cutOffDate]);
const doneItems = {};
for (const rev of revisions) {
const doneKey = rev.item_type + '_' + rev.item_id;
if (doneItems[doneKey]) continue;
const keptRev = await this.modelSelectOne('SELECT * FROM revisions WHERE item_updated_time >= ? AND item_type = ? AND item_id = ? ORDER BY item_updated_time ASC LIMIT 1', [
cutOffDate,
rev.item_type,
rev.item_id,
]);
try {
const deleteQueryCondition = 'item_updated_time < ? AND item_id = ?';
const deleteQueryParams = [cutOffDate, rev.item_id];
const deleteQuery = { sql: 'DELETE FROM revisions WHERE ' + deleteQueryCondition, params: deleteQueryParams };
if (!keptRev) {
const hasEncrypted = await this.modelSelectOne('SELECT * FROM revisions WHERE encryption_applied = 1 AND ' + deleteQueryCondition, deleteQueryParams);
if (!!hasEncrypted) throw new JoplinError('One of the revision to be deleted is encrypted', 'revision_encrypted');
await this.db().transactionExecBatch([deleteQuery]);
} else {
// Note: we don't need to check for encrypted rev here because
// mergeDiff will already throw the revision_encrypted exception
// if a rev is encrypted.
const merged = await this.mergeDiffs(keptRev);
const queries = [
deleteQuery,
{ sql: 'UPDATE revisions SET title_diff = ?, body_diff = ?, metadata_diff = ? WHERE id = ?', params: [
this.createTextPatch('', merged.title),
this.createTextPatch('', merged.body),
this.createObjectPatch({}, merged.metadata),
keptRev.id,
] },
];
await this.db().transactionExecBatch(queries);
}
} catch (error) {
if (error.code === 'revision_encrypted') {
this.logger().info('Aborted deletion of old revisions for item ' + rev.item_id + ' because one of the revisions is still encrypted', error);
} else {
throw error;
}
}
doneItems[doneKey] = true;
}
}
static async revisionExists(itemType, itemId, updatedTime) {
const existingRev = await Revision.latestRevision(itemType, itemId);
return existingRev && existingRev.item_updated_time === updatedTime;
}
}
module.exports = Revision;

View File

@ -191,7 +191,14 @@ class Setting extends BaseModel {
'resourceService.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false }, 'resourceService.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false },
'searchEngine.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false }, 'searchEngine.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false },
'revisionService.lastProcessedChangeId': { value: 0, type: Setting.TYPE_INT, public: false },
'searchEngine.initialIndexingDone': { value: false, type: Setting.TYPE_BOOL, public: false }, 'searchEngine.initialIndexingDone': { value: false, type: Setting.TYPE_BOOL, public: false },
'revisionService.enabled': { section: 'revisionService', value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Enable note history') },
'revisionService.ttlDays': { section: 'revisionService', value: 90, type: Setting.TYPE_INT, public: true, minimum: 1, maximum: 365 * 2, step: 1, unitLabel: (value = null) => { return value === null ? _('days') : _('%d days', value) }, label: () => _('Keep note history for') },
'revisionService.installedTime': { section: 'revisionService', value: 0, type: Setting.TYPE_INT, public: false },
'welcome.wasBuilt': { value: false, type: Setting.TYPE_BOOL, public: false }, 'welcome.wasBuilt': { value: false, type: Setting.TYPE_BOOL, public: false },
}; };
@ -586,6 +593,7 @@ class Setting extends BaseModel {
if (name === 'note') return _('Note'); if (name === 'note') return _('Note');
if (name === 'plugins') return _('Plugins'); if (name === 'plugins') return _('Plugins');
if (name === 'application') return _('Application'); if (name === 'application') return _('Application');
if (name === 'revisionService') return _('Note History');
return name; return name;
} }

View File

@ -7,6 +7,7 @@ const { splitCommandString } = require('lib/string-utils');
const { fileExtension } = require('lib/path-utils'); const { fileExtension } = require('lib/path-utils');
const spawn = require('child_process').spawn; const spawn = require('child_process').spawn;
const chokidar = require('chokidar'); const chokidar = require('chokidar');
// const chokidar = null;
class ExternalEditWatcher { class ExternalEditWatcher {

View File

@ -7,6 +7,7 @@ class ItemChangeUtils {
const lastProcessedChangeIds = [ const lastProcessedChangeIds = [
Setting.value('resourceService.lastProcessedChangeId'), Setting.value('resourceService.lastProcessedChangeId'),
Setting.value('searchEngine.lastProcessedChangeId'), Setting.value('searchEngine.lastProcessedChangeId'),
Setting.value('revisionService.lastProcessedChangeId'),
]; ];
const lowestChangeId = Math.min(...lastProcessedChangeIds); const lowestChangeId = Math.min(...lastProcessedChangeIds);

View File

@ -93,6 +93,7 @@ class ResourceService extends BaseService {
} }
async deleteOrphanResources(expiryDelay = null) { async deleteOrphanResources(expiryDelay = null) {
if (expiryDelay === null) expiryDelay = Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000;
const resourceIds = await NoteResource.orphanResources(expiryDelay); const resourceIds = await NoteResource.orphanResources(expiryDelay);
this.logger().info('ResourceService::deleteOrphanResources:', resourceIds); this.logger().info('ResourceService::deleteOrphanResources:', resourceIds);
for (let i = 0; i < resourceIds.length; i++) { for (let i = 0; i < resourceIds.length; i++) {

View File

@ -0,0 +1,277 @@
const { Logger } = require('lib/logger.js');
const ItemChange = require('lib/models/ItemChange');
const Note = require('lib/models/Note');
const Folder = require('lib/models/Folder');
const Setting = require('lib/models/Setting');
const Revision = require('lib/models/Revision');
const BaseModel = require('lib/BaseModel');
const ItemChangeUtils = require('lib/services/ItemChangeUtils');
const { shim } = require('lib/shim');
const BaseService = require('lib/services/BaseService');
const { _ } = require('lib/locale.js');
const ArrayUtils = require('lib/ArrayUtils.js');
class RevisionService extends BaseService {
constructor() {
super();
// An "old note" is one that has been created before the revision service existed. These
// notes never benefited from revisions so the first time they are modified, a copy of
// the original note is saved. The goal is to have at least one revision in case the note
// is deleted or modified as a result of a bug or user mistake.
this.isOldNotesCache_ = {};
if (!Setting.value('revisionService.installedTime')) Setting.setValue('revisionService.installedTime', Date.now());
}
installedTime() {
return Setting.value('revisionService.installedTime');
}
static instance() {
if (this.instance_) return this.instance_;
this.instance_ = new RevisionService();
return this.instance_;
}
async isOldNote(noteId) {
if (noteId in this.isOldNotesCache_) return this.isOldNotesCache_[noteId];
const r = await Note.noteIsOlderThan(noteId, this.installedTime());
this.isOldNotesCache_[noteId] = r;
return r;
}
noteMetadata_(note) {
const excludedFields = ['type_', 'title', 'body', 'created_time', 'updated_time', 'encryption_applied', 'encryption_cipher_text', 'is_conflict'];
const md = {};
for (let k in note) {
if (excludedFields.indexOf(k) >= 0) continue;
md[k] = note[k];
}
return md;
}
async createNoteRevision(note, parentRevId = null) {
const parentRev = parentRevId ? await Revision.load(parentRevId) : await Revision.latestRevision(BaseModel.TYPE_NOTE, note.id);
const output = {
parent_id: '',
item_type: BaseModel.TYPE_NOTE,
item_id: note.id,
item_updated_time: note.updated_time,
};
const noteMd = this.noteMetadata_(note);
const noteTitle = note.title ? note.title : '';
const noteBody = note.body ? note.body : '';
if (!parentRev) {
output.title_diff = Revision.createTextPatch('', noteTitle);
output.body_diff = Revision.createTextPatch('', noteBody);
output.metadata_diff = Revision.createObjectPatch({}, noteMd);
} else {
const merged = await Revision.mergeDiffs(parentRev);
output.parent_id = parentRev.id;
output.title_diff = Revision.createTextPatch(merged.title, noteTitle);
output.body_diff = Revision.createTextPatch(merged.body, noteBody);
output.metadata_diff = Revision.createObjectPatch(merged.metadata, noteMd);
}
return Revision.save(output);
}
async createNoteRevisionsByIds(noteIds) {
noteIds = ArrayUtils.unique(noteIds);
while (noteIds.length) {
const ids = noteIds.splice(0, 100);
const notes = await Note.byIds(ids);
for (const note of notes) {
const existingRev = await Revision.latestRevision(BaseModel.TYPE_NOTE, note.id);
if (existingRev && existingRev.item_updated_time === note.updated_time) continue;
await this.createNoteRevision(note);
}
}
}
async createNoteRevisionIfNoneFound(noteId, cutOffDate) {
const count = await Revision.countRevisions(BaseModel.TYPE_NOTE, noteId);
if (count) return;
const note = await Note.load(noteId);
if (!note) {
this.logger().warn('RevisionService:createNoteRevisionIfNoneFound: Could not find note ' + noteId);
return;
}
if (note.updated_time > cutOffDate) return;
await this.createNoteRevision(note);
}
async collectRevisions() {
if (this.isCollecting_) return;
this.isCollecting_ = true;
await ItemChange.waitForAllSaved();
const doneNoteIds = [];
try {
while (true) {
// See synchronizer test units to see why changes coming
// from sync are skipped.
const changes = await ItemChange.modelSelectAll(`
SELECT id, item_id, type, before_change_item
FROM item_changes
WHERE item_type = ?
AND source != ?
AND id > ?
ORDER BY id ASC
LIMIT 10
`, [BaseModel.TYPE_NOTE, ItemChange.SOURCE_SYNC, Setting.value('revisionService.lastProcessedChangeId')]);
if (!changes.length) break;
const noteIds = changes.map(a => a.item_id);
const notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND encryption_applied = 0 AND id IN ("' + noteIds.join('","') + '")');
for (let i = 0; i < changes.length; i++) {
const change = changes[i];
const noteId = change.item_id;
if (change.type === ItemChange.TYPE_UPDATE && doneNoteIds.indexOf(noteId) < 0) {
const note = BaseModel.byId(notes, noteId);
const oldNote = change.before_change_item ? JSON.parse(change.before_change_item) : null;
if (note) {
if (oldNote && oldNote.updated_time < this.installedTime()) {
// This is where we save the original version of this old note
await this.createNoteRevision(oldNote);
}
await this.createNoteRevision(note);
doneNoteIds.push(noteId);
this.isOldNotesCache_[noteId] = false;
}
}
if (change.type === ItemChange.TYPE_DELETE && !!change.before_change_item) {
const note = JSON.parse(change.before_change_item);
const revExists = await Revision.revisionExists(BaseModel.TYPE_NOTE, note.id, note.updated_time);
if (!revExists) await this.createNoteRevision(note);
doneNoteIds.push(noteId);
}
Setting.setValue('revisionService.lastProcessedChangeId', change.id);
}
}
} catch (error) {
if (error.code === 'revision_encrypted') {
// One or more revisions are encrypted - stop processing for now
// and these revisions will be processed next time the revision
// collector runs.
this.logger().info('RevisionService::collectRevisions: One or more revision was encrypted. Processing was stopped but will resume later when the revision is decrypted.', error);
} else {
this.logger().error('RevisionService::collectRevisions:', error);
}
}
await Setting.saveAll();
await ItemChangeUtils.deleteProcessedChanges();
this.isCollecting_ = false;
this.logger().info('RevisionService::collectRevisions: Created revisions for ' + doneNoteIds.length + ' notes');
}
async deleteOldRevisions(ttl) {
return Revision.deleteOldRevisions(ttl);
}
async revisionNote(revisions, index) {
if (index < 0 || index >= revisions.length) throw new Error('Invalid revision index: ' + index);
const rev = revisions[index];
const merged = await Revision.mergeDiffs(rev, revisions);
const output = Object.assign({
title: merged.title,
body: merged.body,
}, merged.metadata);
output.updated_time = output.user_updated_time;
output.created_time = output.user_created_time;
output.type_ = BaseModel.TYPE_NOTE;
return output;
}
restoreFolderTitle() {
return _('Restored Notes');
}
async restoreFolder() {
let folder = await Folder.loadByTitle(this.restoreFolderTitle());
if (!folder) {
folder = await Folder.save({ title: this.restoreFolderTitle() });
}
return folder;
}
async importRevisionNote(note) {
const toImport = Object.assign({}, note);
delete toImport.id;
delete toImport.updated_time;
delete toImport.created_time;
delete toImport.encryption_applied;
delete toImport.encryption_cipher_text;
const folder = await this.restoreFolder();
toImport.parent_id = folder.id;
await Note.save(toImport);
}
async maintenance() {
const startTime = Date.now();
this.logger().info('RevisionService::maintenance: Starting...');
if (!Setting.value('revisionService.enabled')) {
this.logger().info('RevisionService::maintenance: Service is disabled');
// We do as if we had processed all the latest changes so that they can be cleaned up
// later on by ItemChangeUtils.deleteProcessedChanges().
Setting.setValue('revisionService.lastProcessedChangeId', await ItemChange.lastChangeId());
await this.deleteOldRevisions(Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000);
} else {
this.logger().info('RevisionService::maintenance: Service is enabled');
await this.collectRevisions();
await this.deleteOldRevisions(Setting.value('revisionService.ttlDays') * 24 * 60 * 60 * 1000);
}
this.logger().info('RevisionService::maintenance: Done in ' + (Date.now() - startTime) + 'ms');
}
runInBackground(collectRevisionInterval = null) {
if (this.isRunningInBackground_) return;
this.isRunningInBackground_ = true;
if (collectRevisionInterval === null) collectRevisionInterval = 1000 * 60 * 10;
this.logger().info('RevisionService::runInBackground: Starting background service with revision collection interval ' + collectRevisionInterval);
setTimeout(() => {
this.maintenance();
}, 1000 * 4);
shim.setInterval(() => {
this.maintenance();
}, collectRevisionInterval);
}
}
module.exports = RevisionService;

View File

@ -2,6 +2,7 @@ const BaseItem = require('lib/models/BaseItem.js');
const Folder = require('lib/models/Folder.js'); const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js'); const Note = require('lib/models/Note.js');
const Resource = require('lib/models/Resource.js'); const Resource = require('lib/models/Resource.js');
const ItemChange = require('lib/models/ItemChange.js');
const ResourceLocalState = require('lib/models/ResourceLocalState.js'); const ResourceLocalState = require('lib/models/ResourceLocalState.js');
const MasterKey = require('lib/models/MasterKey.js'); const MasterKey = require('lib/models/MasterKey.js');
const BaseModel = require('lib/BaseModel.js'); const BaseModel = require('lib/BaseModel.js');
@ -325,7 +326,7 @@ class Synchronizer {
if (action == "createRemote" || action == "updateRemote") { if (action == "createRemote" || action == "updateRemote") {
let canSync = true; let canSync = true;
try { try {
if (this.testingHooks_.indexOf("rejectedByTarget") >= 0) throw new JoplinError("Testing rejectedByTarget", "rejectedByTarget"); if (this.testingHooks_.indexOf("notesRejectedByTarget") >= 0 && local.type_ === BaseModel.TYPE_NOTE) throw new JoplinError("Testing rejectedByTarget", "rejectedByTarget");
const content = await ItemClass.serializeForSync(local); const content = await ItemClass.serializeForSync(local);
await this.api().put(path, content); await this.api().put(path, content);
} catch (error) { } catch (error) {
@ -370,9 +371,9 @@ class Synchronizer {
local = remoteContent; local = remoteContent;
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs()); const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
await ItemClass.save(local, { autoTimestamp: false, nextQueries: syncTimeQueries }); await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
} else { } else {
await ItemClass.delete(local.id); await ItemClass.delete(local.id, { changeSource: ItemChange.SOURCE_SYNC });
} }
} else if (action == "noteConflict") { } else if (action == "noteConflict") {
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
@ -395,7 +396,7 @@ class Synchronizer {
let conflictedNote = Object.assign({}, local); let conflictedNote = Object.assign({}, local);
delete conflictedNote.id; delete conflictedNote.id;
conflictedNote.is_conflict = 1; conflictedNote.is_conflict = 1;
await Note.save(conflictedNote, { autoTimestamp: false }); await Note.save(conflictedNote, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC });
} }
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
@ -406,12 +407,12 @@ class Synchronizer {
if (remote) { if (remote) {
local = remoteContent; local = remoteContent;
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs()); const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
await ItemClass.save(local, { autoTimestamp: false, nextQueries: syncTimeQueries }); await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
if (!!local.encryption_applied) this.dispatch({ type: "SYNC_GOT_ENCRYPTED_ITEM" }); if (!!local.encryption_applied) this.dispatch({ type: "SYNC_GOT_ENCRYPTED_ITEM" });
} else { } else {
// Remote no longer exists (note deleted) so delete local one too // Remote no longer exists (note deleted) so delete local one too
await ItemClass.delete(local.id); await ItemClass.delete(local.id, { changeSource: ItemChange.SOURCE_SYNC });
} }
} }
@ -535,6 +536,8 @@ class Synchronizer {
} }
} }
if (this.testingHooks_.indexOf('skipRevisions') >= 0 && content && content.type_ === BaseModel.TYPE_REVISION) action = null;
if (!action) continue; if (!action) continue;
this.logSyncOperation(action, local, remote, reason); this.logSyncOperation(action, local, remote, reason);
@ -557,30 +560,13 @@ class Synchronizer {
let options = { let options = {
autoTimestamp: false, autoTimestamp: false,
nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, content, time.unixMs()), nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, content, time.unixMs()),
changeSource: ItemChange.SOURCE_SYNC,
}; };
if (action == "createLocal") options.isNew = true; if (action == "createLocal") options.isNew = true;
if (action == "updateLocal") options.oldItem = local; if (action == "updateLocal") options.oldItem = local;
const creatingNewResource = content.type_ == BaseModel.TYPE_RESOURCE && action == "createLocal"; const creatingNewResource = content.type_ == BaseModel.TYPE_RESOURCE && action == "createLocal";
// if (content.type_ == BaseModel.TYPE_RESOURCE && action == "createLocal") {
// let localResourceContentPath = Resource.fullPath(content);
// let remoteResourceContentPath = this.resourceDirName_ + "/" + content.id;
// try {
// await this.api().get(remoteResourceContentPath, { path: localResourceContentPath, target: "file" });
// } catch (error) {
// if (error.code === 'rejectedByTarget') {
// this.progressReport_.errors.push(error);
// this.logger().warn('Rejected by target: ' + path + ': ' + error.message);
// continue;
// } else {
// throw error;
// }
// }
// }
// if (creatingNewResource) content.fetch_status = Resource.FETCH_STATUS_IDLE;
if (creatingNewResource) { if (creatingNewResource) {
await ResourceLocalState.save({ resource_id: content.id, fetch_status: Resource.FETCH_STATUS_IDLE }); await ResourceLocalState.save({ resource_id: content.id, fetch_status: Resource.FETCH_STATUS_IDLE });
} }
@ -608,7 +594,7 @@ class Synchronizer {
} }
let ItemClass = BaseItem.itemClass(local.type_); let ItemClass = BaseItem.itemClass(local.type_);
await ItemClass.delete(local.id, { trackDeleted: false }); await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC });
} }
} }
@ -653,7 +639,7 @@ class Synchronizer {
// CONFLICT // CONFLICT
await Folder.markNotesAsConflict(item.id); await Folder.markNotesAsConflict(item.id);
} }
await Folder.delete(item.id, { deleteChildren: false, trackDeleted: false }); await Folder.delete(item.id, { deleteChildren: false, changeSource: ItemChange.SOURCE_SYNC, trackDeleted: false });
} }
} }

View File

@ -2325,6 +2325,11 @@
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E=" "integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E="
}, },
"diff-match-patch": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.4.tgz",
"integrity": "sha512-Uv3SW8bmH9nAtHKaKSanOQmj2DnlH65fUpcrMdfdaOxUG02QQ4YGZ8AE7kKOMisF7UqvOlGKVYWRvezdncW9lg=="
},
"dom-walk": { "dom-walk": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz",
@ -2680,7 +2685,8 @@
}, },
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true "bundled": true,
"optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
@ -2698,11 +2704,13 @@
}, },
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true "bundled": true,
"optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@ -2715,15 +2723,18 @@
}, },
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true "bundled": true,
"optional": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true "bundled": true,
"optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true "bundled": true,
"optional": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
@ -2826,7 +2837,8 @@
}, },
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"bundled": true "bundled": true,
"optional": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
@ -2836,6 +2848,7 @@
"is-fullwidth-code-point": { "is-fullwidth-code-point": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"number-is-nan": "^1.0.0" "number-is-nan": "^1.0.0"
} }
@ -2848,17 +2861,20 @@
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }
}, },
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"bundled": true "bundled": true,
"optional": true
}, },
"minipass": { "minipass": {
"version": "2.2.4", "version": "2.2.4",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"safe-buffer": "^5.1.1", "safe-buffer": "^5.1.1",
"yallist": "^3.0.0" "yallist": "^3.0.0"
@ -2875,6 +2891,7 @@
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
} }
@ -2947,7 +2964,8 @@
}, },
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true "bundled": true,
"optional": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
@ -2957,6 +2975,7 @@
"once": { "once": {
"version": "1.4.0", "version": "1.4.0",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"wrappy": "1" "wrappy": "1"
} }
@ -3032,7 +3051,8 @@
}, },
"safe-buffer": { "safe-buffer": {
"version": "5.1.1", "version": "5.1.1",
"bundled": true "bundled": true,
"optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@ -3062,6 +3082,7 @@
"string-width": { "string-width": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"code-point-at": "^1.0.0", "code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0", "is-fullwidth-code-point": "^1.0.0",
@ -3079,6 +3100,7 @@
"strip-ansi": { "strip-ansi": {
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "bundled": true,
"optional": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-regex": "^2.0.0"
} }
@ -3117,11 +3139,13 @@
}, },
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true "bundled": true,
"optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.2", "version": "3.0.2",
"bundled": true "bundled": true,
"optional": true
} }
} }
}, },
@ -4123,7 +4147,7 @@
}, },
"load-json-file": { "load-json-file": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
"integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
"requires": { "requires": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
@ -5278,7 +5302,7 @@
}, },
"load-json-file": { "load-json-file": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
"integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
"requires": { "requires": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
@ -7372,7 +7396,7 @@
"dependencies": { "dependencies": {
"uuid": { "uuid": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz", "resolved": "http://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz",
"integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=" "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE="
} }
} }

View File

@ -13,6 +13,7 @@
"base-64": "^0.1.0", "base-64": "^0.1.0",
"buffer": "^5.0.8", "buffer": "^5.0.8",
"diacritics": "^1.3.0", "diacritics": "^1.3.0",
"diff-match-patch": "^1.0.4",
"events": "^1.1.1", "events": "^1.1.1",
"form-data": "^2.1.4", "form-data": "^2.1.4",
"highlight.js": "^9.15.6", "highlight.js": "^9.15.6",

View File

@ -22,9 +22,11 @@ const Tag = require('lib/models/Tag.js');
const NoteTag = require('lib/models/NoteTag.js'); const NoteTag = require('lib/models/NoteTag.js');
const BaseItem = require('lib/models/BaseItem.js'); const BaseItem = require('lib/models/BaseItem.js');
const MasterKey = require('lib/models/MasterKey.js'); const MasterKey = require('lib/models/MasterKey.js');
const Revision = require('lib/models/Revision.js');
const BaseModel = require('lib/BaseModel.js'); const BaseModel = require('lib/BaseModel.js');
const BaseService = require('lib/services/BaseService.js'); const BaseService = require('lib/services/BaseService.js');
const ResourceService = require('lib/services/ResourceService'); const ResourceService = require('lib/services/ResourceService');
const RevisionService = require('lib/services/RevisionService');
const { JoplinDatabase } = require('lib/joplin-database.js'); const { JoplinDatabase } = require('lib/joplin-database.js');
const { Database } = require('lib/database.js'); const { Database } = require('lib/database.js');
const { NotesScreen } = require('lib/components/screens/notes.js'); const { NotesScreen } = require('lib/components/screens/notes.js');
@ -396,6 +398,7 @@ async function initialize(dispatch) {
BaseItem.loadClass('Tag', Tag); BaseItem.loadClass('Tag', Tag);
BaseItem.loadClass('NoteTag', NoteTag); BaseItem.loadClass('NoteTag', NoteTag);
BaseItem.loadClass('MasterKey', MasterKey); BaseItem.loadClass('MasterKey', MasterKey);
BaseItem.loadClass('Revision', Revision);
const fsDriver = new FsDriverRN(); const fsDriver = new FsDriverRN();
@ -430,6 +433,8 @@ async function initialize(dispatch) {
reg.logger().info('db.ftsEnabled = ', Setting.value('db.ftsEnabled')); reg.logger().info('db.ftsEnabled = ', Setting.value('db.ftsEnabled'));
} }
BaseItem.revisionService_ = RevisionService.instance();
// Note: for now we hard-code the folder sort order as we need to // Note: for now we hard-code the folder sort order as we need to
// create a UI to allow customisation (started in branch mobile_add_sidebar_buttons) // create a UI to allow customisation (started in branch mobile_add_sidebar_buttons)
Setting.setValue('folders.sortOrder.field', 'title'); Setting.setValue('folders.sortOrder.field', 'title');
@ -526,6 +531,10 @@ async function initialize(dispatch) {
await WelcomeUtils.install(dispatch); await WelcomeUtils.install(dispatch);
// Collect revisions more frequently on mobile because it doesn't auto-save
// and it cannot collect anything when the app is not active.
RevisionService.instance().runInBackground(1000 * 30);
reg.logger().info('Application initialized'); reg.logger().info('Application initialized');
} }