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

All: Add mechanism to lock and upgrade sync targets (#3524)

This commit is contained in:
Laurent 2020-08-02 12:28:50 +01:00 committed by GitHub
parent 88f22fabf7
commit 0c147236a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
138 changed files with 3686 additions and 647 deletions

View File

@ -61,6 +61,8 @@ Modules/TinyMCE/IconPack/postinstall.js
Modules/TinyMCE/langs/
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
CliClient/tests/synchronizer_LockHandler.js
CliClient/tests/synchronizer_MigrationHandler.js
CliClient/app/LinkSelector.js
CliClient/build/LinkSelector.js
ElectronClient/commands/focusElement.js
@ -133,6 +135,7 @@ ElectronClient/gui/NoteList/commands/focusElementNoteList.js
ElectronClient/gui/NoteListItem.js
ElectronClient/gui/NoteToolbar/NoteToolbar.js
ElectronClient/gui/ResourceScreen.js
ElectronClient/gui/Root_UpgradeSyncTarget.js
ElectronClient/gui/ShareNoteDialog.js
ElectronClient/gui/SideBar/commands/focusElementSideBar.js
ReactNativeClient/lib/AsyncActionQueue.js
@ -140,6 +143,7 @@ ReactNativeClient/lib/checkPermissions.js
ReactNativeClient/lib/commands/historyBackward.js
ReactNativeClient/lib/commands/historyForward.js
ReactNativeClient/lib/commands/synchronize.js
ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js
ReactNativeClient/lib/hooks/useEffectDebugger.js
ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js
ReactNativeClient/lib/hooks/usePrevious.js
@ -161,6 +165,12 @@ ReactNativeClient/lib/services/ResourceEditWatcher/reducer.js
ReactNativeClient/lib/services/rest/actionApi.desktop.js
ReactNativeClient/lib/services/rest/errors.js
ReactNativeClient/lib/services/SettingUtils.js
ReactNativeClient/lib/services/synchronizer/gui/useSyncTargetUpgrade.js
ReactNativeClient/lib/services/synchronizer/LockHandler.js
ReactNativeClient/lib/services/synchronizer/MigrationHandler.js
ReactNativeClient/lib/services/synchronizer/migrations/1.js
ReactNativeClient/lib/services/synchronizer/migrations/2.js
ReactNativeClient/lib/services/synchronizer/utils/types.js
ReactNativeClient/lib/services/UndoRedoService.js
ReactNativeClient/lib/ShareExtension.js
ReactNativeClient/lib/shareHandler.js

10
.gitignore vendored
View File

@ -52,6 +52,8 @@ Tools/commit_hook.txt
*.map
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
CliClient/tests/synchronizer_LockHandler.js
CliClient/tests/synchronizer_MigrationHandler.js
CliClient/app/LinkSelector.js
CliClient/build/LinkSelector.js
ElectronClient/commands/focusElement.js
@ -124,6 +126,7 @@ ElectronClient/gui/NoteList/commands/focusElementNoteList.js
ElectronClient/gui/NoteListItem.js
ElectronClient/gui/NoteToolbar/NoteToolbar.js
ElectronClient/gui/ResourceScreen.js
ElectronClient/gui/Root_UpgradeSyncTarget.js
ElectronClient/gui/ShareNoteDialog.js
ElectronClient/gui/SideBar/commands/focusElementSideBar.js
ReactNativeClient/lib/AsyncActionQueue.js
@ -131,6 +134,7 @@ ReactNativeClient/lib/checkPermissions.js
ReactNativeClient/lib/commands/historyBackward.js
ReactNativeClient/lib/commands/historyForward.js
ReactNativeClient/lib/commands/synchronize.js
ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js
ReactNativeClient/lib/hooks/useEffectDebugger.js
ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js
ReactNativeClient/lib/hooks/usePrevious.js
@ -152,6 +156,12 @@ ReactNativeClient/lib/services/ResourceEditWatcher/reducer.js
ReactNativeClient/lib/services/rest/actionApi.desktop.js
ReactNativeClient/lib/services/rest/errors.js
ReactNativeClient/lib/services/SettingUtils.js
ReactNativeClient/lib/services/synchronizer/gui/useSyncTargetUpgrade.js
ReactNativeClient/lib/services/synchronizer/LockHandler.js
ReactNativeClient/lib/services/synchronizer/MigrationHandler.js
ReactNativeClient/lib/services/synchronizer/migrations/1.js
ReactNativeClient/lib/services/synchronizer/migrations/2.js
ReactNativeClient/lib/services/synchronizer/utils/types.js
ReactNativeClient/lib/services/UndoRedoService.js
ReactNativeClient/lib/ShareExtension.js
ReactNativeClient/lib/shareHandler.js

View File

@ -69,7 +69,7 @@ script:
# and that would break the desktop release.
if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
cd CliClient
npm run test
npm run test-ci
testResult=$?
if [ $testResult -ne 0 ]; then
exit $testResult

View File

@ -20,4 +20,6 @@ out.txt
linkToLocal.sh
yarn-error.log
tests/support/dropbox-auth.txt
tests/support/nextcloud-auth.json
tests/support/onedrive-auth.txt
build/

View File

@ -2,6 +2,7 @@ const yargParser = require('yargs-parser');
const { _ } = require('lib/locale.js');
const { time } = require('lib/time-utils.js');
const stringPadding = require('string-padding');
const { Logger } = require('lib/logger.js');
const cliUtils = {};
@ -245,4 +246,17 @@ cliUtils.redrawDone = function() {
redrawStarted_ = false;
};
cliUtils.stdoutLogger = function(stdout) {
const stdoutFn = (...s) => stdout(s.join(' '));
const logger = new Logger();
logger.addTarget('console', { console: {
info: stdoutFn,
warn: stdoutFn,
error: stdoutFn,
} });
return logger;
};
module.exports = { cliUtils };

View File

@ -11,6 +11,7 @@ const md5 = require('md5');
const locker = require('proper-lockfile');
const fs = require('fs-extra');
const SyncTargetRegistry = require('lib/SyncTargetRegistry');
const MigrationHandler = require('lib/services/synchronizer/MigrationHandler').default;
class Command extends BaseCommand {
constructor() {
@ -29,7 +30,10 @@ class Command extends BaseCommand {
}
options() {
return [['--target <target>', _('Sync to provided target (defaults to sync.target config value)')]];
return [
['--target <target>', _('Sync to provided target (defaults to sync.target config value)')],
['--upgrade', _('Upgrade the sync target to the latest version.')],
];
}
static lockFile(filePath) {
@ -148,12 +152,8 @@ class Command extends BaseCommand {
const syncTarget = reg.syncTarget(this.syncTargetId_);
if (!(await syncTarget.isAuthenticated())) {
app()
.gui()
.showConsole();
app()
.gui()
.maximizeConsole();
app().gui().showConsole();
app().gui().maximizeConsole();
const authDone = await this.doAuth();
if (!authDone) return cleanUp();
@ -176,6 +176,34 @@ class Command extends BaseCommand {
if (!sync) throw new Error(_('Cannot initialise synchroniser.'));
if (args.options.upgrade) {
let migrationError = null;
try {
const migrationHandler = new MigrationHandler(
sync.api(),
sync.lockHandler(),
Setting.value('appType'),
Setting.value('clientId')
);
migrationHandler.setLogger(cliUtils.stdoutLogger(this.stdout.bind(this)));
await migrationHandler.upgrade();
} catch (error) {
migrationError = error;
}
if (!migrationError) {
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_IDLE);
await Setting.saveAll();
}
if (migrationError) throw migrationError;
return cleanUp();
}
this.stdout(_('Starting synchronisation...'));
const contextKey = `sync.${this.syncTargetId_}.context`;
@ -210,6 +238,12 @@ class Command extends BaseCommand {
throw error;
}
if (Setting.value('sync.upgradeState') > Setting.SYNC_UPGRADE_STATE_IDLE) {
this.stdout(`/!\\ ${_('Sync target must be upgraded! Run `%s` to proceed.', 'sync --upgrade')}`);
app().gui().showConsole();
app().gui().maximizeConsole();
}
cleanUp();
}

View File

@ -38,6 +38,8 @@ tasks.prepareTestBuild = {
'lib/',
'locales/',
'node_modules/',
'*.ts',
'*.tsx',
],
});

View File

@ -4,7 +4,8 @@
"license": "MIT",
"author": "Laurent Cozic",
"scripts": {
"test": "gulp buildTests -L && node node_modules/jasmine/bin/jasmine.js --config=tests/support/jasmine.json",
"test": "gulp buildTests -L && node node_modules/jasmine/bin/jasmine.js --fail-fast=true --config=tests/support/jasmine.json",
"test-ci": "gulp buildTests -L && node node_modules/jasmine/bin/jasmine.js --config=tests/support/jasmine.json",
"postinstall": "npm run build && patch-package --patch-dir ../patches",
"build": "gulp build",
"start": "gulp build -L && node 'build/main.js' --stack-trace-enabled --log-level debug --env dev"

View File

@ -4,8 +4,6 @@ const { asyncTest, setupDatabaseAndSynchronizer, switchClient } = require('test-
const { shim } = require('lib/shim');
const { enexXmlToHtml } = require('lib/import-enex-html-gen.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit
process.on('unhandledRejection', (reason, p) => {
console.warn('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -12,8 +12,6 @@ const BaseModel = require('lib/BaseModel.js');
const { shim } = require('lib/shim');
const { enexXmlToMd } = require('lib/import-enex-md-gen.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -13,8 +13,6 @@ const { shim } = require('lib/shim');
const HtmlToHtml = require('lib/joplin-renderer/HtmlToHtml');
const { enexXmlToMd } = require('lib/import-enex-md-gen.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -13,8 +13,6 @@ const { shim } = require('lib/shim');
const HtmlToMd = require('lib/HtmlToMd');
const { enexXmlToMd } = require('lib/import-enex-md-gen.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -14,8 +14,6 @@ const MdToHtml = require('lib/joplin-renderer/MdToHtml');
const { enexXmlToMd } = require('lib/import-enex-md-gen.js');
const { themeStyle } = require('lib/theme');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60 * 60 * 1000; // Can run for a while since everything is in the same test unit
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -9,118 +9,126 @@ const { shim } = require('lib/shim.js');
const fs = require('fs-extra');
const Setting = require('lib/models/Setting.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
let api = null;
const api = null;
// NOTE: These tests work with S3 and memory driver, but not
// with other targets like file system or Nextcloud.
// All this is tested in an indirect way in tests/synchronizer
// anyway.
// We keep the file here as it could be useful as a spec for
// what calls a sync target should support, but it would
// need to be fixed first.
// To test out an FileApi implementation:
// * add a SyncTarget for your driver in `test-utils.js`
// * set `syncTargetId_` to your New SyncTarget:
// `const syncTargetId_ = SyncTargetRegistry.nameToId('memory');`
describe('fileApi', function() {
// describe('fileApi', function() {
beforeEach(async (done) => {
api = new fileApi();
api.clearRoot();
done();
});
// beforeEach(async (done) => {
// api = new fileApi();
// api.clearRoot();
// done();
// });
describe('list', function() {
it('should return items with relative path', asyncTest(async () => {
await api.mkdir('.subfolder');
await api.put('1', 'something on root 1');
await api.put('.subfolder/1', 'something subfolder 1');
await api.put('.subfolder/2', 'something subfolder 2');
await api.put('.subfolder/3', 'something subfolder 3');
sleep(0.8);
// describe('list', function() {
// it('should return items with relative path', asyncTest(async () => {
// await api.mkdir('.subfolder');
// await api.put('1', 'something on root 1');
// await api.put('.subfolder/1', 'something subfolder 1');
// await api.put('.subfolder/2', 'something subfolder 2');
// await api.put('.subfolder/3', 'something subfolder 3');
// sleep(0.8);
const response = await api.list('.subfolder');
const items = response.items;
expect(items.length).toBe(3);
expect(items[0].path).toBe('1');
}));
// const response = await api.list('.subfolder');
// const items = response.items;
// expect(items.length).toBe(3);
// expect(items[0].path).toBe('1');
// }));
it('should default to only files on root directory', asyncTest(async () => {
await api.mkdir('.subfolder');
await api.put('.subfolder/1', 'something subfolder 1');
await api.put('file1', 'something 1');
await api.put('file2', 'something 2');
sleep(0.6);
// it('should default to only files on root directory', asyncTest(async () => {
// await api.mkdir('.subfolder');
// await api.put('.subfolder/1', 'something subfolder 1');
// await api.put('file1', 'something 1');
// await api.put('file2', 'something 2');
// sleep(0.6);
const response = await api.list();
expect(response.items.length).toBe(2);
}));
}); // list
// const response = await api.list();
// expect(response.items.length).toBe(2);
// }));
// }); // list
describe('delete', function() {
it('should not error if file does not exist', asyncTest(async () => {
const hasThrown = await checkThrowAsync(async () => await api.delete('nonexistant_file'));
expect(hasThrown).toBe(false);
}));
// describe('delete', function() {
// it('should not error if file does not exist', asyncTest(async () => {
// const hasThrown = await checkThrowAsync(async () => await api.delete('nonexistant_file'));
// expect(hasThrown).toBe(false);
// }));
it('should delete specific file given full path', asyncTest(async () => {
await api.mkdir('deleteDir');
await api.put('deleteDir/1', 'something 1');
await api.put('deleteDir/2', 'something 2');
sleep(0.4);
// it('should delete specific file given full path', asyncTest(async () => {
// await api.mkdir('deleteDir');
// await api.put('deleteDir/1', 'something 1');
// await api.put('deleteDir/2', 'something 2');
// sleep(0.4);
await api.delete('deleteDir/1');
let response = await api.list('deleteDir');
expect(response.items.length).toBe(1);
response = await api.list('deleteDir/1');
expect(response.items.length).toBe(0);
}));
}); // delete
// await api.delete('deleteDir/1');
// let response = await api.list('deleteDir');
// expect(response.items.length).toBe(1);
// response = await api.list('deleteDir/1');
// expect(response.items.length).toBe(0);
// }));
// }); // delete
describe('get', function() {
it('should return null if object does not exist', asyncTest(async () => {
const response = await api.get('nonexistant_file');
expect(response).toBe(null);
}));
// describe('get', function() {
// it('should return null if object does not exist', asyncTest(async () => {
// const response = await api.get('nonexistant_file');
// expect(response).toBe(null);
// }));
it('should return UTF-8 encoded string by default', asyncTest(async () => {
await api.put('testnote.md', 'something 2');
// it('should return UTF-8 encoded string by default', asyncTest(async () => {
// await api.put('testnote.md', 'something 2');
const response = await api.get('testnote.md');
expect(response).toBe('something 2');
}));
// const response = await api.get('testnote.md');
// expect(response).toBe('something 2');
// }));
it('should return a Response object and writes file to options.path, if options.target is "file"', asyncTest(async () => {
const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
await api.put('testnote.md', 'something 2');
sleep(0.2);
// it('should return a Response object and writes file to options.path, if options.target is "file"', asyncTest(async () => {
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
// await api.put('testnote.md', 'something 2');
// sleep(0.2);
const response = await api.get('testnote.md', { target: 'file', path: localFilePath });
expect(typeof response).toBe('object');
// expect(response.path).toBe(localFilePath);
expect(fs.existsSync(localFilePath)).toBe(true);
expect(fs.readFileSync(localFilePath, 'utf8')).toBe('something 2');
}));
}); // get
// const response = await api.get('testnote.md', { target: 'file', path: localFilePath });
// expect(typeof response).toBe('object');
// // expect(response.path).toBe(localFilePath);
// expect(fs.existsSync(localFilePath)).toBe(true);
// expect(fs.readFileSync(localFilePath, 'utf8')).toBe('something 2');
// }));
// }); // get
describe('put', function() {
it('should create file to remote path and content', asyncTest(async () => {
await api.put('putTest.md', 'I am your content');
sleep(0.2);
// describe('put', function() {
// it('should create file to remote path and content', asyncTest(async () => {
// await api.put('putTest.md', 'I am your content');
// sleep(0.2);
const response = await api.get('putTest.md');
expect(response).toBe('I am your content');
}));
// const response = await api.get('putTest.md');
// expect(response).toBe('I am your content');
// }));
it('should upload file in options.path to remote path, if options.source is "file"', asyncTest(async () => {
const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
fs.writeFileSync(localFilePath, 'I am the local file.');
// it('should upload file in options.path to remote path, if options.source is "file"', asyncTest(async () => {
// const localFilePath = `${Setting.value('tempDir')}/${uuid.create()}.md`;
// fs.writeFileSync(localFilePath, 'I am the local file.');
await api.put('testfile', 'ignore me', { source: 'file', path: localFilePath });
sleep(0.2);
// await api.put('testfile', 'ignore me', { source: 'file', path: localFilePath });
// sleep(0.2);
const response = await api.get('testfile');
expect(response).toBe('I am the local file.');
}));
}); // put
// const response = await api.get('testfile');
// expect(response).toBe('I am the local file.');
// }));
// }); // put
});
// });

View File

@ -10,8 +10,6 @@ const Resource = require('lib/models/Resource.js');
const BaseModel = require('lib/BaseModel.js');
const { shim } = require('lib/shim');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; // The first test is slow because the database needs to be built
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -19,8 +19,6 @@ process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; // The first test is slow because the database needs to be built
let service = null;
describe('services_EncryptionService', function() {

View File

@ -15,8 +15,6 @@ const ArrayUtils = require('lib/ArrayUtils');
const ObjectUtils = require('lib/ObjectUtils');
const { shim } = require('lib/shim.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -11,8 +11,6 @@ const Resource = require('lib/models/Resource.js');
const Note = require('lib/models/Note.js');
const { shim } = require('lib/shim.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
const exportDir = `${__dirname}/export`;
process.on('unhandledRejection', (reason, p) => {

View File

@ -9,8 +9,6 @@ process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
function setupStore() {
const store = KvStore.instance();
store.setDb(db());

View File

@ -23,8 +23,6 @@ process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
function exportDir() {
return `${__dirname}/export`;
}

View File

@ -10,8 +10,6 @@
// console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
// });
// jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
// describe('services_UndoRedoService', function() {
// beforeEach(async (done) => {

View File

@ -11,8 +11,6 @@ const Tag = require('lib/models/Tag');
const NoteTag = require('lib/models/NoteTag');
const { shim } = require('lib/shim');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});

View File

@ -4,6 +4,6 @@
"*.js",
"!test-utils.js"
],
"stopSpecOnExpectationFailure": true,
"stopSpecOnExpectationFailure": false,
"random": true
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
2020-07-16: In the new sync format, the version number is stored in /info.json. However, for backward compatibility, we need to keep the old version.txt file here, otherwise old clients will automatically recreate it, and assume a sync target version 1. So we keep it here but set its value to "2", so that old clients know that they need to be upgraded. This directory can be removed after a year or so, once we are confident that all clients have been upgraded to recent versions.

View File

@ -0,0 +1,24 @@
id: 04c4e932fe3c4c4a9450c09208bd6c21
parent_id: 3d675395b5cd4d1e9d7ca4f045f41493
created_time:
updated_time: 2020-07-25T10:55:20.793Z
is_conflict:
latitude:
longitude:
altitude:
author:
source_url:
is_todo:
todo_due:
todo_completed:
source:
source_application:
application_data:
order:
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000470{"iv":"gJXa88pt5ZzaYAlD4ZCSkA==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"8jM7A4Kx9RfLOHz/uriiE3r5zz6AwnUNVaHFiiF3Sl5La7spsW0hAzoAJuUHncbIE9Rks6RH5g+k7Y4J5zxlpHlsbXYFzvYyh706kemlwg/k/lDPkMK47BPPYICcCCOeUvc+IXAjgcr52RX29yuBYhqQDyJVhFgkaEmbYYmYXbomZrzOWeXF4O0nqVFt7sawFUg3Vu0+gESRK5SV24OL6l9Qw6rRh+kwCO0E8piKq31CDrx3741E3X2LOR2jw6i+EIs/pyPMRDPWg8o3YOF5Ka1u4NkMXM14pSc/VYFiJoR2kDh4alXKIaJaJeIRjQ/rWNz42s3eT/GpoIRxPKwfG5DwD8xT5Ns5HJ0aD2E3es++BjOSeeqi8MkeCdyHLtgIQB9FaILLMtzGu6lUwP6VlxJ7HJhvtA1T+tfKMlTrzNYxJCcKGg2IUU+Qv+8LnSjybIia5weKj58emRNAXyVAWT6CQupoe1c3XxM6hPInggOZkghQOV4rpMuKniZ3DMIuddgFpdXtD8q4pemQkXMQfRtUafyqz69zH9Bkn4RS7yEROfRfqi5TrAUTGnTyPnyAQdyORLHCNBJBheHMa+s37LSe0ZBYmGxhnl1VMWjvUatIZphF7EUlseFC3jzxMv5fYsi8BbmpyLoqH56Odogc6e+ToPuBvRuVdIBHXfT4k5B62+KkWAmedBkjwJIgp6EWaaTGmVwwBL/xWgkKL87qsMmMn+Obj/e/3cDBQm0ubg8gvsWiGsA0cL0jBM3QKMgTb1D123zG2s8DWdIoSSByjwW4sVkb9hH6v3RCPoOyxV1JubcuDsbGnnrL9hhmx/5ahHnoHhUD2vYVLsI5xRmktEUpBbf9BcMY6RGhUXWM40djLLQuTAbYFF49o9X233BlbQrQndEnF6mhy3vLSotJdeGc8PWbSenicXaqJm/2U2beCAeQ5TgcDg5hlru1JitD2TIvJuv5e7Z1UFXSc/P2TEFsIP549bCt7gDpJl8="}
encryption_applied: 1
markup_language:
is_shared:
type_: 1

View File

@ -0,0 +1,24 @@
id: 26b9c0dc3ff146ed99031e259bc1240b
parent_id: 3d675395b5cd4d1e9d7ca4f045f41493
created_time:
updated_time: 2020-07-25T10:55:20.807Z
is_conflict:
latitude:
longitude:
altitude:
author:
source_url:
is_todo:
todo_due:
todo_completed:
source:
source_application:
application_data:
order:
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000470{"iv":"4LKOBhs4VTm2kEFjWsE66g==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"T4CSUbg3Ma11ffHKa8t6Zfu42DYcKGv6+p87c/V4wXsnHEaLFk2vV9iZg0CDOpAfOZaOCBrWSUsUI3FYIWjIEg8Wb2GsIrYz5i03B68yAjjN4hDtulyuLAlFzQUWw6xyPwaCebHUZHekearKFL/sQl2aNClxsKz/uT1/g27ANiilywFxp+GwQ/MxI0YhR7Qdr7Ri6YbySOSVExInQqMY2ERsWhgLQpR0NFSZLVIi3wJmlG4PR4uAxJgHu29Z2tvaMjW/lKUbCaqt5vsbP4qw1BqgNjt8it0qLkf9TwW3y4sxMCXI5zo5cdlwbMEcuDxvU2zfDBfWHgnIIekSR3csOTBswpQ/csXIKyZomlP+7ys7liGtoDssCrbH8YBwGW1KkzBmaKYhP9Q1JOWC8/HWI/YBkSWX0AQIhkwUjPVY+WgzfKUFD4XpUaKm9xmSCfj67GqSFUlB0MhTer1aFj4L3T/YOSZQevaUwmcnJzRpAmArhEAnHlrm4DO5AqubrAd88hPGWiCo5Ud9PYsLBeDiRJaQroA0VCOuzGLTyq5p/YfPedNBI08cN5cLuKc8bx5W4HBp6ZYv6oDkkbR9eZQyY2qbvkZoOQDlG9QKrenvIprrtRgFCO9NUfEYzxApSpyzVRfBv4LFxRTlpjihaGxKcA77U7xfqszFxJNyQj49hK8/SWR+/m4TOmqMRxwWm638t/NYz1MhLU2Vee9tA847ITVbZCgpxKRCVSJytYuHi8gshrTom/zjJPMJxoZnQpoWRB9nEbd2s+Tqev7U5lLPiMwjBzwNNnRLKkI2EFRxIWhOxSUwZuxGSIzhb8dAC1T6/ngwvtFTmYZxx3/XYZnsZO47IeYUbHb9SPbUSUXoQeU9rNDekcWusfrbod9Y7ON0aHoiytX54xLFOTUXyFLY7OeGM0C6QeumvOLTJ/WLHj6ReBB0vVipJULfNW5r4GEhD3Cf8uMf99mqXIYOFpBJj/WQesO72R9PjYrjk7c="}
encryption_applied: 1
markup_language:
is_shared:
type_: 1

View File

@ -0,0 +1,24 @@
id: 3a8eaf72f62847689176a952a0b321a0
parent_id: aa7dca873bdc47beaa9465e04610619d
created_time:
updated_time: 2020-07-25T10:55:20.782Z
is_conflict:
latitude:
longitude:
altitude:
author:
source_url:
is_todo:
todo_due:
todo_completed:
source:
source_application:
application_data:
order:
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0004c8{"iv":"iOtmBH1egyWEQ4howS5ssQ==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"BbJdyFskBpsOLSHMogZbAF4GgPJsY07EwoQjmiZl2WDT6QezrTnLQ4BsYjMLiLHphtjs3MqxK2p11cfQLPY4fAoVywQeTLFjMyncWsnAFZaDnCvZ/VCwbI3yTi/XOVgQ342AAIZFQ47tAkNFmH6CEgLK+g9BW2fcBxHcnky70XfyE7FI3HNl/d0qGIBWViv6LJKl5F4ywbky75/Myb029g3u+TQvmxZXDWjbDewWFdvIj82KdR12XSf+oQJQpeuwR2yHioX8qjTC4/3bLeQ9CumCRHrrSpxTqjCcvMH3w3+OnfMnmT83MxQa+mQJzMhZCaFbeEw5Mvbs1lgcyhsCC6yWDmgTFwhfJfz6KqZxtmkqiLt+E26ej8nd344DaEJ2GSHrbpkgIgkzYJndtiFGWnHnMoKKKsgAyOcTB85mQRvwZqsx+StKGkdNkqfbSQhtspi3XWr6TchjJgE2sqchWMZqyk6HSGVNVROptJZELPyEBDqG+vq7JrHPEagUCGUYBHEL2ZfKIy6ecU31Q7Hmpgpqyct/iRKr8mKrCLPbrUGiL27Yov7Mn8+MDwnpyGSw8tGYG29LCOADAkbSHl8dtYsF7dQl0VULipdwVv5KRO0ZFzacIu5/ciw8mx79wfZSVcKy06YYvSsGOYuyvRxXarftPBc9abVhSL7OLfIxwaNJH5GBus97fEcaWPTMTIoZuj7qRWsR5vjG7DQ1PWlWh/5+zNQ7mP5SANJCIjTEu5138hGhOc54sMrP7HPRfzEVIgiX6fOQ9lyJGwn65nn7SRrlsIiVLeT4Hna2y8mcXRw6p+J1WiqZyn5Sq8MyhBx8Q/Uc3IMVwZd7auOnL2BLr2SG+9z0YHUr5xPR9WADQiPw/d6GoXs9Jo0mzqAhFjX6WKjMo0RYeQVCAOnIvm7XTz65pmwVIxqI++nN3RB3e8HQCnS/ERBiSuBb5cEylsP1NV0Mzp7JSJKrPiRF6TJED4pJ+HHScRV4Sg0hrrZiF/ZkxJ+VGEcgCr8IT/QrMesHjXPaZ5f3ov8Amz57Eq7WLaNP+4lOxzyy8AjYaPdBlswx47PN412Yna7a0qG11l4="}
encryption_applied: 1
markup_language:
is_shared:
type_: 1

View File

@ -0,0 +1,10 @@
id: 3d675395b5cd4d1e9d7ca4f045f41493
created_time:
updated_time: 2020-07-25T10:55:20.632Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000280{"iv":"bbWuqdBgF3f4nSVt1RXDZA==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"2nlg8ecuDKCTHuAVnU7JqpJ774cjEb5OC+HVImvBQe1sVBxK4Cypf31LLXYX7Kz9T+QZkB7EQSnH42+zMlYWLJgQzqLcd4OglSjwnfdFf4puH5xIk6mlYhCNu+H9nvYYa4FQJTXXqAi0Bp2fj7ta0uMePGxKUOZVB/tZ3YPS+rFo+4/C82SoTKY5JDPjEgtrF7ZaokRV51K/LMufT3ApZKcKp4TnK3lm0TCGcUqebONdk0jtbcDQnCy/HmM8P4UZGx/Hcw4dDcrwwSrtvJQrp0sIQ0XwMQwr2VLeK40rS4TAEQ/SeodQcN4K6kkSOS6IEDxps0s/742HZJUDfIfJ95zvBySqsIFXEvMGepwIEB35Vkt9uSzJDoCeAPd1tzI3EE1bEtJ66AuAGv3bZ6EbBiH/PszED1gMxEvmHscoOvZRzHn73SkhPLO/4n1VJhjNJgj0E9KdlLl0k2Mla9E6veIGDhgqdIjWcC+Z+g0ctpRoILRi8zi6nnSH"}
encryption_applied: 1
parent_id:
is_shared:
type_: 2

View File

@ -0,0 +1,24 @@
id: 49e1777d9c17439fb612cce85700d16d
parent_id: 673415563b2f4db2aae8665ddf9fbc67
created_time:
updated_time: 2020-07-25T10:55:20.924Z
is_conflict:
latitude:
longitude:
altitude:
author:
source_url:
is_todo:
todo_due:
todo_completed:
source:
source_application:
application_data:
order:
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0004c8{"iv":"OWv0KZHC+nkbL4+rXmhV4Q==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"L0eNB9OR+DkJ5qatAYN+UlY8+GVQBZJoV438yV76JqGmBibDZEiJmVdhgv9A8pwWCfN+vUP8Kd2DIPCtSgn3d0Hs5ZTJmK78TqkiF7ax/gE7Ri4Ys8ri58Ct2gZ9LTaK+EybJypqjtHrO87dHxyLrROcONuI+m+5JpBoPFkyDor7U1pcM1P0C4VvYhR5zxIRiDEuWL/lVinoogC4OulkhkWBOJl0OCF28WeKGAwGnjV/S0EjF7FDlSXWytHuMAIfk7UkVmlSAFDOwxabVBjuVYmpxxLCTHR/okaJRiZob+1ykZfT+Tvv2Y27zC8Kffarg1t5ZZhcA7vJbl8p5gLhEM7XJtgwaQ27jr8cJVdO8UsjjVoQQf1U/E2mZSS5KPFzma6w0vG33RvoZ37jzPq50v+jCPivt+zLda5mwV0s6E1Lf+M2eWVaOpIpxHiHYGUdbZRPGB7+ZbCmcXbGLezpsIIb/+AYbnE2Tj5Ygj66OBLttJXbaJpvni5IrXaeEkXrPT4ouIMkB1nUeSvEGG6tXCkQv2k2bSddFUQEkRXl22J8U7gMlbdvbh0G8W7bRvLNzME/vtxN+A4IlY0QW20Q4bEmX41B9z9SDdkrjNCydmejN8LxHCTWSl9jHtxD5NSzaRXgtDrOoQgdPQ0zMgXbe81Tsw3Dgf7DIGa7/+kTn5NBi+iqV9l0y9diRRJ9BtHChM9jdwcG2yk14iuSflX8MR5ALDd//USiodRZhHsuDWfDSS1/a7OzJhZCDGXICAnNmrWUwA/JfuXHEiGTsZ/Hq04vfDNHyX/ywM+g7uCjjyPiU2EtuiMwhVORwms7fwFeolQvu8JvG41LWttWo5UJBCEo9DZu1Ikzutv8Qa9xuMKTFCFnIXFtiMruMEGw8paANubodB1RnI2q5AHEHGO3HIo90DFDdcrpiWHv6XOblKhlXe4Q8nS7wMxcHK/P4AWsgRQc/UnkzRsk86HOl0pS09d/ZJ6VR7wOFC4/xb+YLcZgvAlS81Grs3SsXzwlPAH1z59qUxGkzAwO6BlbmmassiqArHetYJC1jje5dQBNfWUFDmGkCKblXgfkm47Oub4="}
encryption_applied: 1
markup_language:
is_shared:
type_: 1

View File

@ -0,0 +1,10 @@
id: 51799b7f7fda4bff84954305f707ca72
created_time:
updated_time: 2020-07-25T10:55:20.634Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002b0{"iv":"VMKkOgt/Zhpw0WSe4YNy8Q==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"j0+JoccW00HDUH0jgeLYIhj6VTYbSYgoSncwpFVBPK+yRXwpefjfL9KKmCBNBNibOd2TvJRh1Tk20BRwr2U3kZK31Ytwnpeq7dMuYZX/rkSpJRZ5QvJ2lwJbPCT5kg1Cv/5kE4zD7bhWD7k8u4cWKTdrz69tsqWWBj6lCXD92ns4Iq85btKvGBT/ExvXFsvOjG1rKKzWua1WDkMXKyS0yRutHZ7GrlgJve2QMvoh28OMCPFyNb7Z4/gzCZjMe44MfCCRIIEyyi1332DSSyXMsgGZuyUwQKNDC3+exlK0sfA4pKZhkT9uBNp4jRGehjxmwq+a/olX4H2qdKkpAjR1UznxXCCyRfew6arokr1K8ZT1TC2DJzM8jc7wrt7hX8FTCY57YO9+jJuhdlVVY4S6YkwfFwRTvC8NQSuBWrydU5IwvNABICr4o1IAB/gfA0CTVI3Ua4DcETh1m/7ptUtTM7vDQJ4+Xb+KzhOm1c7mYCothLPxRU/A/CjoGAwzAIFNM92YCi5UPl/aP9P836trbm52XGggtrb8Ofe9uOA="}
encryption_applied: 1
parent_id: 3d675395b5cd4d1e9d7ca4f045f41493
is_shared:
type_: 2

View File

@ -0,0 +1,14 @@
id: 5fcd3813d8ec4fb29d2a9d08da81a0a0
mime:
filename:
created_time:
updated_time: 2020-07-25T10:55:20.921Z
user_created_time:
user_updated_time:
file_extension:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000308{"iv":"3Rz70DM1fx/t+x96IgfbVA==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"O0o0M5yISa/BJT40Wddy9F2HD00jgI4z8w0tY/BsuDQQHLZ/bJJMJg3qZvXkcWEHrBr/ki2TNOSvV4xCWG3Q2IbbLQjp2lpoBGRULtkzGqKrCtQL/oJhlbYACv3Au2b0nhgozeW6Uj+24aT6a9lTnDlvOvhLbmKP3wjNbMqzYot6Df6Fz/s4ZgUbBqYlTJBkvfu9d2eH0NPPfHc6ZzUMRjk9tOPCQjKnl2f7lfg46YKDqM5VbQR3oE68jlFm5ufmeVVgAE2CZAkv2b1dE/ouh2EKYalVBkrPjOmQuw0MEW4sam0wDy6IlxINxY42ldYL0ASDN53hIzLK3UcL6igz1QhnWTtY7fSHp2OROvVD23Yq7XNORXjWq9C5+99Tp0hryunUZEwTAP60IpfECbi16RyOcs+u07B+uJ1VdJ2uWJ6261XSTTONU5M/jrQA6e9RQoyVsmuE2CmNzv/X1esDVu0JhX92MgadQ0529WN51vqKQ0toR7FMazKZXDFLLh6iWrwz8yPZCRLJOgFF1wnojk86CaZ0Z5bvZDdGAdgJC+ttYWqQI1TPd8CYdpO0K5Sjcb/mmRdmO3hThJ0z4qj+FBrwzCceNgeJPbm/yWhp4h2LLQGvYW0PlxV53neDB5pw"}
encryption_applied: 1
encryption_blob_encrypted:
size:
is_shared:
type_: 4

View File

@ -0,0 +1,10 @@
id: 673415563b2f4db2aae8665ddf9fbc67
created_time:
updated_time: 2020-07-25T10:55:20.815Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000280{"iv":"kCJkRvCMeK+RKb41oOeAtA==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"7Fzu0jh8AejqVH/SjqFIRZGBGA4pMMc0l6v6vJWvN+YXe2FesG+xlpmIeq1EB52h5F/WumQnvEaJWSISK2TA/VygkkUy0xqLlwNsLQmdfvMapJDnT5XXprwqdMgMg9LZ0isjpi8pqvYBSUk+Bzbm2RvuCosUxNEv8deJalYoDNyBajjhZIYSyAeH+2yMs5tQv/AqgEuUy3AOQIr6fvk1DlKcSt4FjIFnw+jFudT6djIrUHlnq2LnKT9zxYVfh1XBLXHCQna0df3aGlMG37DUzlxHX2VNt2Gp9CXqGY2PXuVAA6DcBtAMjy7BKh+nESGhWPbLSfXk53EOfmm5Iis9drqZRoCl4rjZoioehQv7kSpUMPq4712yKTIFYnlOIPzlCBPdlOnWfdcGcwq3qitbtOmZNW4qvLWTlwkRo0H1W7DmfEASUFheqIWCqBJuJ6Wk2ONmFPtGPWutIabYUVsZKAQpK6qYd3arR5t3plq/jJfMFrrG/7edKFEM"}
encryption_applied: 1
parent_id:
is_shared:
type_: 2

View File

@ -0,0 +1,11 @@
id: 6d05a0cf086043129720b5da35210efd
note_id: 26b9c0dc3ff146ed99031e259bc1240b
tag_id: 8a17074d4ec24de7b5a1aa666f7d8b38
created_time:
updated_time: 2020-07-25T10:55:20.809Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002d8{"iv":"2Vou/WCQoWwTOzfTBzaPTQ==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"AZ9IJMctvBFEfDaN7rIcdZGHBvquxEx0W9l0VstK3Gmwv98eiUhajXAQD3MsfCM7JyZPjh17RdDIcqmGxDyY7GMJflFOTRYXaOyOE+e/nT/pvbo9TCegkYO7HWq108I7Ndt4VOLmbe99O/glsE8J5G93qaiqR15fL8sET346T+kVkuBEoWBoxYZ0W5eNSdtU3TEl9ieOsmJ3vx+xt+85+6HGsrB/HXLaEi1rHKklUUp8EDUavh3BiAunHr315oqedJbdbV8VlwehJvtYaZvyivvshsVuiJLA6QRgKHwbM8DFeVNdDiq5E5LNBsoNnAQzskqwSjt3E7ONkQMv65hRaTFE/DHisJ+IC261J/0wYXCWOKxF+8ybTKPc3XqRzuMZZkEAgOmW48OBVsjU72pNpx2e53HwiLABRlQp2a/0MVfZrI+dVjGLP0Azgifj0IJJeAhDqJN6nmZHTxhlkv9kDH4e3508qKpX/+EB0EHVo06nO/0kB+hbR5FyyQhedRbOMclfkbsiNRZfjg1vpYEtHPF5u4pqyT+atHDOh3q8Hdku9uzL9QGa0KUxylZG98B218sVOgUfQeqVKA=="}
encryption_applied: 1
is_shared:
type_: 6

View File

@ -0,0 +1,24 @@
id: 7ac37541e8404239babdaf1d3fa39c90
parent_id: aa7dca873bdc47beaa9465e04610619d
created_time:
updated_time: 2020-07-25T10:55:20.792Z
is_conflict:
latitude:
longitude:
altitude:
author:
source_url:
is_todo:
todo_due:
todo_completed:
source:
source_application:
application_data:
order:
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000470{"iv":"bhbCBStJc0FV0QfurbZZRw==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"vYNW1c21bdKGNLhcpPlu/WwRPGZC3kfn2ZJLJj0Q5jxsLw+ZFCBvZlYMfgW5KP14PjhmQZhaCrzfJn35uLaO7E3OZUnH5jZnHyGpcL7GE78hml+odtVJrPsjTltMLKx3TXtcza3WPQjGPJyL/ekU+YPI5+tt/DcgHofzDJ8K8vvHyf7/OQ4oXkK5K2AnL0mzLNqaY2swuZsuHASGX9KBKAQ6EzJqLvd2Fm459im8kTY3v1oaLKWfchQHYDPkvXVjzXCgAsRrjUY1b4MdcBODoWk8Hs7AxqLGdbDcYMjs4T3tUjJ+8g+osZU1hMYEl9B180RBt3/ZEn03SrvZsGn7kQ5ILYes8Og0AZ4R0WVmwABOObTYy+qgg/2dSuveyDs5hsBVcVYnQOAC8lYEodhOu1XZsEEJxh+h3fvzSN8grd5fL5F7BbZ50Elz0Ba3YJFh2yWWLT5AR7yxsj+CSxoCZAiAEm97KgeeA9exY89o9EuLt7bq+VuGFNPRppldHCF5si/u9CmR6Dv50QVRmMdNU3AjyVRNESXQ/BxhwGluAOKd0dV65hjf96TufN+BhVKOrDl/ztTkt57buNPx+8rWNOUodJSu2zJIvajfPWCSbWAMCGinjD5uy95SsNuyiUZulS1NadR+eKVA9oZURzcf1ZHDIJ2oyWhoNW5dmhtZyQ5vmT7IrmE/PQRKmTwEi/2rYF+EI8NFxWdZ/e20le9UM67LwWfkzqDY2I9ZJC1Xxu8gG/FeEkP7qADhEF/QR7u+WvIfCISksTl4XjyA+pfhsRgU14D9vqRmkaMgrU0SOqDusQdC932Htzr9Aun8eLjATBsZJ57zqEcaiaXVl5aZb7yic5iVXi9KYis84ijFr0vP0QfNpxIjBigvDGWO/xTcGme8pkoMyjXCuOhs6uc/7KoBhO4V+ZrsvDAF0QQVq6l0tl//cUmCPkSlHhcVIT3fDm7Ry4GuNy6gik6q0xhNMr8d98Q4rqxajgn/bBo="}
encryption_applied: 1
markup_language:
is_shared:
type_: 1

View File

@ -0,0 +1,10 @@
id: 8a17074d4ec24de7b5a1aa666f7d8b38
created_time:
updated_time: 2020-07-25T10:55:20.801Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c00027c{"iv":"+bwkkcvF8+iRUp42297fAQ==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"GuuoQYblFgQ9OlYr+A4UVDNSjq6k/sFgE9kW6VNbee4LFSYFmxLtkRFgmoCJe97yopB/JLDPVyn+InOpEAQvRyGFbgku/yElAy1v/PEL3VKST3QezhpNHB+qgvUUPIqkkFtmo67CtvUsSD20txIPPbm+y2dabjaoY+Xi0TE4XYAMUjCDR2vozWrgtMylETyV7xzwWw991YM/HufWlktjsNKiRQvanEhda5z4xaIIk42zhqnqHfnhwDAvy1eTrfIy3giTgB0bPNYxY0ZvlFARfNuHF1owzKpBB38lHGvW77CK1elBsDdkzCKD4stm5LOlUHuQ6w6vPLuGbpcSJJZk2vaeer6ydoG8BPG+xUQofesL5QXpAx1CWqXsH+o7j0NBHclR4lic2JiVMHBhKIRmHvM+a1EzqlT/YL46Bh9P6dCbPqLCKrBJxOJwozj8DfCVG/xMLrCn11vA09lq395gQlH1qJ1c6XQ6vexC/iuDB7US8KarYpns"}
encryption_applied: 1
is_shared:
parent_id:
type_: 5

View File

@ -0,0 +1,10 @@
id: 8d4de93d82e2468baa8e539d10d67510
created_time:
updated_time: 2020-07-25T10:55:20.813Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000280{"iv":"oope5TviAxbb3opHGHqzWw==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"UML4deeatwKX3QkbH57smGVgvQxzXk/My6rkupu9AhP4W4g7J6iFKs30Xbt9sLdrBaHsshI4d7Tp44YA/5fASSHosHr2DjSLH9AZnQ35eCGKjHaXKMXPWAdQwSfCM6mB6z2BXFA2GU0Ign0GfJvSwTNZo4WIG7s+pjPG8puaONlvJ0E6dPkZJoEM8IGrQku0N6yvnQu82HlVNWUJs/bi4Rd7leegPDYoMowC31xmF0uK09afBlPd2UixL+4qnq9bLiIaF4UKYlE+Cd0G+1tTjPklebRxhfdkDxdJVOImrjWdba8h2gRZak3Gm+i6ZLM5H6doZyWUBs/EpTOU2TOh80+Gmd4DdOdJ6yVASND5hsY5QbjpGJ/6M7RoastJPB9HSM+5mSDU51oSeqIEjrcgvr2j0XB5IGc8zY6Y1Ebgg5VAV2Ds7p1Avak3Jb+R3bF9oA2ZyrrxK+LJfc0T31ZgvwzDEqvPwt7cHosjwjNrjLiakyhekFDE1+ee"}
encryption_applied: 1
parent_id:
is_shared:
type_: 2

View File

@ -0,0 +1,8 @@
id: a1a0987e82cc400c90582492f814c23c
created_time: 2020-07-25T10:55:20.995Z
updated_time: 2020-07-25T10:55:20.995Z
source_application: net.cozic.joplintest-cli
encryption_method: 4
checksum:
content: {"iv":"lAWEbGcNeJKdyBtAFJhCJg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"L0lL3nxewe0=","ct":"ZonX+RPsUjs7KznJXZb2HA3WDPc5vBnqcAU2FSphHIYYP6FEjPSmiGbAmCcScs2WsOkSm6t+4Elt5nu44XPxj7PR/O5JkpOlP2WNrIMovS7dhpm2fFhvnywMeYfVYJGvOvaOxUsAnbTjNayUxIYFKoKEB6o3pmp9Msu8ZESc46mnnjbL7VDuiCWIVp/PCf9hK4l1Id8hLAy7Xd2Jl+ToItD7LYWkuA8+xEa6WvW3l7tEyPeAYKQGze3GqL3p+R1E6bXZ4XV7K193JlYwDvDSORq5eGR290FmjaGk0twuSerzx33YJFNfGpYx2YBEqYD9JeHmEabDgBn2sbZRbCNVRsEx7YwAvUzgAH92Vpzw9m59zCdKVxEBekkNUfOAk9ylDhl/1qnYmHd6bpnO8ruU0NrYV0cAfpgwIkD01stmGo3Nq0w5hAS2qtpZBvHickJR+3nb/G+HPCrnHvQVjUZo/7PRfnTuqdF+ZxRM/ENxL8s545x60ugJLOFUC4KU3VmkAt3YdmamgoS8O/7t8pR/vgY3Ll9DbYSDfL91w1mgZ0fo+XYI+Y/cgDpNhiTwcWSn8QjyXPBIO5Y0Rv1t6Sh+G8VOhf90riUI7RJG4t9Y69YEyqbbdsDLCcAbTxPL950guHz9na3KQq2ALiflPIMgKjvD9PAJqx3Cm/aoTFUZiah3tJ4vmBxpMg=="}
type_: 9

View File

@ -0,0 +1,10 @@
id: a2fc1b9ae0d04c8bbeab6d299d0193cc
created_time:
updated_time: 2020-07-25T10:55:20.785Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c00027c{"iv":"SR6yWBcxM5ngXZzQPnrfcg==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"GkMaEQSVNWpYVstVT+viNzY0b6Ol3QzdgthJ4xIYEdLGZpaLCbBoN1bcYK6VVm8HLegUvH4HjeKlGQQEgX8nhwIaI/DGpWp5KMK+4d7QDSbz/JrDz8mJYpMPIgaTVd/elAs/aojUoJC7ZMEqIiZJi08xkiZeMxj5sFk3Bp+t7Fdg9q/HGTKIoxZBRKHke2j5IiZhLmaeI1soFksi6UmpofejXJB3e/U6p91j2dZVOuI/XpV4aI+Qqut5sU6nWB/kM1/DgTmSJGwYcMmx87zUqLxTs0efWOOuMH+598PTYTACW8QGXx1ErjeiiT9XnXo3yte5Bq2OhLf/Bs2YfkToCejQ7pnLOmVRFX26M8CQzhOqVm9lGxP2eB+ArgHXuUwEFWFDQaNI4LmwzXoXDQLA2HWxCEI5tfFZMD2ge9uTU0TBQ2cvVd282R0kM++LwaNdcDNFjPkQLp6G8Cbyql34sksiTPlshXHF86yoYKxpHLdfyBQ4uE96"}
encryption_applied: 1
is_shared:
parent_id:
type_: 5

View File

@ -0,0 +1,11 @@
id: a807b6e7d6594567934938dbfcd2bcdf
note_id: 04c4e932fe3c4c4a9450c09208bd6c21
tag_id: a2fc1b9ae0d04c8bbeab6d299d0193cc
created_time:
updated_time: 2020-07-25T10:55:20.795Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002d8{"iv":"EPjRvnAQokTnBFvw2ZeRkQ==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"xBFojIoEUsUNbGAu8CibWcTn7Pn8N/zC6UkIxla9Gul8STudN/HLxdX5zBnlLzIiXvumAt/OtRQ8aOjmGxrpPudPH220Shd/eH7jLfh59PxpDNWRrUZLwO1rxUozJjpFAJsqiHGurmsLjtWzMM2p1zeoSRbIacb8X/Sk3BiD3mnnbBNOHoSbV3Rli+mBRgs5SkWqJSvwHbFzjJIk7vv5Uavkwchh21EFOrGwsNlYby5/JIZTz84e3JjXOR0pVs3D33cZM1nSv0ijnJwlLS6Aty+UGfGZl0pq1FdB+Rw9BVAge2+eptKfzmN+bfUEWTIEfKbPWnaokoAa8Jcs9qlJHIqCAAmYRs20iHggjliPzYQ0JO/YXlfsQlpqPS4r+vrEt8V2caCXG0+VLV2XcmJgFjU1oZuXuunJNlD9iobxdgSjxDbmEdYZ/FzT6BjUpTVXbjZLEChq3tUz2PB+JLqK4uNM7J9ZPASW2/M5CH6NdmESk7gafjYM70TQJ8AFsDQNZ3xuA9/kw4xvkW4s8PniELPhMgbSbOlI8ACWP0UReMgvi8h8/fxNc9JILEizuXgN9Vv5Ya32rT8CDw=="}
encryption_applied: 1
is_shared:
type_: 6

View File

@ -0,0 +1,10 @@
id: aa7dca873bdc47beaa9465e04610619d
created_time:
updated_time: 2020-07-25T10:55:20.635Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002b0{"iv":"/hdpjX6UaArAOXSsk6W8eg==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"7qX8Fym/M6iCMLeN1M1NuTgPzBKFlleRtwTdGAJCNR6s88rgC4XYlT0AA/mAJ28FDw61JO29ZnVQTLulyuAhmsNtxY6Bar005sLHlEhX/s+/LRyfdLKhzVWVMuKvGeg9F8omkDVDjE9oOcx2DqM+wV9kQe7d2sDWYpu/fEk7T9MNBWI+vodqfw3iNLOiQWzQq5Xrqy7lpb/fj4sSgtKGZouSkbhcckjaKYl5IpblfZffnRF5T770Our6ufID3kTFAorqD4Zg6lY4SUKLrWWAptPv4/wQtBa4J5mQ0XUy3OSSjh4EP70H2js2NrDouKFsaRB2KyKk0/JuMmnMhuajyb9ozIDGwWEmi9zKZZ5FrvqLCKY53hhqxyQ8BMOrrHm8T/GVmfFMKN2ZkO8eLdoTETx8qqyErB9oHg6FsKCJS+943BcsrEuDHaSKUFTV+y5JNiEHIg+hBqMwc53z5mNgylP5eYqvje+t8zo0ZCXPJaMArlSohSQOE2pga9b2k8pwtczIQk1ZL36CX0WcDJVe5ir+c8NtMHG7omHolaU="}
encryption_applied: 1
parent_id: 3d675395b5cd4d1e9d7ca4f045f41493
is_shared:
type_: 2

View File

@ -0,0 +1,14 @@
id: bda6d120223140afbe7f03ef1d876400
mime:
filename:
created_time:
updated_time: 2020-07-25T10:55:20.778Z
user_created_time:
user_updated_time:
file_extension:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c000308{"iv":"zyEI38ZCb2UGn3cy0Ejjjg==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"vqxf3Ul0liOt86X6uiY3/tyGGpv1nb7Nhj/xX7j+BBrUCeVJEG49cxUGwH94ZCKI1LDnFW1wiiItsdmZySyZZXTmcaVmw3lIA3hDRmS6QaSevRM8dSg1m7zMArXVGKwHPAhfysZF5wtS5rMf4cZSlr4ib5w064Ux8LqYEYS7IIDqczt4qORZUrzwigx2V54426w4PsUD4xzUI4K5PIdBewWinaRsmZ/Dwf4tLHJa0B3BYT/j4T0Iuznw+1O6JXUZc3G12lYLDPL9NAmX7BQ+aTzjCrvrbAq+ew0Sefhzb3kUxp6VM9QBbD9sPWSE0L3rr97t0smJYA8SVPBcwmiPRU6tsNx6ISc0/pQMe6KxZNQVid2/K1/e3OmkFItI5y20PXRs9IuUhRhylQ6/2dL6MR1d514PA3BFQyfsq1EBL8waO0p9A3qMFxNkpgrxail/Q21XbSaojTA9oW2+o3e7jMecwUjVlR2kvZG+by+FcE4SwZz09nBVvFEU4hmRDnKkRYNwnB5WbvVGsfa+gn5uj3HTFY/XIgK9160oGSncKVGkoG9wpoQ+JhKqhwjBZ0rIA/fOaD02ndEMpKlxoBjIPAtXTS/3rRYIypvajiI1qc/2FYgBCl1PvbWle1yOFsXT"}
encryption_applied: 1
encryption_blob_encrypted:
size:
is_shared:
type_: 4

View File

@ -0,0 +1,11 @@
id: ce59e12313e84ae299b6166068755253
note_id: 49e1777d9c17439fb612cce85700d16d
tag_id: 8a17074d4ec24de7b5a1aa666f7d8b38
created_time:
updated_time: 2020-07-25T10:55:20.926Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002d8{"iv":"h2tVkjGBWm/rXhSD6tyneQ==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"l41akNAnKt7pRrD5evL4ebPZJ+9wgV1bian4MPJUiUsK63AXJspadGbgMEZ0lrBDody4dC1lauUKdiDc3CFL715xGyscvMR9LNhqHqNWqg2PPMTLbwPThNi8X0OkaeH5tftpxBElD3eYvWRyoSy8mqytzkmaqDdgGBHZQcaqjHcB8f8B7gnNgT2EAhsHHe+KsN/AwD4v1CzJAs2ODrGZK6Bx76doZzH5vRFZ4gyYHoo0N0Ec9AaSl2UtiPH171GD4i5J7r3Puvif1sFZk4EPTfyfpJ+xxwWMM/10a6o9T4kIWOiFTFPi0o/l0ocD4ghUNX2FKxrfRtCcQk+D5qlRXhrTaJDj6SFOAAkkseddotgtvU+y44kagu47XKV+frEjAW6HMznpZBTrJ3OVLXf33uvOLVLM5c1RDiiBj20kNQ0m4+XuGAsXxrcxFj/uAzCO48Q1AvaLE3xlRP6rs+h6+JP8CAmmd5Grxm4Y73ww8IcJyzfajiQj6siScgNRw6LVY7lI8MV6UwxI0fah8MsqcSS2ZMWx/DJpqlbuoRhLWVeBLp3LNq1VT78LAZVqJsn6LesUSCeZd/tXeQ=="}
encryption_applied: 1
is_shared:
type_: 6

View File

@ -0,0 +1,11 @@
id: ed20d91ef4e64fc0910088112c077188
note_id: 3a8eaf72f62847689176a952a0b321a0
tag_id: a2fc1b9ae0d04c8bbeab6d299d0193cc
created_time:
updated_time: 2020-07-25T10:55:20.788Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002d8{"iv":"WIWEiOpZBFro4/AXC+mtcg==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"GREDeqYxU2c9LLKu7WNzDhaCMyZRmo8eL6E1Kx/0rvHT7A8tI1ZK/yTSNvfLcVWU164pxvkCtRB6fPij8dVA6P1uLcdhH6ztuvekCcHsb2PPdOtX4Hytc3X5Hi0oHvgVTnMnmY00XpODR22M0LPUE0/OoJYajTGM5Cx93cieTUB+qyxyyIHGBhjhw8vsI/CIjpvZIogOQDm4D6ptg1gmFhDrJx04C8tfZxpMI7iszBTyfsnIOBtRsbeONnqHxcOweIHkIMRKQlBcIRg0yM8vm0Ft1wyQzRg0B/KOTcfctdV1yUTnY3txVTb5o29tH829fyYKF0q2rLtqlVNfN56gaHgpkXC/6szW+UNkCmJI/v+EgkorfBB7B8NPH7v3zVw01u9QWuETi8RKjcMo7ZUMdMDqQYi4vjqSsmWtfAfs5/uUG260nS+cu3OxNJm1UtdIJ+l2e1e36EwbHbCiiKRF9deQaLWBmm43ZmqoukpmX9FFzjv78s2JbeFnDKc5tiw7BkYU9yETv7DgC7f7T8D9xDQDJGELkXbzzNBGoqniWFgWJc7Akt+Gof3uKZUIQO8lRKjTOpgxP2+7/A=="}
encryption_applied: 1
is_shared:
type_: 6

View File

@ -0,0 +1,11 @@
id: f58c1af04627410da55d9c771b28bece
note_id: 04c4e932fe3c4c4a9450c09208bd6c21
tag_id: 8a17074d4ec24de7b5a1aa666f7d8b38
created_time:
updated_time: 2020-07-25T10:55:20.803Z
user_created_time:
user_updated_time:
encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002d8{"iv":"j4Au5F1MRKUgSevr56iisw==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"HvRLpScsoN1juxw18ostkjkjcxO+VWaprzz15PzE+3LU0KfoPqo0g2GgVPpbmp9dNyuyv+akfj5u5/buMZipiyGteEOa5WxoJ16KJ9qIYjv+kxu9kcfteDhHP6gTz1Mc0DfQRlLfZ5EbcpYQwkcNvdF71t8JsH5QwqA27P/wk5TKZDM/gd641zL92tNViAM4dZws2FDeWvgb4xRU3L7tfSBVoR5DXBKPl5syNrr8m2prdolydWms+RZuQQnFWIMn0jIKucSx56YEwcmCsAdsOLpNd8/MLqHfUOafrCQYDd9QIWEbNz509wWoXiu/Cjl49B+xx0ACa5z/Ey0yBQAwibPLMBq2yAQgxo6SWG00reOkGKQmxYIkD7mQa87zUtsCRWPebFohDV2LfAbbPFScsqNsH2wNhVXJZJ4JcOKMUR1R4hx66P158wOn9VY1Rf3zyJujiqzAhGivdvMQ2qp1TyAoiA8ibPizZIEPh0oyYf7EoZf1mIO/hyFMfs31xSbfdfXhUn57BCgVkndA7NRT2xoEdqBgymLMp2z7ll7F8xjMEG+8wUlAWNXzNDjhzPza2D2s8Co9pqgs9g=="}
encryption_applied: 1
is_shared:
type_: 6

View File

@ -0,0 +1 @@
{"version":2}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1 @@
2020-07-16: In the new sync format, the version number is stored in /info.json. However, for backward compatibility, we need to keep the old version.txt file here, otherwise old clients will automatically recreate it, and assume a sync target version 1. So we keep it here but set its value to "2", so that old clients know that they need to be upgraded. This directory can be removed after a year or so, once we are confident that all clients have been upgraded to recent versions.

View File

@ -0,0 +1,16 @@
photo.jpg
id: 006a89df4de64a22b4b1fa71f87fd258
mime: image/jpeg
filename:
created_time: 2020-07-25T10:55:18.547Z
updated_time: 2020-07-25T10:55:18.547Z
user_created_time: 2020-07-25T10:55:18.547Z
user_updated_time: 2020-07-25T10:55:18.547Z
file_extension: jpg
encryption_cipher_text:
encryption_applied: 0
encryption_blob_encrypted: 0
size: 2720
is_shared: 0
type_: 4

View File

@ -0,0 +1,11 @@
id: 106ec766eba54715b19dc899e1de6906
note_id: bf551517ef7c40be9477168677d0b77a
tag_id: b684a65012c74c508b891935ecf2f5b1
created_time: 2020-07-25T10:55:18.556Z
updated_time: 2020-07-25T10:55:18.556Z
user_created_time: 2020-07-25T10:55:18.556Z
user_updated_time: 2020-07-25T10:55:18.556Z
encryption_cipher_text:
encryption_applied: 0
is_shared: 0
type_: 6

View File

@ -0,0 +1,11 @@
id: 23d35df6c34848ec86c42c3194051ecc
note_id: a91bf5ddf3a749d2be010e9a04e5a1cc
tag_id: b684a65012c74c508b891935ecf2f5b1
created_time: 2020-07-25T10:55:18.439Z
updated_time: 2020-07-25T10:55:18.439Z
user_created_time: 2020-07-25T10:55:18.439Z
user_updated_time: 2020-07-25T10:55:18.439Z
encryption_cipher_text:
encryption_applied: 0
is_shared: 0
type_: 6

View File

@ -0,0 +1,28 @@
note1
![photo.jpg](:/6f60ca35b0e4423fb49f9e097449fd99)
id: 2a914b3fb8fb43819b976eb4e5be80e3
parent_id: 2fa39884ba3b47a489dae93dc20021f2
created_time: 2020-07-25T10:55:18.127Z
updated_time: 2020-07-25T10:55:18.403Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin
source_application: net.cozic.joplintest-cli
application_data:
order: 1595674518127
user_created_time: 2020-07-25T10:55:18.127Z
user_updated_time: 2020-07-25T10:55:18.403Z
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 0
type_: 1

View File

@ -0,0 +1,12 @@
subFolder2
id: 2fa39884ba3b47a489dae93dc20021f2
created_time: 2020-07-25T10:55:18.125Z
updated_time: 2020-07-25T10:55:18.125Z
user_created_time: 2020-07-25T10:55:18.125Z
user_updated_time: 2020-07-25T10:55:18.125Z
encryption_cipher_text:
encryption_applied: 0
parent_id: c4e45cadb2e84beb801980155a707e21
is_shared: 0
type_: 2

View File

@ -0,0 +1,12 @@
subFolder1
id: 352dcd65cd6e4b09a93669378b8e2b50
created_time: 2020-07-25T10:55:18.122Z
updated_time: 2020-07-25T10:55:18.122Z
user_created_time: 2020-07-25T10:55:18.122Z
user_updated_time: 2020-07-25T10:55:18.122Z
encryption_cipher_text:
encryption_applied: 0
parent_id: c4e45cadb2e84beb801980155a707e21
is_shared: 0
type_: 2

View File

@ -0,0 +1,11 @@
id: 38341af5a8764d4d9318f58f778e3240
note_id: 45867f53ece54da38eed83288882e374
tag_id: 6cb91bb296ee458589eea0256ada06fa
created_time: 2020-07-25T10:55:18.425Z
updated_time: 2020-07-25T10:55:18.425Z
user_created_time: 2020-07-25T10:55:18.425Z
user_updated_time: 2020-07-25T10:55:18.425Z
encryption_cipher_text:
encryption_applied: 0
is_shared: 0
type_: 6

View File

@ -0,0 +1,12 @@
folder3
id: 40f117103de1405586b4289a55e0ea22
created_time: 2020-07-25T10:55:18.443Z
updated_time: 2020-07-25T10:55:18.443Z
user_created_time: 2020-07-25T10:55:18.443Z
user_updated_time: 2020-07-25T10:55:18.443Z
encryption_cipher_text:
encryption_applied: 0
parent_id:
is_shared: 0
type_: 2

View File

@ -0,0 +1,26 @@
note3
id: 45867f53ece54da38eed83288882e374
parent_id: c4e45cadb2e84beb801980155a707e21
created_time: 2020-07-25T10:55:18.424Z
updated_time: 2020-07-25T10:55:18.424Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin
source_application: net.cozic.joplintest-cli
application_data:
order: 1595674518424
user_created_time: 2020-07-25T10:55:18.424Z
user_updated_time: 2020-07-25T10:55:18.424Z
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 0
type_: 1

View File

@ -0,0 +1,11 @@
id: 50fdc4447c334b00a4dde44344aceb25
note_id: 45867f53ece54da38eed83288882e374
tag_id: b684a65012c74c508b891935ecf2f5b1
created_time: 2020-07-25T10:55:18.434Z
updated_time: 2020-07-25T10:55:18.434Z
user_created_time: 2020-07-25T10:55:18.434Z
user_updated_time: 2020-07-25T10:55:18.434Z
encryption_cipher_text:
encryption_applied: 0
is_shared: 0
type_: 6

View File

@ -0,0 +1,11 @@
id: 567486477f4249d38feadf6c5ec6e03d
note_id: 2a914b3fb8fb43819b976eb4e5be80e3
tag_id: 6cb91bb296ee458589eea0256ada06fa
created_time: 2020-07-25T10:55:18.416Z
updated_time: 2020-07-25T10:55:18.416Z
user_created_time: 2020-07-25T10:55:18.416Z
user_updated_time: 2020-07-25T10:55:18.416Z
encryption_cipher_text:
encryption_applied: 0
is_shared: 0
type_: 6

View File

@ -0,0 +1,12 @@
tag1
id: 6cb91bb296ee458589eea0256ada06fa
created_time: 2020-07-25T10:55:18.411Z
updated_time: 2020-07-25T10:55:18.411Z
user_created_time: 2020-07-25T10:55:18.411Z
user_updated_time: 2020-07-25T10:55:18.411Z
encryption_cipher_text:
encryption_applied: 0
is_shared: 0
parent_id:
type_: 5

View File

@ -0,0 +1,16 @@
photo.jpg
id: 6f60ca35b0e4423fb49f9e097449fd99
mime: image/jpeg
filename:
created_time: 2020-07-25T10:55:18.397Z
updated_time: 2020-07-25T10:55:18.397Z
user_created_time: 2020-07-25T10:55:18.397Z
user_updated_time: 2020-07-25T10:55:18.397Z
file_extension: jpg
encryption_cipher_text:
encryption_applied: 0
encryption_blob_encrypted: 0
size: 2720
is_shared: 0
type_: 4

View File

@ -0,0 +1,12 @@
folder2
id: 7ae4083db8e64328a4d3ccab6279c6bf
created_time: 2020-07-25T10:55:18.442Z
updated_time: 2020-07-25T10:55:18.442Z
user_created_time: 2020-07-25T10:55:18.442Z
user_updated_time: 2020-07-25T10:55:18.442Z
encryption_cipher_text:
encryption_applied: 0
parent_id:
is_shared: 0
type_: 2

View File

@ -0,0 +1,26 @@
note4
id: a91bf5ddf3a749d2be010e9a04e5a1cc
parent_id: c4e45cadb2e84beb801980155a707e21
created_time: 2020-07-25T10:55:18.437Z
updated_time: 2020-07-25T10:55:18.437Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin
source_application: net.cozic.joplintest-cli
application_data:
order: 1595674518437
user_created_time: 2020-07-25T10:55:18.437Z
user_updated_time: 2020-07-25T10:55:18.437Z
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 0
type_: 1

View File

@ -0,0 +1,12 @@
tag2
id: b684a65012c74c508b891935ecf2f5b1
created_time: 2020-07-25T10:55:18.432Z
updated_time: 2020-07-25T10:55:18.432Z
user_created_time: 2020-07-25T10:55:18.432Z
user_updated_time: 2020-07-25T10:55:18.432Z
encryption_cipher_text:
encryption_applied: 0
is_shared: 0
parent_id:
type_: 5

View File

@ -0,0 +1,28 @@
note5
![photo.jpg](:/006a89df4de64a22b4b1fa71f87fd258)
id: bf551517ef7c40be9477168677d0b77a
parent_id: 40f117103de1405586b4289a55e0ea22
created_time: 2020-07-25T10:55:18.444Z
updated_time: 2020-07-25T10:55:18.551Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin
source_application: net.cozic.joplintest-cli
application_data:
order: 1595674518444
user_created_time: 2020-07-25T10:55:18.444Z
user_updated_time: 2020-07-25T10:55:18.551Z
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 0
type_: 1

View File

@ -0,0 +1,12 @@
folder1
id: c4e45cadb2e84beb801980155a707e21
created_time: 2020-07-25T10:55:18.120Z
updated_time: 2020-07-25T10:55:18.120Z
user_created_time: 2020-07-25T10:55:18.120Z
user_updated_time: 2020-07-25T10:55:18.120Z
encryption_cipher_text:
encryption_applied: 0
parent_id:
is_shared: 0
type_: 2

View File

@ -0,0 +1,26 @@
note2
id: edd3cb394ada4d389c01c2bdca09b3ef
parent_id: 2fa39884ba3b47a489dae93dc20021f2
created_time: 2020-07-25T10:55:18.422Z
updated_time: 2020-07-25T10:55:18.422Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin
source_application: net.cozic.joplintest-cli
application_data:
order: 1595674518422
user_created_time: 2020-07-25T10:55:18.422Z
user_updated_time: 2020-07-25T10:55:18.422Z
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 0
type_: 1

View File

@ -0,0 +1 @@
{"version":2}

View File

@ -5,9 +5,13 @@ const Setting = require('lib/models/Setting');
const Folder = require('lib/models/Folder');
const Note = require('lib/models/Note');
const Tag = require('lib/models/Tag');
const Resource = require('lib/models/Resource');
const markdownUtils = require('lib/markdownUtils');
const {shim} = require('lib/shim');
const fs = require('fs-extra');
const snapshotBaseDir = `${__dirname}/../../tests/support/syncTargetSnapshots`;
const testData = {
folder1: {
subFolder1: {},
@ -96,6 +100,12 @@ async function checkTestData(data) {
await recurseCheck(data);
}
async function deploySyncTargetSnapshot(syncTargetType, syncVersion) {
const sourceDir = `${snapshotBaseDir}/${syncVersion}/${syncTargetType}`;
await fs.remove(syncDir);
await fs.copy(sourceDir, syncDir);
}
async function main(syncTargetType) {
const validSyncTargetTypes = ['normal', 'e2ee'];
if (!validSyncTargetTypes.includes(syncTargetType)) throw new Error('Sync target type must be: ' + validSyncTargetTypes.join(', '));
@ -112,7 +122,7 @@ async function main(syncTargetType) {
await synchronizer().start();
if (!Setting.value('syncVersion')) throw new Error('syncVersion is not set');
const destDir = `${__dirname}/../../tests/support/syncTargetSnapshots/${Setting.value('syncVersion')}/${syncTargetType}`;
const destDir = `${snapshotBaseDir}/${Setting.value('syncVersion')}/${syncTargetType}`;
await fs.mkdirp(destDir); // Create intermediate directories
await fs.remove(destDir);
await fs.mkdirp(destDir);
@ -125,4 +135,5 @@ module.exports = {
checkTestData,
main,
testData,
deploySyncTargetSnapshot,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,201 @@
import LockHandler, { LockType, LockHandlerOptions, Lock } from 'lib/services/synchronizer/LockHandler';
require('app-module-path').addPath(__dirname);
const { isNetworkSyncTarget, asyncTest, fileApi, setupDatabaseAndSynchronizer, synchronizer, switchClient, msleep, expectThrow, expectNotThrow } = require('test-utils.js');
process.on('unhandledRejection', (reason:any, p:any) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
// For tests with memory of file system we can use low intervals to make the tests faster.
// However if we use such low values with network sync targets, some calls might randomly fail with
// ECONNRESET and similar errors (Dropbox or OneDrive migth also throttle). Also we can't use a
// low lock TTL value because the lock might expire between the time it's written and the time it's checked.
// For that reason we add this multiplier for non-memory sync targets.
const timeoutMultipler = isNetworkSyncTarget() ? 100 : 1;
let lockHandler_:LockHandler = null;
function newLockHandler(options:LockHandlerOptions = null):LockHandler {
return new LockHandler(fileApi(), options);
}
function lockHandler():LockHandler {
if (lockHandler_) return lockHandler_;
lockHandler_ = new LockHandler(fileApi());
return lockHandler_;
}
describe('synchronizer_LockHandler', function() {
beforeEach(async (done:Function) => {
// logger.setLevel(Logger.LEVEL_WARN);
lockHandler_ = null;
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
await synchronizer().start(); // Need to sync once to setup the sync target and allow locks to work
// logger.setLevel(Logger.LEVEL_DEBUG);
done();
});
it('should acquire and release a sync lock', asyncTest(async () => {
await lockHandler().acquireLock(LockType.Sync, 'mobile', '123456');
const locks = await lockHandler().locks(LockType.Sync);
expect(locks.length).toBe(1);
expect(locks[0].type).toBe(LockType.Sync);
expect(locks[0].clientId).toBe('123456');
expect(locks[0].clientType).toBe('mobile');
await lockHandler().releaseLock(LockType.Sync, 'mobile', '123456');
expect((await lockHandler().locks(LockType.Sync)).length).toBe(0);
}));
it('should not use files that are not locks', asyncTest(async () => {
await fileApi().put('locks/desktop.ini', 'a');
await fileApi().put('locks/exclusive.json', 'a');
await fileApi().put('locks/garbage.json', 'a');
await fileApi().put('locks/sync_mobile_72c4d1b7253a4475bfb2f977117d26ed.json', 'a');
const locks = await lockHandler().locks(LockType.Sync);
expect(locks.length).toBe(1);
}));
it('should allow multiple sync locks', asyncTest(async () => {
await lockHandler().acquireLock(LockType.Sync, 'mobile', '111');
await switchClient(2);
await lockHandler().acquireLock(LockType.Sync, 'mobile', '222');
expect((await lockHandler().locks(LockType.Sync)).length).toBe(2);
{
await lockHandler().releaseLock(LockType.Sync, 'mobile', '222');
const locks = await lockHandler().locks(LockType.Sync);
expect(locks.length).toBe(1);
expect(locks[0].clientId).toBe('111');
}
}));
it('should auto-refresh a lock', asyncTest(async () => {
const handler = newLockHandler({ autoRefreshInterval: 100 * timeoutMultipler });
const lock = await handler.acquireLock(LockType.Sync, 'desktop', '111');
const lockBefore = await handler.activeLock(LockType.Sync, 'desktop', '111');
handler.startAutoLockRefresh(lock, () => {});
await msleep(500 * timeoutMultipler);
const lockAfter = await handler.activeLock(LockType.Sync, 'desktop', '111');
expect(lockAfter.updatedTime).toBeGreaterThan(lockBefore.updatedTime);
handler.stopAutoLockRefresh(lock);
}));
it('should call the error handler when lock has expired while being auto-refreshed', asyncTest(async () => {
const handler = newLockHandler({
lockTtl: 50 * timeoutMultipler,
autoRefreshInterval: 200 * timeoutMultipler,
});
const lock = await handler.acquireLock(LockType.Sync, 'desktop', '111');
let autoLockError:any = null;
handler.startAutoLockRefresh(lock, (error:any) => {
autoLockError = error;
});
await msleep(250 * timeoutMultipler);
expect(autoLockError.code).toBe('lockExpired');
handler.stopAutoLockRefresh(lock);
}));
it('should not allow sync locks if there is an exclusive lock', asyncTest(async () => {
await lockHandler().acquireLock(LockType.Exclusive, 'desktop', '111');
await expectThrow(async () => {
await lockHandler().acquireLock(LockType.Sync, 'mobile', '222');
}, 'hasExclusiveLock');
}));
it('should not allow exclusive lock if there are sync locks', asyncTest(async () => {
const lockHandler = newLockHandler({ lockTtl: 1000 * 60 * 60 });
await lockHandler.acquireLock(LockType.Sync, 'mobile', '111');
await lockHandler.acquireLock(LockType.Sync, 'mobile', '222');
await expectThrow(async () => {
await lockHandler.acquireLock(LockType.Exclusive, 'desktop', '333');
}, 'hasSyncLock');
}));
it('should allow exclusive lock if the sync locks have expired', asyncTest(async () => {
const lockHandler = newLockHandler({ lockTtl: 500 * timeoutMultipler });
await lockHandler.acquireLock(LockType.Sync, 'mobile', '111');
await lockHandler.acquireLock(LockType.Sync, 'mobile', '222');
await msleep(600 * timeoutMultipler);
await expectNotThrow(async () => {
await lockHandler.acquireLock(LockType.Exclusive, 'desktop', '333');
});
}));
it('should decide what is the active exclusive lock', asyncTest(async () => {
const lockHandler = newLockHandler();
{
const lock1:Lock = { type: LockType.Exclusive, clientId: '1', clientType: 'd' };
const lock2:Lock = { type: LockType.Exclusive, clientId: '2', clientType: 'd' };
await lockHandler.saveLock_(lock1);
await msleep(100);
await lockHandler.saveLock_(lock2);
const activeLock = await lockHandler.activeLock(LockType.Exclusive);
expect(activeLock.clientId).toBe('1');
}
}));
// it('should not have race conditions', asyncTest(async () => {
// const lockHandler = newLockHandler();
// const clients = [];
// for (let i = 0; i < 20; i++) {
// clients.push({
// id: 'client' + i,
// type: 'desktop',
// });
// }
// for (let loopIndex = 0; loopIndex < 1000; loopIndex++) {
// const promises:Promise<void | Lock>[] = [];
// for (let clientIndex = 0; clientIndex < clients.length; clientIndex++) {
// const client = clients[clientIndex];
// promises.push(
// lockHandler.acquireLock(LockType.Exclusive, client.type, client.id).catch(() => {})
// );
// // if (gotLock) {
// // await msleep(100);
// // const locks = await lockHandler.locks(LockType.Exclusive);
// // console.info('=======================================');
// // console.info(locks);
// // lockHandler.releaseLock(LockType.Exclusive, client.type, client.id);
// // }
// // await msleep(500);
// }
// const result = await Promise.all(promises);
// const locks = result.filter((lock:any) => !!lock);
// expect(locks.length).toBe(1);
// const lock:Lock = locks[0] as Lock;
// const allLocks = await lockHandler.locks();
// console.info('================================', allLocks);
// lockHandler.releaseLock(LockType.Exclusive, lock.clientType, lock.clientId);
// }
// }));
});

View File

@ -0,0 +1,159 @@
import LockHandler from 'lib/services/synchronizer/LockHandler';
import MigrationHandler from 'lib/services/synchronizer/MigrationHandler';
// To create a sync target snapshot for the current syncVersion:
// - In test-utils, set syncTargetName_ to "filesystem"
// - Then run:
// gulp buildTests -L && node tests-build/support/createSyncTargetSnapshot.js normal && node tests-build/support/createSyncTargetSnapshot.js e2ee
require('app-module-path').addPath(__dirname);
const { asyncTest, setSyncTargetName, fileApi, synchronizer, decryptionWorker, encryptionService, setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow } = require('test-utils.js');
const { deploySyncTargetSnapshot, testData, checkTestData } = require('./support/syncTargetUtils');
const Setting = require('lib/models/Setting');
const MasterKey = require('lib/models/MasterKey');
const specTimeout = 60000 * 10; // Nextcloud tests can be slow
let lockHandler_:LockHandler = null;
let migrationHandler_:MigrationHandler = null;
function lockHandler():LockHandler {
if (lockHandler_) return lockHandler_;
lockHandler_ = new LockHandler(fileApi());
return lockHandler_;
}
function migrationHandler(clientId:string = 'abcd'):MigrationHandler {
if (migrationHandler_) return migrationHandler_;
migrationHandler_ = new MigrationHandler(fileApi(), lockHandler(), 'desktop', clientId);
return migrationHandler_;
}
interface MigrationTests {
[key:string]: Function;
}
const migrationTests:MigrationTests = {
2: async function() {
const items = (await fileApi().list('', { includeHidden: true })).items;
expect(items.filter((i:any) => i.path === '.resource' && i.isDir).length).toBe(1);
expect(items.filter((i:any) => i.path === 'locks' && i.isDir).length).toBe(1);
expect(items.filter((i:any) => i.path === 'temp' && i.isDir).length).toBe(1);
expect(items.filter((i:any) => i.path === 'info.json' && !i.isDir).length).toBe(1);
const versionForOldClients = await fileApi().get('.sync/version.txt');
expect(versionForOldClients).toBe('2');
},
};
let previousSyncTargetName:string = '';
describe('synchronizer_MigrationHandler', function() {
beforeEach(async (done:Function) => {
// To test the migrations, we have to use the filesystem sync target
// because the sync target snapshots are plain files. Eventually
// it should be possible to copy a filesystem target to memory
// but for now that will do.
previousSyncTargetName = setSyncTargetName('filesystem');
lockHandler_ = null;
migrationHandler_ = null;
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
done();
});
afterEach(async (done:Function) => {
setSyncTargetName(previousSyncTargetName);
done();
});
it('should not allow syncing if the sync target is out-dated', asyncTest(async () => {
await synchronizer().start();
await fileApi().put('info.json', `{"version":${Setting.value('syncVersion') - 1}}`);
await expectThrow(async () => await migrationHandler().checkCanSync(), 'outdatedSyncTarget');
}), specTimeout);
it('should not allow syncing if the client is out-dated', asyncTest(async () => {
await synchronizer().start();
await fileApi().put('info.json', `{"version":${Setting.value('syncVersion') + 1}}`);
await expectThrow(async () => await migrationHandler().checkCanSync(), 'outdatedClient');
}), specTimeout);
for (const migrationVersionString in migrationTests) {
const migrationVersion = Number(migrationVersionString);
it(`should migrate (${migrationVersion})`, asyncTest(async () => {
await deploySyncTargetSnapshot('normal', migrationVersion - 1);
const info = await migrationHandler().fetchSyncTargetInfo();
expect(info.version).toBe(migrationVersion - 1);
// Now, migrate to the new version
await migrationHandler().upgrade(migrationVersion);
// Verify that it has been upgraded
const newInfo = await migrationHandler().fetchSyncTargetInfo();
expect(newInfo.version).toBe(migrationVersion);
await migrationTests[migrationVersion]();
// Now sync with that upgraded target
await synchronizer().start();
// Check that the data has not been altered
await expectNotThrow(async () => await checkTestData(testData));
// Check what happens if we switch to a different client and sync
await switchClient(2);
Setting.setConstant('syncVersion', migrationVersion);
await synchronizer().start();
await expectNotThrow(async () => await checkTestData(testData));
}), specTimeout);
it(`should migrate (E2EE) (${migrationVersion})`, asyncTest(async () => {
// First create some test data that will be used to validate
// that the migration didn't alter any data.
await deploySyncTargetSnapshot('e2ee', migrationVersion - 1);
// Now, migrate to the new version
Setting.setConstant('syncVersion', migrationVersion);
await migrationHandler().upgrade(migrationVersion);
// Verify that it has been upgraded
const newInfo = await migrationHandler().fetchSyncTargetInfo();
expect(newInfo.version).toBe(migrationVersion);
await migrationTests[migrationVersion]();
// Now sync with that upgraded target
await synchronizer().start();
// Decrypt the data
const masterKey = (await MasterKey.all())[0];
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
await encryptionService().loadMasterKeysFromSettings();
await decryptionWorker().start();
// Check that the data has not been altered
await expectNotThrow(async () => await checkTestData(testData));
// Check what happens if we switch to a different client and sync
await switchClient(2);
Setting.setConstant('syncVersion', migrationVersion);
await synchronizer().start();
// Should throw because data hasn't been decrypted yet
await expectThrow(async () => await checkTestData(testData));
// Enable E2EE and decrypt
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
await encryptionService().loadMasterKeysFromSettings();
await decryptionWorker().start();
// Should not throw because data is decrypted
await expectNotThrow(async () => await checkTestData(testData));
}), specTimeout);
}
});

View File

@ -21,6 +21,7 @@ const { FileApiDriverMemory } = require('lib/file-api-driver-memory.js');
const { FileApiDriverLocal } = require('lib/file-api-driver-local.js');
const { FileApiDriverWebDav } = require('lib/file-api-driver-webdav.js');
const { FileApiDriverDropbox } = require('lib/file-api-driver-dropbox.js');
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
const { FileApiDriverAmazonS3 } = require('lib/file-api-driver-amazon-s3.js');
const BaseService = require('lib/services/BaseService.js');
const { FsDriverNode } = require('lib/fs-driver-node.js');
@ -43,6 +44,7 @@ const ResourceFetcher = require('lib/services/ResourceFetcher.js');
const KvStore = require('lib/services/KvStore.js');
const WebDavApi = require('lib/WebDavApi');
const DropboxApi = require('lib/DropboxApi');
const { OneDriveApi } = require('lib/onedrive-api');
const { loadKeychainServiceAndSettings } = require('lib/services/SettingUtils');
const KeychainServiceDriver = require('lib/services/keychain/KeychainServiceDriver.node').default;
const KeychainServiceDriverDummy = require('lib/services/keychain/KeychainServiceDriver.dummy').default;
@ -50,14 +52,15 @@ const md5 = require('md5');
const S3 = require('aws-sdk/clients/s3');
const databases_ = [];
const synchronizers_ = [];
let synchronizers_ = [];
const synchronizerContexts_ = {};
const fileApis_ = {};
const encryptionServices_ = [];
const revisionServices_ = [];
const decryptionWorkers_ = [];
const resourceServices_ = [];
const resourceFetchers_ = [];
const kvStores_ = [];
let fileApi_ = null;
let currentClient_ = 1;
// The line `process.on('unhandledRejection'...` in all the test files is going to
@ -88,16 +91,39 @@ SyncTargetRegistry.addClass(SyncTargetNextcloud);
SyncTargetRegistry.addClass(SyncTargetDropbox);
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
// const syncTargetId_ = SyncTargetRegistry.nameToId("nextcloud");
const syncTargetId_ = SyncTargetRegistry.nameToId('memory');
// const syncTargetId_ = SyncTargetRegistry.nameToId('filesystem');
// const syncTargetId_ = SyncTargetRegistry.nameToId('dropbox');
// const syncTargetId_ = SyncTargetRegistry.nameToId('amazon_s3');
let syncTargetName_ = '';
let syncTargetId_ = null;
let sleepTime = 0;
let isNetworkSyncTarget_ = false;
function syncTargetName() {
return syncTargetName_;
}
function setSyncTargetName(name) {
if (name === syncTargetName_) return syncTargetName_;
const previousName = syncTargetName_;
syncTargetName_ = name;
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3'].includes(syncTargetName_);
synchronizers_ = [];
return previousName;
}
setSyncTargetName('memory');
// setSyncTargetName('nextcloud');
// setSyncTargetName('dropbox');
// setSyncTargetName('onedrive');
// setSyncTargetName('amazon_s3');
console.info(`Testing with sync target: ${syncTargetName_}`);
const syncDir = `${__dirname}/../tests/sync`;
const sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
console.info(`Testing with sync target: ${SyncTargetRegistry.idToName(syncTargetId_)}`);
let defaultJasmineTimeout = 90 * 1000;
if (isNetworkSyncTarget_) defaultJasmineTimeout = 60 * 1000 * 10;
if (typeof jasmine !== 'undefined') jasmine.DEFAULT_TIMEOUT_INTERVAL = defaultJasmineTimeout;
const dbLogger = new Logger();
dbLogger.addTarget('console');
@ -129,6 +155,10 @@ function syncTargetId() {
return syncTargetId_;
}
function isNetworkSyncTarget() {
return isNetworkSyncTarget_;
}
function sleep(n) {
return new Promise((resolve, reject) => {
setTimeout(() => {
@ -137,6 +167,14 @@ function sleep(n) {
});
}
function msleep(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
}
function currentClientId() {
return currentClient_;
}
@ -252,9 +290,11 @@ async function setupDatabaseAndSynchronizer(id = null, options = null) {
if (!synchronizers_[id]) {
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
const syncTarget = new SyncTargetClass(db(id));
await initFileApi();
syncTarget.setFileApi(fileApi());
syncTarget.setLogger(logger);
synchronizers_[id] = await syncTarget.synchronizer();
synchronizerContexts_[id] = null;
}
encryptionServices_[id] = new EncryptionService();
@ -278,6 +318,19 @@ function synchronizer(id = null) {
return synchronizers_[id];
}
// This is like calling synchronizer.start() but it handles the
// complexity of passing around the sync context depending on
// the client.
async function synchronizerStart(id = null, extraOptions = null) {
if (id === null) id = currentClient_;
const context = synchronizerContexts_[id];
const options = Object.assign({}, extraOptions);
if (context) options.context = context;
const newContext = await synchronizer(id).start(options);
synchronizerContexts_[id] = newContext;
return newContext;
}
function encryptionService(id = null) {
if (id === null) id = currentClient_;
return encryptionServices_[id];
@ -331,44 +384,67 @@ async function loadEncryptionMasterKey(id = null, useExisting = false) {
return masterKey;
}
function fileApi() {
if (fileApi_) return fileApi_;
async function initFileApi() {
if (fileApis_[syncTargetId_]) return;
let fileApi = null;
if (syncTargetId_ == SyncTargetRegistry.nameToId('filesystem')) {
fs.removeSync(syncDir);
fs.mkdirpSync(syncDir, 0o755);
fileApi_ = new FileApi(syncDir, new FileApiDriverLocal());
fileApi = new FileApi(syncDir, new FileApiDriverLocal());
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('memory')) {
fileApi_ = new FileApi('/root', new FileApiDriverMemory());
fileApi = new FileApi('/root', new FileApiDriverMemory());
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('nextcloud')) {
const options = {
baseUrl: () => 'http://nextcloud.local/remote.php/dav/files/admin/JoplinTest',
username: () => 'admin',
password: () => '123456',
};
const api = new WebDavApi(options);
fileApi_ = new FileApi('', new FileApiDriverWebDav(api));
const options = require(`${__dirname}/../tests/support/nextcloud-auth.json`);
const api = new WebDavApi({
baseUrl: () => options.baseUrl,
username: () => options.username,
password: () => options.password,
});
fileApi = new FileApi('', new FileApiDriverWebDav(api));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('dropbox')) {
// To get a token, go to the App Console:
// https://www.dropbox.com/developers/apps/
// Then select "JoplinTest" and click "Generated access token"
const api = new DropboxApi();
const authTokenPath = `${__dirname}/support/dropbox-auth.txt`;
const authToken = fs.readFileSync(authTokenPath, 'utf8');
if (!authToken) throw new Error(`Dropbox auth token missing in ${authTokenPath}`);
api.setAuthToken(authToken);
fileApi_ = new FileApi('', new FileApiDriverDropbox(api));
fileApi = new FileApi('', new FileApiDriverDropbox(api));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('onedrive')) {
// To get a token, open the URL below, then copy the *complete*
// redirection URL in onedrive-auth.txt. Keep in mind that auth data
// only lasts 1h for OneDrive.
// https://login.live.com/oauth20_authorize.srf?client_id=f1e68e1e-a729-4514-b041-4fdd5c7ac03a&scope=files.readwrite,offline_access&response_type=token&redirect_uri=https://joplinapp.org
const { parameters, setEnvOverride } = require('lib/parameters.js');
Setting.setConstant('env', 'dev');
setEnvOverride('test');
const config = parameters().oneDriveTest;
const api = new OneDriveApi(config.id, config.secret, false);
const authData = fs.readFileSync(`${__dirname}/support/onedrive-auth.txt`, 'utf8');
const urlInfo = require('url-parse')(authData, true);
const auth = require('querystring').parse(urlInfo.hash.substr(1));
api.setAuth(auth);
const appDir = await api.appDirectory();
fileApi = new FileApi(appDir, new FileApiDriverOneDrive(api));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('amazon_s3')) {
const amazonS3CredsPath = `${__dirname}/support/amazon-s3-auth.json`;
const amazonS3Creds = require(amazonS3CredsPath);
if (!amazonS3Creds || !amazonS3Creds.accessKeyId) throw new Error(`AWS auth JSON missing in ${amazonS3CredsPath} format should be: { "accessKeyId": "", "secretAccessKey": "", "bucket": "mybucket"}`);
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
fileApi_ = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
}
fileApi.setLogger(logger);
fileApi.setSyncTargetId(syncTargetId_);
fileApi.requestRepeatCount_ = isNetworkSyncTarget_ ? 1 : 0;
fileApi_.setLogger(logger);
fileApi_.setSyncTargetId(syncTargetId_);
fileApi_.requestRepeatCount_ = 0;
return fileApi_;
fileApis_[syncTargetId_] = fileApi;
}
function fileApi() {
return fileApis_[syncTargetId_];
}
function objectsEqual(o1, o2) {
@ -390,6 +466,41 @@ async function checkThrowAsync(asyncFn) {
return hasThrown;
}
async function expectThrow(asyncFn, errorCode = undefined) {
let hasThrown = false;
let thrownError = null;
try {
await asyncFn();
} catch (error) {
hasThrown = true;
thrownError = error;
}
if (!hasThrown) {
expect('not throw').toBe('throw', 'Expected function to throw an error but did not');
} else if (thrownError.code !== errorCode) {
console.error(thrownError);
expect(`error code: ${thrownError.code}`).toBe(`error code: ${errorCode}`);
} else {
expect(true).toBe(true);
}
}
async function expectNotThrow(asyncFn) {
let thrownError = null;
try {
await asyncFn();
} catch (error) {
thrownError = error;
}
if (thrownError) {
expect(thrownError.message).toBe('', 'Expected function not to throw an error but it did');
} else {
expect(true).toBe(true);
}
}
function checkThrow(fn) {
let hasThrown = false;
try {
@ -427,13 +538,15 @@ function asyncTest(callback) {
}
async function allSyncTargetItemsEncrypted() {
const list = await fileApi().list();
const list = await fileApi().list('', { includeDirs: false });
const files = list.items;
let totalCount = 0;
let encryptedCount = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!BaseItem.isSystemPath(file.path)) continue;
const remoteContentString = await fileApi().get(file.path);
const remoteContent = await BaseItem.unserialize(remoteContentString);
const ItemClass = BaseItem.itemClass(remoteContent);
@ -585,4 +698,4 @@ class TestApp extends BaseApplication {
}
}
module.exports = { syncDir, kvStore, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
module.exports = { synchronizerStart, syncTargetName, setSyncTargetName, syncDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };

View File

@ -1022,7 +1022,7 @@ class Application extends BaseApplication {
}
const sortNoteReverseItem = menu.getMenuItemById('sort:notes:reverse');
sortNoteReverseItem.enabled = state.settings['notes.sortOrder.field'] !== 'order';
if (sortNoteReverseItem) sortNoteReverseItem.enabled = state.settings['notes.sortOrder.field'] !== 'order';
// const devToolsMenuItem = menu.getMenuItemById('help:toggleDevTools');
// devToolsMenuItem.checked = state.devToolsVisible;
@ -1083,39 +1083,6 @@ class Application extends BaseApplication {
return cssString;
}
// async createManyNotes() {
// return;
// const folderIds = [];
// const randomFolderId = (folderIds) => {
// if (!folderIds.length) return '';
// const idx = Math.floor(Math.random() * folderIds.length);
// if (idx > folderIds.length - 1) throw new Error('Invalid index ' + idx + ' / ' + folderIds.length);
// return folderIds[idx];
// }
// let rootFolderCount = 0;
// let folderCount = 100;
// for (let i = 0; i < folderCount; i++) {
// let parentId = '';
// if (Math.random() >= 0.9 || rootFolderCount >= folderCount / 10) {
// parentId = randomFolderId(folderIds);
// } else {
// rootFolderCount++;
// }
// const folder = await Folder.save({ title: 'folder' + i, parent_id: parentId });
// folderIds.push(folder.id);
// }
// for (let i = 0; i < 10000; i++) {
// const parentId = randomFolderId(folderIds);
// Note.save({ title: 'note' + i, parent_id: parentId });
// }
// }
async start(argv) {
const electronIsDev = require('electron-is-dev');
@ -1125,6 +1092,10 @@ class Application extends BaseApplication {
argv = await super.start(argv);
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
return { action: 'upgradeSyncTarget' };
}
const dir = Setting.value('profileDir');
// Loads app-wide styles. (Markdown preview-specific styles loaded in app.js)

View File

@ -182,6 +182,15 @@ class Bridge {
}
}
restart() {
// Note that in this case we are not sending the "appClose" event
// to notify services and component that the app is about to close
// but for the current use-case it's not really needed.
const { app } = require('electron');
app.relaunch();
app.exit();
}
}
let bridge_ = null;

View File

@ -224,7 +224,7 @@ class MainScreenComponent extends React.Component {
this.styles_.messageBox = {
width: width,
height: 30,
height: 50,
display: 'flex',
alignItems: 'center',
paddingLeft: 10,
@ -315,8 +315,23 @@ class MainScreenComponent extends React.Component {
});
};
const onRestartAndUpgrade = async () => {
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
await Setting.saveAll();
bridge().restart();
};
let msg = null;
if (this.props.hasDisabledSyncItems) {
if (this.props.shouldUpgradeSyncTarget) {
msg = (
<span>
{_('The sync target needs to be upgraded before Joplin can sync. The operation may take a few minutes to complete and the app needs to be restarted. To proceed please click on the link.')}{' '}
<a href="#" onClick={() => onRestartAndUpgrade()}>
{_('Restart and upgrade')}
</a>
</span>
);
} else if (this.props.hasDisabledSyncItems) {
msg = (
<span>
{_('Some items cannot be synchronised.')}{' '}
@ -371,7 +386,7 @@ class MainScreenComponent extends React.Component {
}
messageBoxVisible() {
return this.props.hasDisabledSyncItems || this.props.showMissingMasterKeyMessage || this.props.showNeedUpgradingMasterKeyMessage || this.props.showShouldReencryptMessage || this.props.hasDisabledEncryptionItems;
return this.props.hasDisabledSyncItems || this.props.showMissingMasterKeyMessage || this.props.showNeedUpgradingMasterKeyMessage || this.props.showShouldReencryptMessage || this.props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget;
}
registerCommands() {
@ -492,6 +507,7 @@ const mapStateToProps = state => {
showMissingMasterKeyMessage: state.notLoadedMasterKeys.length && state.masterKeys.length,
showNeedUpgradingMasterKeyMessage: !!EncryptionService.instance().masterKeysThatNeedUpgrading(state.masterKeys).length,
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
selectedFolderId: state.selectedFolderId,
sidebarWidth: state.settings['style.sidebar.width'],
noteListWidth: state.settings['style.noteList.width'],

View File

@ -0,0 +1,102 @@
import * as React from 'react';
import { useEffect } from 'react';
import useSyncTargetUpgrade, { SyncTargetUpgradeResult } from 'lib/services/synchronizer/gui/useSyncTargetUpgrade';
const { render } = require('react-dom');
const ipcRenderer = require('electron').ipcRenderer;
const Setting = require('lib/models/Setting');
const { bridge } = require('electron').remote.require('./bridge');
function useAppCloseHandler(upgradeResult:SyncTargetUpgradeResult) {
useEffect(function() {
async function onAppClose() {
let canClose = true;
if (!upgradeResult.done) {
canClose = confirm('The synchronisation target upgrade is still running and it is recommanded to let it finish. Close the application anyway?');
}
if (canClose) {
// We set the state back to IDLE so that the app can start normally and
// potentially the user can fix issues if any, export the data, etc.
// The message to upgrade will show up again if they try to sync.
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_IDLE);
await Setting.saveAll();
}
ipcRenderer.send('asynchronous-message', 'appCloseReply', {
canClose: canClose,
});
}
ipcRenderer.on('appClose', onAppClose);
return () => {
ipcRenderer.off('appClose', onAppClose);
};
}, [upgradeResult.done]);
}
function useStyle() {
useEffect(function() {
const element = document.createElement('style');
element.appendChild(document.createTextNode(`
body {
font-family: sans-serif;
padding: 5px 20px;
color: #333333;
}
.errorBox {
border: 1px solid red;
padding: 5px 20px;
background-color: #ffeeee;
}
pre {
overflow-x: scroll;
}
`));
document.head.appendChild(element);
}, []);
}
function useRestartOnDone(upgradeResult:SyncTargetUpgradeResult) {
useEffect(function() {
if (upgradeResult.done) {
bridge().restart();
}
}, [upgradeResult.done]);
}
function Root_UpgradeSyncTarget() {
const upgradeResult = useSyncTargetUpgrade();
useStyle();
useRestartOnDone(upgradeResult);
useAppCloseHandler(upgradeResult);
function renderUpgradeError() {
if (!upgradeResult.error) return null;
return (
<div className="errorBox">
<h2>Error</h2>
<p>The sync target could not be upgraded due to an error. For support, please copy the <em>complete</em> content of this page and paste it in the forum: https://discourse.joplinapp.org/</p>
<p>The full error was:</p>
<p>{upgradeResult.error.message}</p>
<pre>{upgradeResult.error.stack}</pre>
</div>
);
}
return (
<div>
<h2>Joplin upgrade in progress...</h2>
<p>Please wait while the sync target is being upgraded. It may take a few seconds or a few minutes depending on the upgrade. The application will automatically restart once it is completed.</p>
{renderUpgradeError()}
</div>
);
}
render(<Root_UpgradeSyncTarget />, document.getElementById('react-root'));

View File

@ -95,8 +95,12 @@ document.addEventListener('auxclick', event => event.preventDefault());
// which would open a new browser window.
document.addEventListener('click', (event) => event.preventDefault());
app().start(bridge().processArgv()).then(() => {
app().start(bridge().processArgv()).then((result) => {
if (!result || !result.action) {
require('./gui/Root.min.js');
} else if (result.action === 'upgradeSyncTarget') {
require('./gui/Root_UpgradeSyncTarget');
}
}).catch((error) => {
const env = bridge().env();

View File

@ -235,6 +235,12 @@
"integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==",
"dev": true
},
"@types/jasmine": {
"version": "3.5.11",
"resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-3.5.11.tgz",
"integrity": "sha512-fg1rOd/DehQTIJTifGqGVY6q92lDgnLfs7C6t1ccSwQrMyoTGSoH6wWzhJDZb6ezhsdwAX4EIBLe8w5fXWmEng==",
"dev": true
},
"@types/node": {
"version": "12.12.38",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.38.tgz",

View File

@ -78,6 +78,7 @@
},
"homepage": "https://github.com/laurent22/joplin#readme",
"devDependencies": {
"@types/jasmine": "^3.5.11",
"ajv": "^6.5.0",
"app-builder-bin": "^1.9.11",
"babel-cli": "^6.26.0",

View File

@ -81,11 +81,16 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
- [Markdown Guide](https://github.com/laurent22/joplin/blob/master/readme/markdown.md)
- [How to enable end-to-end encryption](https://github.com/laurent22/joplin/blob/master/readme/e2ee.md)
- [What is a conflict?](https://github.com/laurent22/joplin/blob/master/readme/conflict.md)
- [End-to-end encryption spec](https://github.com/laurent22/joplin/blob/master/readme/spec.md)
- [How to enable debug mode](https://github.com/laurent22/joplin/blob/master/readme/debugging.md)
- [API documentation](https://github.com/laurent22/joplin/blob/master/readme/api.md)
- [FAQ](https://github.com/laurent22/joplin/blob/master/readme/faq.md)
- Development
- [End-to-end encryption spec](https://github.com/laurent22/joplin/blob/master/readme/spec/e2ee.md)
- [Note History spec](https://github.com/laurent22/joplin/blob/master/readme/spec/history.md)
- [Sync Lock spec](https://github.com/laurent22/joplin/blob/master/readme/spec/sync_lock.md)
- Google Summer of Code 2020
- [Google Summer of Code 2020](https://github.com/laurent22/joplin/blob/master/readme/gsoc2020/index.md)

View File

@ -13,10 +13,6 @@ class BaseSyncTarget {
return false;
}
static resourceDirName() {
return '.resource';
}
option(name, defaultValue = null) {
return this.options_ && name in this.options_ ? this.options_[name] : defaultValue;
}

View File

@ -33,8 +33,15 @@ class SyncTargetOneDrive extends BaseSyncTarget {
return SyncTargetOneDrive.id();
}
isTesting() {
const p = parameters();
return !!p.oneDriveTest;
}
oneDriveParameters() {
return parameters().oneDrive;
const p = parameters();
if (p.oneDriveTest) return p.oneDriveTest;
return p.oneDrive;
}
authRouteName() {
@ -42,6 +49,10 @@ class SyncTargetOneDrive extends BaseSyncTarget {
}
api() {
if (this.isTesting()) {
return this.fileApi_.driver().api();
}
if (this.api_) return this.api_;
const isPublic = Setting.value('appType') != 'cli' && Setting.value('appType') != 'desktop';

View File

@ -450,6 +450,7 @@ class ScreenHeaderComponent extends React.PureComponent {
if (this.props.showMissingMasterKeyMessage) warningComps.push(this.renderWarningBox('EncryptionConfig', _('Press to set the decryption password.')));
if (this.props.hasDisabledSyncItems) warningComps.push(this.renderWarningBox('Status', _('Some items cannot be synchronised. Press for more info.')));
if (this.props.shouldUpgradeSyncTarget && this.props.showShouldUpgradeSyncTargetMessage !== false) warningComps.push(this.renderWarningBox('UpgradeSyncTarget', _('The sync target needs to be upgraded. Press this banner to proceed.')));
const showSideMenuButton = !!this.props.showSideMenuButton && !this.props.noteSelectionEnabled;
const showSelectAllButton = this.props.noteSelectionEnabled;
@ -536,6 +537,7 @@ const ScreenHeader = connect(state => {
selectedNoteIds: state.selectedNoteIds,
showMissingMasterKeyMessage: state.notLoadedMasterKeys.length && state.masterKeys.length,
hasDisabledSyncItems: state.hasDisabledSyncItems,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
};
})(ScreenHeaderComponent);

View File

@ -0,0 +1,72 @@
import * as React from 'react';
import { View, Text, ScrollView } from 'react-native';
import useSyncTargetUpgrade from 'lib/services/synchronizer/gui/useSyncTargetUpgrade';
const { connect } = require('react-redux');
const { themeStyle } = require('lib/components/global-style.js');
const { ScreenHeader } = require('lib/components/screen-header.js');
const { _ } = require('lib/locale.js');
function UpgradeSyncTargetScreen(props:any) {
const upgradeResult = useSyncTargetUpgrade();
const theme = themeStyle(props.theme);
const lineStyle = { ...theme.normalText, marginBottom: 20 };
const stackTraceStyle = { ...theme.normalText, flexWrap: 'nowrap', fontSize: theme.fontSize * 0.5, color: theme.colorFaded };
const headerStyle = { ...theme.headerStyle, marginBottom: 20 };
function renderUpgradeError() {
if (!upgradeResult.error) return null;
return (
<View style={{ backgroundColor: theme.backgroundColor, flex: 1, flexDirection: 'column' }}>
<Text style={headerStyle}>Error</Text>
<Text style={lineStyle}>The sync target could not be upgraded due to an error. For support, please copy the content of this page and paste it in the forum: https://discourse.joplinapp.org/</Text>
<Text style={lineStyle}>The full error was:</Text>
<Text style={lineStyle}>{upgradeResult.error.message}</Text>
<Text style={stackTraceStyle}>{upgradeResult.error.stack}</Text>
</View>
);
}
function renderInProgress() {
if (upgradeResult.error || upgradeResult.done) return null;
return (
<View>
<Text style={headerStyle}>Joplin upgrade in progress...</Text>
<Text style={lineStyle}>Please wait while the sync target is being upgraded. It may take a few seconds or a few minutes depending on the upgrade.</Text>
<Text style={lineStyle}>Make sure you leave your device on and the app opened while the upgrade is in progress.</Text>
</View>
);
}
function renderDone() {
if (upgradeResult.error || !upgradeResult.done) return null;
return (
<View>
<Text style={headerStyle}>Upgrade complete</Text>
<Text style={lineStyle}>The upgrade has been applied successfully. Please press Back to exit this screen.</Text>
</View>
);
}
return (
<ScrollView style={{ flex: 1, flexDirection: 'column', backgroundColor: theme.backgroundColor }}>
<ScreenHeader title={_('Sync Target Upgrade')} parentComponent={this} showShouldUpgradeSyncTargetMessage={false} showSearchButton={false} showBackButton={upgradeResult.done}/>
<View style={{ padding: 15, flex: 1 }}>
{renderInProgress()}
{renderDone()}
{renderUpgradeError()}
</View>
</ScrollView>
);
}
export default connect((state:any) => {
return {
theme: state.settings.theme,
};
})(UpgradeSyncTargetScreen);

View File

@ -111,7 +111,7 @@ class FileApiDriverMemory {
this.items_.push(item);
} else {
this.items_[index].content = this.encodeContent_(content);
this.items_[index].updated_time = time.unix();
this.items_[index].updated_time = time.unixMs();
}
}

View File

@ -78,6 +78,10 @@ class FileApiDriverOneDrive {
}
async list(path, options = null) {
options = Object.assign({}, {
context: null,
}, options);
let query = this.itemFilter_();
let url = `${this.makePath_(path)}:/children`;
@ -186,8 +190,23 @@ class FileApiDriverOneDrive {
return this.pathCache_[path];
}
clearRoot() {
throw new Error('Not implemented');
async clearRoot() {
const recurseItems = async (path) => {
const result = await this.list(this.fileApi_.fullPath_(path));
const output = [];
for (const item of result.items) {
const fullPath = `${path}/${item.path}`;
if (item.isDir) {
await recurseItems(fullPath);
}
await this.delete(this.fileApi_.fullPath_(fullPath));
}
return output;
};
await recurseItems('');
}
async delta(path, options = null) {

View File

@ -89,7 +89,7 @@ class FileApiDriverWebDav {
async delta(path, options) {
const getDirStats = async path => {
const result = await this.list(path);
const result = await this.list(path, { includeDirs: false });
return result.items;
};
@ -130,13 +130,25 @@ class FileApiDriverWebDav {
}
async list(path) {
// See mkdir() call for explanation
if (!path.endsWith('/')) path = `${path}/`;
const result = await this.api().execPropFind(path, 1, ['d:getlastmodified', 'd:resourcetype']);
// See mkdir() call for explanation about trailing slash
const result = await this.api().execPropFind(!path.endsWith('/') ? `${path}/` : path, 1, ['d:getlastmodified', 'd:resourcetype']);
const resources = this.api().arrayFromJson(result, ['d:multistatus', 'd:response']);
const stats = this.statsFromResources_(resources);
const stats = this.statsFromResources_(resources).map((stat) => {
if (path && stat.path.indexOf(`${path}/`) === 0) {
const s = stat.path.substr(path.length + 1);
if (s.split('/').length === 1) {
return {
...stat,
path: stat.path.substr(path.length + 1),
};
}
}
return stat;
}).filter((stat) => {
return stat.path !== rtrimSlashes(path);
});
return {
items: stats,

View File

@ -128,6 +128,8 @@ class FileApi {
if (!options) options = {};
if (!('includeHidden' in options)) options.includeHidden = false;
if (!('context' in options)) options.context = null;
if (!('includeDirs' in options)) options.includeDirs = true;
if (!('syncItemsOnly' in options)) options.syncItemsOnly = false;
this.logger().debug(`list ${this.baseDir()}`);
@ -141,6 +143,14 @@ class FileApi {
result.items = temp;
}
if (!options.includeDirs) {
result.items = result.items.filter(f => !f.isDir);
}
if (options.syncItemsOnly) {
result.items = result.items.filter(f => !f.isDir && BaseItem.isSystemPath(f.path));
}
return result;
}

View File

@ -86,6 +86,12 @@ class Setting extends BaseModel {
},
},
'sync.upgradeState': {
value: Setting.SYNC_UPGRADE_STATE_IDLE,
type: Setting.TYPE_INT,
public: false,
},
'sync.2.path': {
value: '',
type: Setting.TYPE_STRING,
@ -1324,6 +1330,10 @@ Setting.SHOULD_REENCRYPT_NO = 0; // Data doesn't need to be re-encrypted
Setting.SHOULD_REENCRYPT_YES = 1; // Data should be re-encrypted
Setting.SHOULD_REENCRYPT_NOTIFIED = 2; // Data should be re-encrypted, and user has been notified
Setting.SYNC_UPGRADE_STATE_IDLE = 0; // Doesn't need to be upgraded
Setting.SYNC_UPGRADE_STATE_SHOULD_DO = 1; // Should be upgraded, but waiting for user to confirm
Setting.SYNC_UPGRADE_STATE_MUST_DO = 2; // Must be upgraded - on next restart, the upgrade will start
Setting.custom_css_files = {
JOPLIN_APP: 'userchrome.css',
RENDERED_MARKDOWN: 'userstyle.css',
@ -1344,7 +1354,7 @@ Setting.constants_ = {
templateDir: '',
tempDir: '',
flagOpenDevTools: false,
syncVersion: 1,
syncVersion: 2,
};
Setting.autoSaveEnabled = true;

View File

@ -225,7 +225,10 @@ class OneDriveApi {
// In general, `path` contains a path relative to the base URL, but in some
// cases the full URL is provided (for example, when it's a URL that was
// retrieved from the API).
if (url.indexOf('https://') !== 0) url = `https://graph.microsoft.com/v1.0${path}`;
if (url.indexOf('https://') !== 0) {
const slash = path.indexOf('/') === 0 ? '' : '/';
url = `https://graph.microsoft.com/v1.0${slash}${path}`;
}
if (query) {
url += url.indexOf('?') < 0 ? '?' : '&';

View File

@ -2,6 +2,13 @@ const Setting = require('lib/models/Setting.js');
const parameters_ = {};
parameters_.test = {
oneDriveTest: {
id: 'f1e68e1e-a729-4514-b041-4fdd5c7ac03a',
secret: '~PC7cwAC_AXGICk_V0~12SmI9lbaC-MBDT',
},
};
parameters_.dev = {
oneDrive: {
id: 'cbabb902-d276-4ea4-aa88-062a5889d6dc',
@ -32,7 +39,13 @@ parameters_.prod = {
},
};
let envOverride_ = null;
function setEnvOverride(env) {
envOverride_ = env;
}
function parameters(env = null) {
if (envOverride_) env = envOverride_;
if (env === null) env = Setting.value('env');
const output = parameters_[env];
if (Setting.value('isDemo')) {
@ -41,4 +54,4 @@ function parameters(env = null) {
return output;
}
module.exports = { parameters };
module.exports = { parameters, setEnvOverride };

View File

@ -1,8 +1,13 @@
class BaseService {
logger() {
if (this.instanceLogger_) return this.instanceLogger_;
if (!BaseService.logger_) throw new Error('BaseService.logger_ not set!!');
return BaseService.logger_;
}
setLogger(v) {
this.instanceLogger_ = v;
}
}
BaseService.logger_ = null;

View File

@ -2,7 +2,7 @@ const Resource = require('lib/models/Resource');
const Setting = require('lib/models/Setting');
const BaseService = require('lib/services/BaseService');
const ResourceService = require('lib/services/ResourceService');
const BaseSyncTarget = require('lib/BaseSyncTarget');
const { Dirnames } = require('lib/services/synchronizer/utils/types');
const { Logger } = require('lib/logger.js');
const EventEmitter = require('events');
const { shim } = require('lib/shim');
@ -17,7 +17,6 @@ class ResourceFetcher extends BaseService {
this.logger_ = new Logger();
this.queue_ = [];
this.fetchingItems_ = {};
this.resourceDirName_ = BaseSyncTarget.resourceDirName();
this.maxDownloads_ = 3;
this.addingResources_ = false;
this.eventEmitter_ = new EventEmitter();
@ -159,7 +158,7 @@ class ResourceFetcher extends BaseService {
this.fetchingItems_[resourceId] = resource;
const localResourceContentPath = Resource.fullPath(resource, !!resource.encryption_blob_encrypted);
const remoteResourceContentPath = `${this.resourceDirName_}/${resource.id}`;
const remoteResourceContentPath = `${Dirnames.Resources}/${resource.id}`;
await Resource.setLocalState(resource, { fetch_status: Resource.FETCH_STATUS_STARTED });

View File

@ -0,0 +1,348 @@
import { Dirnames } from './utils/types';
const JoplinError = require('lib/JoplinError');
const { time } = require('lib/time-utils');
const { fileExtension, filename } = require('lib/path-utils.js');
export enum LockType {
None = '',
Sync = 'sync',
Exclusive = 'exclusive',
}
export interface Lock {
type: LockType,
clientType: string,
clientId: string,
updatedTime?: number,
}
interface RefreshTimer {
id: any,
inProgress: boolean
}
interface RefreshTimers {
[key:string]: RefreshTimer;
}
export interface LockHandlerOptions {
autoRefreshInterval?: number,
lockTtl?: number,
}
export default class LockHandler {
private api_:any = null;
private refreshTimers_:RefreshTimers = {};
private autoRefreshInterval_:number = 1000 * 60;
private lockTtl_:number = 1000 * 60 * 3;
constructor(api:any, options:LockHandlerOptions = null) {
if (!options) options = {};
this.api_ = api;
if ('lockTtl' in options) this.lockTtl_ = options.lockTtl;
if ('autoRefreshInterval' in options) this.autoRefreshInterval_ = options.autoRefreshInterval;
}
public get lockTtl():number {
return this.lockTtl_;
}
// Should only be done for testing purposes since all clients should
// use the same lock max age.
public set lockTtl(v:number) {
this.lockTtl_ = v;
}
private lockFilename(lock:Lock) {
return `${[lock.type, lock.clientType, lock.clientId].join('_')}.json`;
}
private lockTypeFromFilename(name:string):LockType {
const ext = fileExtension(name);
if (ext !== 'json') return LockType.None;
if (name.indexOf(LockType.Sync) === 0) return LockType.Sync;
if (name.indexOf(LockType.Exclusive) === 0) return LockType.Exclusive;
return LockType.None;
}
private lockFilePath(lock:Lock) {
return `${Dirnames.Locks}/${this.lockFilename(lock)}`;
}
private lockFileToObject(file:any):Lock {
const p = filename(file.path).split('_');
return {
type: p[0],
clientType: p[1],
clientId: p[2],
updatedTime: file.updated_time,
};
}
async locks(lockType:LockType = null):Promise<Lock[]> {
const result = await this.api_.list(Dirnames.Locks);
if (result.hasMore) throw new Error('hasMore not handled'); // Shouldn't happen anyway
const output = [];
for (const file of result.items) {
const type = this.lockTypeFromFilename(file.path);
if (type === LockType.None) continue;
if (lockType && type !== lockType) continue;
const lock = this.lockFileToObject(file);
output.push(lock);
}
return output;
}
private lockIsActive(lock:Lock):boolean {
return Date.now() - lock.updatedTime < this.lockTtl;
}
async hasActiveLock(lockType:LockType, clientType:string = null, clientId:string = null) {
const lock = await this.activeLock(lockType, clientType, clientId);
return !!lock;
}
// Finds if there's an active lock for this clientType and clientId and returns it.
// If clientType and clientId are not specified, returns the first active lock
// of that type instead.
async activeLock(lockType:LockType, clientType:string = null, clientId:string = null) {
const locks = await this.locks(lockType);
if (lockType === LockType.Exclusive) {
const activeLocks = locks
.slice()
.filter((lock:Lock) => this.lockIsActive(lock))
.sort((a:Lock, b:Lock) => {
if (a.updatedTime === b.updatedTime) {
return a.clientId < b.clientId ? -1 : +1;
}
return a.updatedTime < b.updatedTime ? -1 : +1;
});
if (!activeLocks.length) return null;
const activeLock = activeLocks[0];
if (clientType && clientType !== activeLock.clientType) return null;
if (clientId && clientId !== activeLock.clientId) return null;
return activeLock;
} else if (lockType === LockType.Sync) {
for (const lock of locks) {
if (clientType && lock.clientType !== clientType) continue;
if (clientId && lock.clientId !== clientId) continue;
if (this.lockIsActive(lock)) return lock;
}
return null;
}
throw new Error(`Unsupported lock type: ${lockType}`);
}
private async saveLock(lock:Lock) {
await this.api_.put(this.lockFilePath(lock), JSON.stringify(lock));
}
// This is for testing only
public async saveLock_(lock:Lock) {
return this.saveLock(lock);
}
private async acquireSyncLock(clientType:string, clientId:string):Promise<Lock> {
try {
let isFirstPass = true;
while (true) {
const [exclusiveLock, syncLock] = await Promise.all([
this.activeLock(LockType.Exclusive),
this.activeLock(LockType.Sync, clientType, clientId),
]);
if (exclusiveLock) {
throw new JoplinError(`Cannot acquire sync lock because the following client has an exclusive lock on the sync target: ${this.lockToClientString(exclusiveLock)}`, 'hasExclusiveLock');
}
if (syncLock) {
// Normally the second pass should happen immediately afterwards, but if for some reason
// (slow network, etc.) it took more than 10 seconds then refresh the lock.
if (isFirstPass || Date.now() - syncLock.updatedTime > 1000 * 10) {
await this.saveLock(syncLock);
}
return syncLock;
}
// Something wrong happened, which means we saved a lock but we didn't read
// it back. Could be application error or server issue.
if (!isFirstPass) throw new Error('Cannot acquire sync lock: either the lock could be written but not read back. Or it was expired before it was read again.');
await this.saveLock({
type: LockType.Sync,
clientType: clientType,
clientId: clientId,
});
isFirstPass = false;
}
} catch (error) {
await this.releaseLock(LockType.Sync, clientType, clientId);
throw error;
}
}
private lockToClientString(lock:Lock):string {
return `(${lock.clientType} #${lock.clientId})`;
}
private async acquireExclusiveLock(clientType:string, clientId:string, timeoutMs:number = 0):Promise<Lock> {
// The logic to acquire an exclusive lock, while avoiding race conditions is as follow:
//
// - Check if there is a lock file present
//
// - If there is a lock file, see if I'm the one owning it by checking that its content has my identifier.
// - If that's the case, just write to the data file then delete the lock file.
// - If that's not the case, just wait a second or a small random length of time and try the whole cycle again-.
//
// -If there is no lock file, create one with my identifier and try the whole cycle again to avoid race condition (re-check that the lock file is really mine)-.
const startTime = Date.now();
async function waitForTimeout() {
if (!timeoutMs) return false;
const elapsed = Date.now() - startTime;
if (timeoutMs && elapsed < timeoutMs) {
await time.sleep(2);
return true;
}
return false;
}
try {
while (true) {
const [activeSyncLock, activeExclusiveLock] = await Promise.all([
this.activeLock(LockType.Sync),
this.activeLock(LockType.Exclusive),
]);
if (activeSyncLock) {
if (await waitForTimeout()) continue;
throw new JoplinError(`Cannot acquire exclusive lock because the following clients have a sync lock on the target: ${this.lockToClientString(activeSyncLock)}`, 'hasSyncLock');
}
if (activeExclusiveLock) {
if (activeExclusiveLock.clientId === clientId) {
// Save it again to refresh the timestamp
await this.saveLock(activeExclusiveLock);
return activeExclusiveLock;
} else {
// If there's already an exclusive lock, wait for it to be released
if (await waitForTimeout()) continue;
throw new JoplinError(`Cannot acquire exclusive lock because the following client has an exclusive lock on the sync target: ${this.lockToClientString(activeExclusiveLock)}`, 'hasExclusiveLock');
}
} else {
// If there's not already an exclusive lock, acquire one
// then loop again to check that we really got the lock
// (to prevent race conditions)
await this.saveLock({
type: LockType.Exclusive,
clientType: clientType,
clientId: clientId,
});
await time.msleep(100);
}
}
} catch (error) {
await this.releaseLock(LockType.Exclusive, clientType, clientId);
throw error;
}
}
private autoLockRefreshHandle(lock:Lock) {
return [lock.type, lock.clientType, lock.clientId].join('_');
}
startAutoLockRefresh(lock:Lock, errorHandler:Function):string {
const handle = this.autoLockRefreshHandle(lock);
if (this.refreshTimers_[handle]) {
throw new Error(`There is already a timer refreshing this lock: ${handle}`);
}
this.refreshTimers_[handle] = {
id: null,
inProgress: false,
};
this.refreshTimers_[handle].id = setInterval(async () => {
if (this.refreshTimers_[handle].inProgress) return;
const defer = () => {
if (!this.refreshTimers_[handle]) return;
this.refreshTimers_[handle].inProgress = false;
};
this.refreshTimers_[handle].inProgress = true;
let error = null;
const hasActiveLock = await this.hasActiveLock(lock.type, lock.clientType, lock.clientId);
if (!this.refreshTimers_[handle]) return defer(); // Timeout has been cleared
if (!hasActiveLock) {
error = new JoplinError('Lock has expired', 'lockExpired');
} else {
try {
await this.acquireLock(lock.type, lock.clientType, lock.clientId);
if (!this.refreshTimers_[handle]) return defer(); // Timeout has been cleared
} catch (e) {
error = e;
}
}
if (error) {
if (this.refreshTimers_[handle]) {
clearInterval(this.refreshTimers_[handle].id);
delete this.refreshTimers_[handle];
}
errorHandler(error);
}
defer();
}, this.autoRefreshInterval_);
return handle;
}
stopAutoLockRefresh(lock:Lock) {
const handle = this.autoLockRefreshHandle(lock);
if (!this.refreshTimers_[handle]) {
// Should not throw an error because lock may have been cleared in startAutoLockRefresh
// if there was an error.
// throw new Error(`There is no such lock being auto-refreshed: ${this.lockToString(lock)}`);
return;
}
clearInterval(this.refreshTimers_[handle].id);
delete this.refreshTimers_[handle];
}
async acquireLock(lockType:LockType, clientType:string, clientId:string, timeoutMs:number = 0):Promise<Lock> {
if (lockType === LockType.Sync) {
return this.acquireSyncLock(clientType, clientId);
} else if (lockType === LockType.Exclusive) {
return this.acquireExclusiveLock(clientType, clientId, timeoutMs);
} else {
throw new Error(`Invalid lock type: ${lockType}`);
}
}
async releaseLock(lockType:LockType, clientType:string, clientId:string) {
await this.api_.delete(this.lockFilePath({
type: lockType,
clientType: clientType,
clientId: clientId,
}));
}
}

View File

@ -0,0 +1,141 @@
import LockHandler, { LockType } from './LockHandler';
import { Dirnames } from './utils/types';
const BaseService = require('lib/services/BaseService.js');
// To add a new migration:
// - Add the migration logic in ./migrations/VERSION_NUM.js
// - Add the file to the array below.
// - Set Setting.syncVersion to VERSION_NUM in models/Setting.js
// - Add tests in synchronizer_migrationHandler
const migrations = [
null,
require('./migrations/1.js').default,
require('./migrations/2.js').default,
];
const Setting = require('lib/models/Setting');
const { sprintf } = require('sprintf-js');
const JoplinError = require('lib/JoplinError');
interface SyncTargetInfo {
version: number,
}
export default class MigrationHandler extends BaseService {
private api_:any = null;
private lockHandler_:LockHandler = null;
private clientType_:string;
private clientId_:string;
constructor(api:any, lockHandler:LockHandler, clientType:string, clientId:string) {
super();
this.api_ = api;
this.lockHandler_ = lockHandler;
this.clientType_ = clientType;
this.clientId_ = clientId;
}
public async fetchSyncTargetInfo():Promise<SyncTargetInfo> {
const syncTargetInfoText = await this.api_.get('info.json');
// Returns version 0 if the sync target is empty
let output:SyncTargetInfo = { version: 0 };
if (syncTargetInfoText) {
output = JSON.parse(syncTargetInfoText);
if (!output.version) throw new Error('Missing "version" field in info.json');
} else {
const oldVersion = await this.api_.get('.sync/version.txt');
if (oldVersion) output = { version: 1 };
}
return output;
}
private serializeSyncTargetInfo(info:SyncTargetInfo) {
return JSON.stringify(info);
}
async checkCanSync():Promise<SyncTargetInfo> {
const supportedSyncTargetVersion = Setting.value('syncVersion');
const syncTargetInfo = await this.fetchSyncTargetInfo();
if (syncTargetInfo.version) {
if (syncTargetInfo.version > supportedSyncTargetVersion) {
throw new JoplinError(sprintf('Sync version of the target (%d) is greater than the version supported by the client (%d). Please upgrade your client.', syncTargetInfo.version, supportedSyncTargetVersion), 'outdatedClient');
} else if (syncTargetInfo.version < supportedSyncTargetVersion) {
throw new JoplinError(sprintf('Sync version of the target (%d) is lower than the version supported by the client (%d). Please upgrade the sync target.', syncTargetInfo.version, supportedSyncTargetVersion), 'outdatedSyncTarget');
}
}
return syncTargetInfo;
}
async upgrade(targetVersion:number = 0) {
const supportedSyncTargetVersion = Setting.value('syncVersion');
const syncTargetInfo = await this.fetchSyncTargetInfo();
if (syncTargetInfo.version > supportedSyncTargetVersion) {
throw new JoplinError(sprintf('Sync version of the target (%d) is greater than the version supported by the client (%d). Please upgrade your client.', syncTargetInfo.version, supportedSyncTargetVersion), 'outdatedClient');
}
// if (supportedSyncTargetVersion !== migrations.length - 1) {
// // Sanity check - it means a migration has been added by syncVersion has not be incremented or vice-versa,
// // so abort as it can cause strange issues.
// throw new JoplinError('Application error: mismatch between max supported sync version and max migration number: ' + supportedSyncTargetVersion + ' / ' + (migrations.length - 1));
// }
// Special case for version 1 because it didn't have the lock folder and without
// it the lock handler will break. So we create the directory now.
// Also if the sync target version is 0, it means it's a new one so we need the
// lock folder first before doing anything else.
if (syncTargetInfo.version === 0 || syncTargetInfo.version === 1) {
this.logger().info('MigrationHandler: Sync target version is 0 or 1 - creating "locks" directory:', syncTargetInfo);
await this.api_.mkdir(Dirnames.Locks);
}
this.logger().info('MigrationHandler: Acquiring exclusive lock');
const exclusiveLock = await this.lockHandler_.acquireLock(LockType.Exclusive, this.clientType_, this.clientId_, 1000 * 30);
let autoLockError = null;
this.lockHandler_.startAutoLockRefresh(exclusiveLock, (error:any) => {
autoLockError = error;
});
this.logger().info('MigrationHandler: Acquired exclusive lock:', exclusiveLock);
try {
for (let newVersion = syncTargetInfo.version + 1; newVersion < migrations.length; newVersion++) {
if (targetVersion && newVersion > targetVersion) break;
const fromVersion = newVersion - 1;
this.logger().info(`MigrationHandler: Migrating from version ${fromVersion} to version ${newVersion}`);
const migration = migrations[newVersion];
if (!migration) continue;
try {
if (autoLockError) throw autoLockError;
await migration(this.api_);
if (autoLockError) throw autoLockError;
await this.api_.put('info.json', this.serializeSyncTargetInfo({
...syncTargetInfo,
version: newVersion,
}));
this.logger().info(`MigrationHandler: Done migrating from version ${fromVersion} to version ${newVersion}`);
} catch (error) {
error.message = `Could not upgrade from version ${fromVersion} to version ${newVersion}: ${error.message}`;
throw error;
}
}
} finally {
this.logger().info('MigrationHandler: Releasing exclusive lock');
this.lockHandler_.stopAutoLockRefresh(exclusiveLock);
await this.lockHandler_.releaseLock(LockType.Exclusive, this.clientType_, this.clientId_);
}
}
}

Some files were not shown because too many files have changed in this diff Show More