From 0f4324c2f8c580af070dff492212c893a85ebc05 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Sat, 24 Mar 2018 19:35:10 +0000 Subject: [PATCH 1/9] All: Added backend for Dropbox support --- CliClient/.gitignore | 3 +- CliClient/tests/synchronizer.js | 15 +- CliClient/tests/test-utils.js | 31 ++- ReactNativeClient/lib/BaseSyncTarget.js | 4 + ReactNativeClient/lib/DropboxApi.js | 154 ++++++++++++++ ReactNativeClient/lib/SyncTargetDropbox.js | 54 +++++ .../lib/file-api-driver-dropbox.js | 199 ++++++++++++++++++ .../lib/file-api-driver-webdav.js | 1 + ReactNativeClient/lib/shim-init-node.js | 9 +- ReactNativeClient/lib/shim-init-react.js | 2 + ReactNativeClient/lib/shim.js | 1 + 11 files changed, 441 insertions(+), 32 deletions(-) create mode 100644 ReactNativeClient/lib/DropboxApi.js create mode 100644 ReactNativeClient/lib/SyncTargetDropbox.js create mode 100644 ReactNativeClient/lib/file-api-driver-dropbox.js diff --git a/CliClient/.gitignore b/CliClient/.gitignore index 540f2f47c..fb2f3fd42 100644 --- a/CliClient/.gitignore +++ b/CliClient/.gitignore @@ -18,4 +18,5 @@ tests/cli-integration/ tests/sync out.txt linkToLocal.sh -yarn-error.log \ No newline at end of file +yarn-error.log +tests/support/dropbox-auth.txt \ No newline at end of file diff --git a/CliClient/tests/synchronizer.js b/CliClient/tests/synchronizer.js index f4cd3d3b3..dbdaf799e 100644 --- a/CliClient/tests/synchronizer.js +++ b/CliClient/tests/synchronizer.js @@ -339,22 +339,17 @@ describe('Synchronizer', function() { it('should delete local folder', asyncTest(async () => { let folder1 = await Folder.save({ title: "folder1" }); let folder2 = await Folder.save({ title: "folder2" }); - await synchronizer().start(); + let context1 = await synchronizer().start(); await switchClient(2); - await synchronizer().start(); - - await sleep(0.1); - + let context2 = await synchronizer().start(); await Folder.delete(folder2.id); - - await synchronizer().start(); + await synchronizer().start({ context: context2 }); await switchClient(1); - await synchronizer().start(); - + await synchronizer().start({ context: context1 }); let items = await allItems(); await localItemsSameAsRemote(items, expect); })); @@ -438,7 +433,7 @@ describe('Synchronizer', function() { expect(items1.length).toBe(0); expect(items1.length).toBe(items2.length); - })); + })); it('should handle conflict when remote note is deleted then local note is modified', asyncTest(async () => { let folder1 = await Folder.save({ title: "folder1" }); diff --git a/CliClient/tests/test-utils.js b/CliClient/tests/test-utils.js index 67821570c..0dc7bf165 100644 --- a/CliClient/tests/test-utils.js +++ b/CliClient/tests/test-utils.js @@ -16,6 +16,7 @@ const { FileApi } = require('lib/file-api.js'); 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 BaseService = require('lib/services/BaseService.js'); const { FsDriverNode } = require('lib/fs-driver-node.js'); const { time } = require('lib/time-utils.js'); @@ -25,9 +26,11 @@ const SyncTargetMemory = require('lib/SyncTargetMemory.js'); const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js'); const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js'); const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js'); +const SyncTargetDropbox = require('lib/SyncTargetDropbox.js'); const EncryptionService = require('lib/services/EncryptionService.js'); const DecryptionWorker = require('lib/services/DecryptionWorker.js'); const WebDavApi = require('lib/WebDavApi'); +const DropboxApi = require('lib/DropboxApi'); let databases_ = []; let synchronizers_ = []; @@ -51,10 +54,12 @@ SyncTargetRegistry.addClass(SyncTargetMemory); SyncTargetRegistry.addClass(SyncTargetFilesystem); SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetNextcloud); +SyncTargetRegistry.addClass(SyncTargetDropbox); // const syncTargetId_ = SyncTargetRegistry.nameToId("nextcloud"); -const syncTargetId_ = SyncTargetRegistry.nameToId("memory"); +// const syncTargetId_ = SyncTargetRegistry.nameToId("memory"); //const syncTargetId_ = SyncTargetRegistry.nameToId('filesystem'); +const syncTargetId_ = SyncTargetRegistry.nameToId('dropbox'); const syncDir = __dirname + '/../tests/sync'; const sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;//400; @@ -247,25 +252,15 @@ function fileApi() { const api = new WebDavApi(options); fileApi_ = new FileApi('', new FileApiDriverWebDav(api)); + } else if (syncTargetId_ == SyncTargetRegistry.nameToId('dropbox')) { + const api = new DropboxApi(); + const authTokenPath = __dirname + '/support/dropbox-auth.txt'; + const authToken = fs.readFileSync(authTokenPath, 'utf8'); + if (!authTokenPath) throw new Error('Dropbox auth token missing in ' + authTokenPath); + api.setAuthToken(authToken); + fileApi_ = new FileApi('', new FileApiDriverDropbox(api)); } - // } else if (syncTargetId == Setting.SYNC_TARGET_ONEDRIVE) { - // let auth = require('./onedrive-auth.json'); - // if (!auth) { - // const oneDriveApiUtils = new OneDriveApiNodeUtils(oneDriveApi); - // auth = await oneDriveApiUtils.oauthDance(); - // fs.writeFileSync('./onedrive-auth.json', JSON.stringify(auth)); - // process.exit(1); - // } else { - // auth = JSON.parse(auth); - // } - - // // const oneDriveApiUtils = new OneDriveApiNodeUtils(reg.oneDriveApi()); - // // const auth = await oneDriveApiUtils.oauthDance(this); - // // Setting.setValue('sync.3.auth', auth ? JSON.stringify(auth) : null); - // // if (!auth) return; - // } - fileApi_.setLogger(logger); fileApi_.setSyncTargetId(syncTargetId_); fileApi_.requestRepeatCount_ = 0; diff --git a/ReactNativeClient/lib/BaseSyncTarget.js b/ReactNativeClient/lib/BaseSyncTarget.js index b8b0f332d..10380b564 100644 --- a/ReactNativeClient/lib/BaseSyncTarget.js +++ b/ReactNativeClient/lib/BaseSyncTarget.js @@ -66,6 +66,10 @@ class BaseSyncTarget { return this.fileApi_; } + fileApiSync() { + return this.fileApi_; + } + // Usually each sync target should create and setup its own file API via initFileApi() // but for testing purposes it might be convenient to provide it here so that multiple // clients can share and sync to the same file api (see test-utils.js) diff --git a/ReactNativeClient/lib/DropboxApi.js b/ReactNativeClient/lib/DropboxApi.js new file mode 100644 index 000000000..382d8e989 --- /dev/null +++ b/ReactNativeClient/lib/DropboxApi.js @@ -0,0 +1,154 @@ +const { Logger } = require('lib/logger.js'); +const { shim } = require('lib/shim.js'); +const JoplinError = require('lib/JoplinError'); +const URL = require('url-parse'); +const { time } = require('lib/time-utils'); + +class DropboxApi { + + constructor(options) { + this.logger_ = new Logger(); + this.options_ = options; + this.authToken_ = null; + } + + setLogger(l) { + this.logger_ = l; + } + + logger() { + return this.logger_; + } + + authToken() { + return this.authToken_; // Must be "Bearer XXXXXXXXXXXXXXXXXX" + } + + setAuthToken(v) { + this.authToken_ = v; + } + + baseUrl(endPointFormat) { + if (['content', 'api'].indexOf(endPointFormat) < 0) throw new Error('Invalid end point format: ' + endPointFormat); + return 'https://' + endPointFormat + '.dropboxapi.com/2'; + } + + requestToCurl_(url, options) { + let output = []; + output.push('curl'); + if (options.method) output.push('-X ' + options.method); + if (options.headers) { + for (let n in options.headers) { + if (!options.headers.hasOwnProperty(n)) continue; + output.push('-H ' + "'" + n + ': ' + options.headers[n] + "'"); + } + } + if (options.body) output.push('--data ' + '"' + options.body + '"'); + output.push(url); + + return output.join(' '); + } + + async exec(method, path = '', body = null, headers = null, options = null) { + if (headers === null) headers = {}; + if (options === null) options = {}; + if (!options.target) options.target = 'string'; + + const authToken = this.authToken(); + + if (authToken) headers['Authorization'] = authToken; + + const endPointFormat = ['files/upload', 'files/download'].indexOf(path) >= 0 ? 'content' : 'api'; + + if (endPointFormat === 'api') { + headers['Content-Type'] = 'application/json'; + if (body && typeof body === 'object') body = JSON.stringify(body); + } else { + headers['Content-Type'] = 'application/octet-stream'; + } + + const fetchOptions = {}; + fetchOptions.headers = headers; + fetchOptions.method = method; + if (options.path) fetchOptions.path = options.path; + if (body) fetchOptions.body = body; + + const url = this.baseUrl(endPointFormat) + '/' + path; + + let tryCount = 0; + + while (true) { + try { + let response = null; + + // console.info(this.requestToCurl_(url, fetchOptions)); + + const now = Date.now(); + // console.info(now + ': ' + method + ' ' + url); + + if (options.source == 'file' && (method == 'POST' || method == 'PUT')) { + response = await shim.uploadBlob(url, fetchOptions); + } else if (options.target == 'string') { + response = await shim.fetch(url, fetchOptions); + } else { // file + response = await shim.fetchBlob(url, fetchOptions); + } + + const responseText = await response.text(); + + // console.info(now + ': Response: ' + responseText); + + let responseJson_ = null; + const loadResponseJson = () => { + if (!responseText) return null; + if (responseJson_) return responseJson_; + try { + responseJson_ = JSON.parse(responseText); + } catch (error) { + return { error: responseText }; + } + return responseJson_; + } + + // Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier + const newError = (message) => { + const json = loadResponseJson(); + let code = ''; + if (json && json.error_summary) { + code = json.error_summary; + } + + // Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of + // JSON. That way the error message will still show there's a problem but without filling up the log or screen. + const shortResponseText = (responseText + '').substr(0, 1024); + return new JoplinError(method + ' ' + path + ': ' + message + ' (' + response.status + '): ' + shortResponseText, code); + } + + if (!response.ok) { + // When using fetchBlob we only get a string (not xml or json) back + if (options.target === 'file') throw newError('fetchBlob error'); + + throw newError('Error'); + } + + if (options.responseFormat === 'text') return responseText; + + return loadResponseJson(); + } catch (error) { + tryCount++; + if (error.code.indexOf('too_many_write_operations') >= 0) { + this.logger().warn('too_many_write_operations ' + tryCount); + if (tryCount >= 3) { + throw error; + } + await time.sleep(tryCount * 2); + } else { + throw error; + } + } + } + } + +} + +module.exports = DropboxApi; \ No newline at end of file diff --git a/ReactNativeClient/lib/SyncTargetDropbox.js b/ReactNativeClient/lib/SyncTargetDropbox.js new file mode 100644 index 000000000..8edc2a559 --- /dev/null +++ b/ReactNativeClient/lib/SyncTargetDropbox.js @@ -0,0 +1,54 @@ +const BaseSyncTarget = require('lib/BaseSyncTarget.js'); +const { _ } = require('lib/locale.js'); +const DropboxApi = require('lib/DropboxApi'); +const Setting = require('lib/models/Setting.js'); +const { parameters } = require('lib/parameters.js'); +const { FileApi } = require('lib/file-api.js'); +const { Synchronizer } = require('lib/synchronizer.js'); +const { FileApiDriverDropbox } = require('lib/file-api-driver-dropbox.js'); + +class SyncTargetDropbox extends BaseSyncTarget { + + static id() { + return 7; + } + + constructor(db, options = null) { + super(db, options); + this.api_ = null; + } + + static targetName() { + return 'dropbox'; + } + + static label() { + return _('Dropbox'); + } + + isAuthenticated() { + const f = this.fileApiSync(); + return f && f.driver().api().authToken(); + } + + syncTargetId() { + return SyncTargetDropbox.id(); + } + + async initFileApi() { + const api = new DropboxApi(); + const appDir = ''; + const fileApi = new FileApi(appDir, new FileApiDriverDropbox(api)); + fileApi.setSyncTargetId(this.syncTargetId()); + fileApi.setLogger(this.logger()); + return fileApi; + } + + async initSynchronizer() { + if (!this.isAuthenticated()) throw new Error('User is not authentified'); + return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType')); + } + +} + +module.exports = SyncTargetDropbox; \ No newline at end of file diff --git a/ReactNativeClient/lib/file-api-driver-dropbox.js b/ReactNativeClient/lib/file-api-driver-dropbox.js new file mode 100644 index 000000000..1475f2dc9 --- /dev/null +++ b/ReactNativeClient/lib/file-api-driver-dropbox.js @@ -0,0 +1,199 @@ +const { time } = require('lib/time-utils.js'); +const { shim } = require('lib/shim'); +const JoplinError = require('lib/JoplinError'); +const { basicDelta } = require('lib/file-api'); + +class FileApiDriverDropbox { + + constructor(api) { + this.api_ = api; + } + + api() { + return this.api_; + } + + requestRepeatCount() { + return 3; + } + + makePath_(path) { + if (!path) return ''; + return '/' + path; + } + + async stat(path) { + try { + const metadata = await this.api().exec('POST', 'files/get_metadata', { + path: this.makePath_(path), + }); + + return this.metadataToStat_(metadata, path); + } catch (error) { + if (error.code.indexOf('not_found') >= 0) { + // ignore + } else { + throw error; + } + } + } + + metadataToStat_(md, path) { + const output = { + path: path, + updated_time: md.server_modified ? new Date(md.server_modified) : new Date(), + isDir: md['.tag'] === 'folder', + }; + + if (md['.tag'] === 'deleted') output.isDeleted = true; + + return output; + } + + metadataToStats_(mds) { + const output = []; + for (let i = 0; i < mds.length; i++) { + output.push(this.metadataToStat_(mds[i], mds[i].name)); + } + return output; + } + + async setTimestamp(path, timestampMs) { + throw new Error('Not implemented'); // Not needed anymore + } + + async delta(path, options) { + const context = options ? options.context : null; + let cursor = context ? context.cursor : null; + + const urlPath = cursor ? 'files/list_folder/continue' : 'files/list_folder'; + const body = cursor ? { cursor: cursor } : { path: this.makePath_(path), include_deleted: true }; + const response = await this.api().exec('POST', urlPath, body); + + const output = { + items: this.metadataToStats_(response.entries), + hasMore: response.has_more, + context: { cursor: response.cursor }, + } + + return output; + + + + + // TODO: handle error - reset cursor + } + + async list(path, options) { + let response = await this.api().exec('POST', 'files/list_folder', { + path: this.makePath_(path), + }); + + let output = this.metadataToStats_(response.entries); + + while (response.has_more) { + response = await this.api().exec('POST', 'files/list_folder/continue', { + cursor: response.cursor, + }); + + output = output.concat(this.metadataToStats_(response.entries)); + } + + return { + items: output, + hasMore: false, + context: { cursor: response.cursor }, + }; + } + + async get(path, options) { + if (!options) options = {}; + if (!options.responseFormat) options.responseFormat = 'text'; + + try { + const response = await this.api().exec('POST', 'files/download', null, { + 'Dropbox-API-Arg': JSON.stringify({ "path": this.makePath_(path) }), + }, options); + return response; + } catch (error) { + if (error.code.indexOf('not_found') >= 0) { + return null; + } else { + throw error; + } + } + } + + async mkdir(path) { + try { + await this.api().exec('POST', 'files/create_folder_v2', { + path: this.makePath_(path), + }); + } catch (error) { + if (error.code.indexOf('path/conflict') >= 0) { + // Ignore + } else { + throw error; + } + } + } + + async put(path, content, options = null) { + // See https://github.com/facebook/react-native/issues/14445#issuecomment-352965210 + if (typeof content === 'string') content = Buffer.from(content, 'utf8') + + await this.api().exec('POST', 'files/upload', content, { + 'Dropbox-API-Arg': JSON.stringify({ + path: this.makePath_(path), + mode: 'overwrite', + mute: true, // Don't send a notification to user since there can be many of these updates + })}, options); + } + + async delete(path) { + try { + await this.api().exec('POST', 'files/delete_v2', { + path: this.makePath_(path), + }); + } catch (error) { + if (error.code.indexOf('not_found') >= 0) { + // ignore + } else { + throw error; + } + } + } + + async move(oldPath, newPath) { + throw new Error('Not supported'); + } + + format() { + throw new Error('Not supported'); + } + + async clearRoot() { + const entries = await this.list(''); + const batchDelete = []; + for (let i = 0; i < entries.items.length; i++) { + batchDelete.push({ path: this.makePath_(entries.items[i].path) }); + } + + const response = await this.api().exec('POST', 'files/delete_batch', { entries: batchDelete }); + const jobId = response.async_job_id; + + while (true) { + const check = await this.api().exec('POST', 'files/delete_batch/check', { async_job_id: jobId }); + if (check['.tag'] === 'complete') break; + + // It returns "failed" if it didn't work but anyway throw an error if it's anything other than complete or in_progress + if (check['.tag'] !== 'in_progress') { + throw new Error('Batch delete failed? ' + JSON.stringify(check)); + } + await time.sleep(2); + } + } + +} + +module.exports = { FileApiDriverDropbox }; \ No newline at end of file diff --git a/ReactNativeClient/lib/file-api-driver-webdav.js b/ReactNativeClient/lib/file-api-driver-webdav.js index 88d104195..0e16b14ce 100644 --- a/ReactNativeClient/lib/file-api-driver-webdav.js +++ b/ReactNativeClient/lib/file-api-driver-webdav.js @@ -293,6 +293,7 @@ class FileApiDriverWebDav { return response; } catch (error) { if (error.code !== 404) throw error; + return null; } } diff --git a/ReactNativeClient/lib/shim-init-node.js b/ReactNativeClient/lib/shim-init-node.js index 559734f5d..bf0065c09 100644 --- a/ReactNativeClient/lib/shim-init-node.js +++ b/ReactNativeClient/lib/shim-init-node.js @@ -111,10 +111,9 @@ function shimInit() { const urlParse = require('url').parse; url = urlParse(url.trim()); + const method = options.method ? options.method : 'GET'; const http = url.protocol.toLowerCase() == 'http:' ? require('follow-redirects').http : require('follow-redirects').https; const headers = options.headers ? options.headers : {}; - const method = options.method ? options.method : 'GET'; - if (method != 'GET') throw new Error('Only GET is supported'); const filePath = options.path; function makeResponse(response) { @@ -143,7 +142,7 @@ function shimInit() { // Note: relative paths aren't supported const file = fs.createWriteStream(filePath); - const request = http.get(requestOptions, function(response) { + const request = http.request(requestOptions, function(response) { response.pipe(file); file.on('finish', function() { @@ -157,6 +156,8 @@ function shimInit() { fs.unlink(filePath); reject(error); }); + + request.end(); } catch(error) { fs.unlink(filePath); reject(error); @@ -180,6 +181,8 @@ function shimInit() { return Buffer.byteLength(string, 'utf-8'); } + shim.Buffer = Buffer; + } module.exports = { shimInit }; \ No newline at end of file diff --git a/ReactNativeClient/lib/shim-init-react.js b/ReactNativeClient/lib/shim-init-react.js index 915d5c087..c8edf6e4e 100644 --- a/ReactNativeClient/lib/shim-init-react.js +++ b/ReactNativeClient/lib/shim-init-react.js @@ -116,6 +116,8 @@ function shimInit() { shim.stringByteLength = function(string) { return Buffer.byteLength(string, 'utf-8'); } + + shim.Buffer = Buffer; } module.exports = { shimInit }; \ No newline at end of file diff --git a/ReactNativeClient/lib/shim.js b/ReactNativeClient/lib/shim.js index efeb7b75d..db8ab1efb 100644 --- a/ReactNativeClient/lib/shim.js +++ b/ReactNativeClient/lib/shim.js @@ -129,5 +129,6 @@ shim.clearInterval = function(id) { shim.stringByteLength = function(string) { throw new Error('Not implemented'); } shim.detectAndSetLocale = null; shim.attachFileToNote = async (note, filePath) => {} +shim.Buffer = null; module.exports = { shim }; \ No newline at end of file From ac07bf784d3df7b359ad1b452114ad9fc112e939 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Mon, 26 Mar 2018 18:33:55 +0100 Subject: [PATCH 2/9] Adding Dropbox sync to Electron app --- CliClient/app/command-sync.js | 23 +++- ElectronClient/app/gui/DropboxLoginScreen.jsx | 111 ++++++++++++++++++ ElectronClient/app/gui/Root.jsx | 2 + ElectronClient/app/theme.js | 1 + ReactNativeClient/lib/BaseApplication.js | 12 +- ReactNativeClient/lib/BaseSyncTarget.js | 4 +- ReactNativeClient/lib/DropboxApi.js | 47 +++++++- ReactNativeClient/lib/SyncTargetDropbox.js | 31 ++++- ReactNativeClient/lib/SyncTargetFilesystem.js | 2 +- ReactNativeClient/lib/SyncTargetMemory.js | 2 +- ReactNativeClient/lib/SyncTargetNextcloud.js | 2 +- ReactNativeClient/lib/SyncTargetOneDrive.js | 4 +- ReactNativeClient/lib/SyncTargetWebDAV.js | 2 +- .../lib/components/shared/side-menu-shared.js | 2 +- ReactNativeClient/lib/models/Setting.js | 4 +- ReactNativeClient/lib/parameters.js | 8 ++ ReactNativeClient/lib/registry.js | 2 +- 17 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 ElectronClient/app/gui/DropboxLoginScreen.jsx diff --git a/CliClient/app/command-sync.js b/CliClient/app/command-sync.js index e31d18f2c..1e9d03c45 100644 --- a/CliClient/app/command-sync.js +++ b/CliClient/app/command-sync.js @@ -78,10 +78,26 @@ class Command extends BaseCommand { return false; } + return true; + } else if (syncTargetMd.name === 'dropbox') { // Dropbox + const api = await syncTarget.api(); + const loginUrl = api.loginUrl(); + this.stdout(_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')); + this.stdout(_('Step 1: Open this URL in your browser to authorise the application:')); + this.stdout(loginUrl); + const authCode = await this.prompt(_('Step 2: Enter the code provided by Dropbox:'), { type: 'string' }); + if (!authCode) { + this.stdout(_('Authentication was not completed (did not receive an authentication token).')); + return false; + } + + const response = await api.execAuthToken(authCode); + Setting.setValue('sync.' + this.syncTargetId_ + '.auth', JSON.stringify(response)); + api.setAuthToken(response.access_token); return true; } - this.stdout(_('Not authentified with %s. Please provide any missing credentials.', syncTarget.label())); + this.stdout(_('Not authentified with %s. Please provide any missing credentials.', syncTargetMd.label)); return false; } @@ -100,6 +116,7 @@ class Command extends BaseCommand { this.releaseLockFn_ = null; // Lock is unique per profile/database + // TODO: use SQLite database to do lock? const lockFilePath = require('os').tmpdir() + '/synclock_' + md5(escape(Setting.value('profileDir'))); // https://github.com/pvorb/node-md5/issues/41 if (!await fs.pathExists(lockFilePath)) await fs.writeFile(lockFilePath, 'synclock'); @@ -130,7 +147,7 @@ class Command extends BaseCommand { const syncTarget = reg.syncTarget(this.syncTargetId_); - if (!syncTarget.isAuthenticated()) { + if (!await syncTarget.isAuthenticated()) { app().gui().showConsole(); app().gui().maximizeConsole(); @@ -197,7 +214,7 @@ class Command extends BaseCommand { const syncTarget = reg.syncTarget(syncTargetId); - if (syncTarget.isAuthenticated()) { + if (await syncTarget.isAuthenticated()) { const sync = await syncTarget.synchronizer(); if (sync) await sync.cancel(); } else { diff --git a/ElectronClient/app/gui/DropboxLoginScreen.jsx b/ElectronClient/app/gui/DropboxLoginScreen.jsx new file mode 100644 index 000000000..f85562be2 --- /dev/null +++ b/ElectronClient/app/gui/DropboxLoginScreen.jsx @@ -0,0 +1,111 @@ +const React = require('react'); +const { connect } = require('react-redux'); +const { reg } = require('lib/registry.js'); +const { bridge } = require('electron').remote.require('./bridge'); +const { Header } = require('./Header.min.js'); +const { themeStyle } = require('../theme.js'); +const SyncTargetRegistry = require('lib/SyncTargetRegistry'); +const { _ } = require('lib/locale.js'); + +class DropboxLoginScreenComponent extends React.Component { + + constructor() { + super(); + + this.dropboxApi_ = null; + + this.state = { + loginUrl: '', + authCode: '', + checkingAuthToken: false, + }; + + this.loginUrl_click = () => { + if (!this.state.loginUrl) return; + bridge().openExternal(this.state.loginUrl) + } + + this.authCodeInput_change = (event) => { + this.setState({ + authCode: event.target.value + }); + } + + this.submit_click = async () => { + this.setState({ checkingAuthToken: true }); + + const api = await this.dropboxApi(); + try { + const response = await api.execAuthToken(this.state.authCode); + Setting.setValue('sync.' + this.syncTargetId() + '.auth', JSON.stringify(response)); + api.setAuthToken(response.access_token); + bridge().showInfoMessageBox(_('The application has been authorised!')); + this.props.dispatch({ type: 'NAV_BACK' }); + } catch (error) { + bridge().showErrorMessageBox(_('Could not authorise application:\n\n%s\n\nPlease try again.', error.message)); + } finally { + this.setState({ checkingAuthToken: false }); + } + } + } + + componentWillMount() { + this.refreshUrl(); + } + + syncTargetId() { + return SyncTargetRegistry.nameToId('dropbox'); + } + + async dropboxApi() { + if (this.dropboxApi_) return this.dropboxApi_; + + const syncTarget = reg.syncTarget(this.syncTargetId()); + this.dropboxApi_ = await syncTarget.api(); + return this.dropboxApi_; + } + + async refreshUrl() { + const api = await this.dropboxApi(); + + this.setState({ + loginUrl: api.loginUrl(), + }); + } + + render() { + const style = this.props.style; + const theme = themeStyle(this.props.theme); + + const headerStyle = { + width: style.width, + }; + + const inputStyle = Object.assign({}, theme.inputStyle, { width: 500 }); + + return ( +
+
+
+

{_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')}

+

{_('Step 1: Open this URL in your browser to authorise the application:')}

+ {this.state.loginUrl} +

{_('Step 2: Enter the code provided by Dropbox:')}

+

+ +
+
+ ); + } + +} + +const mapStateToProps = (state) => { + return { + theme: state.settings.theme, + }; +}; + +const DropboxLoginScreen = connect(mapStateToProps)(DropboxLoginScreenComponent); + +module.exports = { DropboxLoginScreen }; \ No newline at end of file diff --git a/ElectronClient/app/gui/Root.jsx b/ElectronClient/app/gui/Root.jsx index 66dda0344..c1a740970 100644 --- a/ElectronClient/app/gui/Root.jsx +++ b/ElectronClient/app/gui/Root.jsx @@ -8,6 +8,7 @@ const Setting = require('lib/models/Setting.js'); const { MainScreen } = require('./MainScreen.min.js'); const { OneDriveLoginScreen } = require('./OneDriveLoginScreen.min.js'); +const { DropboxLoginScreen } = require('./DropboxLoginScreen.min.js'); const { StatusScreen } = require('./StatusScreen.min.js'); const { ImportScreen } = require('./ImportScreen.min.js'); const { ConfigScreen } = require('./ConfigScreen.min.js'); @@ -75,6 +76,7 @@ class RootComponent extends React.Component { const screens = { Main: { screen: MainScreen }, OneDriveLogin: { screen: OneDriveLoginScreen, title: () => _('OneDrive Login') }, + DropboxLogin: { screen: DropboxLoginScreen, title: () => _('Dropbox Login') }, Import: { screen: ImportScreen, title: () => _('Import') }, Config: { screen: ConfigScreen, title: () => _('Options') }, Status: { screen: StatusScreen, title: () => _('Synchronisation Status') }, diff --git a/ElectronClient/app/theme.js b/ElectronClient/app/theme.js index 93d8f62de..389980d69 100644 --- a/ElectronClient/app/theme.js +++ b/ElectronClient/app/theme.js @@ -62,6 +62,7 @@ globalStyle.icon = { globalStyle.lineInput = { color: globalStyle.color, backgroundColor: globalStyle.backgroundColor, + fontFamily: globalStyle.fontFamily, }; globalStyle.textStyle = { diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index b091d268b..2e6bdcd05 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -29,6 +29,7 @@ const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js'); const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js'); const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js'); const SyncTargetWebDAV = require('lib/SyncTargetWebDAV.js'); +const SyncTargetDropbox = require('lib/SyncTargetDropbox.js'); const EncryptionService = require('lib/services/EncryptionService'); const DecryptionWorker = require('lib/services/DecryptionWorker'); const BaseService = require('lib/services/BaseService'); @@ -38,6 +39,7 @@ SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetOneDriveDev); SyncTargetRegistry.addClass(SyncTargetNextcloud); SyncTargetRegistry.addClass(SyncTargetWebDAV); +SyncTargetRegistry.addClass(SyncTargetDropbox); class BaseApplication { @@ -421,8 +423,14 @@ class BaseApplication { if (Setting.value('firstStart')) { const locale = shim.detectAndSetLocale(Setting); reg.logger().info('First start: detected locale as ' + locale); - if (Setting.value('env') === 'dev') Setting.setValue('sync.target', SyncTargetRegistry.nameToId('onedrive_dev')); - Setting.setValue('firstStart', 0) + + if (Setting.value('env') === 'dev') { + Setting.setValue('showTrayIcon', 0); + Setting.setValue('autoUpdateEnabled', 0); + Setting.setValue('sync.interval', 3600); + } + + Setting.setValue('firstStart', 0); } else { setLocale(Setting.value('locale')); } diff --git a/ReactNativeClient/lib/BaseSyncTarget.js b/ReactNativeClient/lib/BaseSyncTarget.js index 10380b564..32edc5b12 100644 --- a/ReactNativeClient/lib/BaseSyncTarget.js +++ b/ReactNativeClient/lib/BaseSyncTarget.js @@ -30,7 +30,7 @@ class BaseSyncTarget { return this.db_; } - isAuthenticated() { + async isAuthenticated() { return false; } @@ -113,7 +113,7 @@ class BaseSyncTarget { async syncStarted() { if (!this.synchronizer_) return false; - if (!this.isAuthenticated()) return false; + if (!await this.isAuthenticated()) return false; const sync = await this.synchronizer(); return sync.state() != 'idle'; } diff --git a/ReactNativeClient/lib/DropboxApi.js b/ReactNativeClient/lib/DropboxApi.js index 382d8e989..dadf3e7c0 100644 --- a/ReactNativeClient/lib/DropboxApi.js +++ b/ReactNativeClient/lib/DropboxApi.js @@ -12,6 +12,14 @@ class DropboxApi { this.authToken_ = null; } + clientId() { + return this.options_.id; + } + + clientSecret() { + return this.options_.secret; + } + setLogger(l) { this.logger_ = l; } @@ -21,13 +29,17 @@ class DropboxApi { } authToken() { - return this.authToken_; // Must be "Bearer XXXXXXXXXXXXXXXXXX" + return this.authToken_; // Without the "Bearer " prefix } setAuthToken(v) { this.authToken_ = v; } + loginUrl() { + return 'https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=' + this.clientId(); + } + baseUrl(endPointFormat) { if (['content', 'api'].indexOf(endPointFormat) < 0) throw new Error('Invalid end point format: ' + endPointFormat); return 'https://' + endPointFormat + '.dropboxapi.com/2'; @@ -49,6 +61,35 @@ class DropboxApi { return output.join(' '); } + async execAuthToken(authCode) { + const postData = { + code: authCode, + grant_type: 'authorization_code', + client_id: this.clientId(), + client_secret: this.clientSecret(), + }; + + var formBody = []; + for (var property in postData) { + var encodedKey = encodeURIComponent(property); + var encodedValue = encodeURIComponent(postData[property]); + formBody.push(encodedKey + "=" + encodedValue); + } + formBody = formBody.join("&"); + + const response = await shim.fetch('https://api.dropboxapi.com/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' + }, + body: formBody + }); + + const responseText = await response.text(); + if (!response.ok) throw new Error(responseText); + return JSON.parse(responseText); + } + async exec(method, path = '', body = null, headers = null, options = null) { if (headers === null) headers = {}; if (options === null) options = {}; @@ -56,7 +97,7 @@ class DropboxApi { const authToken = this.authToken(); - if (authToken) headers['Authorization'] = authToken; + if (authToken) headers['Authorization'] = 'Bearer ' + authToken; const endPointFormat = ['files/upload', 'files/download'].indexOf(path) >= 0 ? 'content' : 'api'; @@ -73,7 +114,7 @@ class DropboxApi { if (options.path) fetchOptions.path = options.path; if (body) fetchOptions.body = body; - const url = this.baseUrl(endPointFormat) + '/' + path; + const url = path.indexOf('https://') === 0 ? path : this.baseUrl(endPointFormat) + '/' + path; let tryCount = 0; diff --git a/ReactNativeClient/lib/SyncTargetDropbox.js b/ReactNativeClient/lib/SyncTargetDropbox.js index 8edc2a559..60b609523 100644 --- a/ReactNativeClient/lib/SyncTargetDropbox.js +++ b/ReactNativeClient/lib/SyncTargetDropbox.js @@ -26,9 +26,18 @@ class SyncTargetDropbox extends BaseSyncTarget { return _('Dropbox'); } - isAuthenticated() { - const f = this.fileApiSync(); - return f && f.driver().api().authToken(); + authRouteName() { + return 'DropboxLogin'; + } + + async isAuthenticated() { + const f = await this.fileApi(); + return !!f.driver().api().authToken(); + } + + async api() { + const fileApi = await this.fileApi(); + return fileApi.driver().api(); } syncTargetId() { @@ -36,7 +45,19 @@ class SyncTargetDropbox extends BaseSyncTarget { } async initFileApi() { - const api = new DropboxApi(); + const params = parameters().dropbox; + + const api = new DropboxApi({ + id: params.id, + secret: params.secret, + }); + + const authJson = Setting.value('sync.' + SyncTargetDropbox.id() + '.auth'); + if (authJson) { + const auth = JSON.parse(authJson); + api.setAuthToken(auth.access_token); + } + const appDir = ''; const fileApi = new FileApi(appDir, new FileApiDriverDropbox(api)); fileApi.setSyncTargetId(this.syncTargetId()); @@ -45,7 +66,7 @@ class SyncTargetDropbox extends BaseSyncTarget { } async initSynchronizer() { - if (!this.isAuthenticated()) throw new Error('User is not authentified'); + if (!(await this.isAuthenticated())) throw new Error('User is not authentified'); return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType')); } diff --git a/ReactNativeClient/lib/SyncTargetFilesystem.js b/ReactNativeClient/lib/SyncTargetFilesystem.js index 6dd4f4851..5f6a5580c 100644 --- a/ReactNativeClient/lib/SyncTargetFilesystem.js +++ b/ReactNativeClient/lib/SyncTargetFilesystem.js @@ -19,7 +19,7 @@ class SyncTargetFilesystem extends BaseSyncTarget { return _('File system'); } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/SyncTargetMemory.js b/ReactNativeClient/lib/SyncTargetMemory.js index d0ac33461..1e3d2812d 100644 --- a/ReactNativeClient/lib/SyncTargetMemory.js +++ b/ReactNativeClient/lib/SyncTargetMemory.js @@ -19,7 +19,7 @@ class SyncTargetMemory extends BaseSyncTarget { return 'Memory'; } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/SyncTargetNextcloud.js b/ReactNativeClient/lib/SyncTargetNextcloud.js index 57ba9900c..094a63f34 100644 --- a/ReactNativeClient/lib/SyncTargetNextcloud.js +++ b/ReactNativeClient/lib/SyncTargetNextcloud.js @@ -28,7 +28,7 @@ class SyncTargetNextcloud extends BaseSyncTarget { return _('Nextcloud'); } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/SyncTargetOneDrive.js b/ReactNativeClient/lib/SyncTargetOneDrive.js index 50f0a04eb..027b1eafa 100644 --- a/ReactNativeClient/lib/SyncTargetOneDrive.js +++ b/ReactNativeClient/lib/SyncTargetOneDrive.js @@ -26,7 +26,7 @@ class SyncTargetOneDrive extends BaseSyncTarget { return _('OneDrive'); } - isAuthenticated() { + async isAuthenticated() { return this.api().auth(); } @@ -80,7 +80,7 @@ class SyncTargetOneDrive extends BaseSyncTarget { } async initSynchronizer() { - if (!this.isAuthenticated()) throw new Error('User is not authentified'); + if (!await this.isAuthenticated()) throw new Error('User is not authentified'); return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType')); } diff --git a/ReactNativeClient/lib/SyncTargetWebDAV.js b/ReactNativeClient/lib/SyncTargetWebDAV.js index 6c17e9617..e5ebf99a8 100644 --- a/ReactNativeClient/lib/SyncTargetWebDAV.js +++ b/ReactNativeClient/lib/SyncTargetWebDAV.js @@ -24,7 +24,7 @@ class SyncTargetWebDAV extends BaseSyncTarget { return _('WebDAV'); } - isAuthenticated() { + async isAuthenticated() { return true; } diff --git a/ReactNativeClient/lib/components/shared/side-menu-shared.js b/ReactNativeClient/lib/components/shared/side-menu-shared.js index 5cb87ec0a..bfdba6971 100644 --- a/ReactNativeClient/lib/components/shared/side-menu-shared.js +++ b/ReactNativeClient/lib/components/shared/side-menu-shared.js @@ -36,7 +36,7 @@ shared.synchronize_press = async function(comp) { const action = comp.props.syncStarted ? 'cancel' : 'start'; - if (!reg.syncTarget().isAuthenticated()) { + if (!await reg.syncTarget().isAuthenticated()) { if (reg.syncTarget().authRouteName()) { comp.props.dispatch({ type: 'NAV_GO', diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index 0b59e59fb..d0db461b0 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -99,7 +99,7 @@ class Setting extends BaseModel { }}, 'noteVisiblePanes': { value: ['editor', 'viewer'], type: Setting.TYPE_ARRAY, public: false, appTypes: ['desktop'] }, 'showAdvancedOptions': { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['mobile' ], label: () => _('Show advanced options') }, - 'sync.target': { value: SyncTargetRegistry.nameToId('onedrive'), type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation target'), description: (appType) => { return appType !== 'cli' ? null : _('The target to synchonise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).') }, options: () => { + 'sync.target': { value: SyncTargetRegistry.nameToId('dropbox'), type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation target'), description: (appType) => { return appType !== 'cli' ? null : _('The target to synchonise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).') }, options: () => { return SyncTargetRegistry.idAndLabelPlainObject(); }}, @@ -121,12 +121,14 @@ class Setting extends BaseModel { 'sync.3.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.4.auth': { value: '', type: Setting.TYPE_STRING, public: false }, + 'sync.7.auth': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.1.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.2.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.3.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.4.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.5.context': { value: '', type: Setting.TYPE_STRING, public: false }, 'sync.6.context': { value: '', type: Setting.TYPE_STRING, public: false }, + 'sync.7.context': { value: '', type: Setting.TYPE_STRING, public: false }, }; return this.metadata_; diff --git a/ReactNativeClient/lib/parameters.js b/ReactNativeClient/lib/parameters.js index 53002131d..e73ff06d7 100644 --- a/ReactNativeClient/lib/parameters.js +++ b/ReactNativeClient/lib/parameters.js @@ -11,6 +11,10 @@ parameters_.dev = { id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6', secret: 'qabchuPYL7931$ePDEQ3~_$', }, + dropbox: { + id: 'cx9li9ur8taq1z7', + secret: 'i8f9a1mvx3bijrt', + }, }; parameters_.prod = { @@ -22,6 +26,10 @@ parameters_.prod = { id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6', secret: 'qabchuPYL7931$ePDEQ3~_$', }, + dropbox: { + id: 'm044w3cvmxhzvop', + secret: 'r298deqisz0od56', + }, }; function parameters(env = null) { diff --git a/ReactNativeClient/lib/registry.js b/ReactNativeClient/lib/registry.js index f92531f72..719bb7eb2 100644 --- a/ReactNativeClient/lib/registry.js +++ b/ReactNativeClient/lib/registry.js @@ -70,7 +70,7 @@ reg.scheduleSync = async (delay = null, syncOptions = null) => { const syncTargetId = Setting.value('sync.target'); - if (!reg.syncTarget(syncTargetId).isAuthenticated()) { + if (!await reg.syncTarget(syncTargetId).isAuthenticated()) { reg.logger().info('Synchroniser is missing credentials - manual sync required to authenticate.'); promiseResolve(); return; From 6e994fd8b9a911589a081abbbbae5649d216a649 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 27 Mar 2018 00:05:39 +0100 Subject: [PATCH 3/9] All: Dropbox: Handle various error conditions --- CliClient/app/command-sync.js | 2 +- ElectronClient/app/gui/DropboxLoginScreen.jsx | 2 +- ElectronClient/app/gui/NoteText.jsx | 2 +- ReactNativeClient/lib/DropboxApi.js | 27 +++++++++++-- ...event-dispatcher.js => EventDispatcher.js} | 2 +- ReactNativeClient/lib/SyncTargetDropbox.js | 18 ++++----- .../lib/file-api-driver-dropbox.js | 40 ++++++++++++------- 7 files changed, 60 insertions(+), 33 deletions(-) rename ReactNativeClient/lib/{event-dispatcher.js => EventDispatcher.js} (94%) diff --git a/CliClient/app/command-sync.js b/CliClient/app/command-sync.js index 1e9d03c45..674aef757 100644 --- a/CliClient/app/command-sync.js +++ b/CliClient/app/command-sync.js @@ -92,7 +92,7 @@ class Command extends BaseCommand { } const response = await api.execAuthToken(authCode); - Setting.setValue('sync.' + this.syncTargetId_ + '.auth', JSON.stringify(response)); + Setting.setValue('sync.' + this.syncTargetId_ + '.auth', response.access_token); api.setAuthToken(response.access_token); return true; } diff --git a/ElectronClient/app/gui/DropboxLoginScreen.jsx b/ElectronClient/app/gui/DropboxLoginScreen.jsx index f85562be2..ab4a8e3da 100644 --- a/ElectronClient/app/gui/DropboxLoginScreen.jsx +++ b/ElectronClient/app/gui/DropboxLoginScreen.jsx @@ -37,7 +37,7 @@ class DropboxLoginScreenComponent extends React.Component { const api = await this.dropboxApi(); try { const response = await api.execAuthToken(this.state.authCode); - Setting.setValue('sync.' + this.syncTargetId() + '.auth', JSON.stringify(response)); + Setting.setValue('sync.' + this.syncTargetId() + '.auth', response.access_token); api.setAuthToken(response.access_token); bridge().showInfoMessageBox(_('The application has been authorised!')); this.props.dispatch({ type: 'NAV_BACK' }); diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx index 8b4cfa886..f34b7baf6 100644 --- a/ElectronClient/app/gui/NoteText.jsx +++ b/ElectronClient/app/gui/NoteText.jsx @@ -370,7 +370,7 @@ class NoteTextComponent extends React.Component { webviewReady: true, }); - if (Setting.value('env') === 'dev') this.webview_.openDevTools(); + // if (Setting.value('env') === 'dev') this.webview_.openDevTools(); } webview_ref(element) { diff --git a/ReactNativeClient/lib/DropboxApi.js b/ReactNativeClient/lib/DropboxApi.js index dadf3e7c0..143e07e49 100644 --- a/ReactNativeClient/lib/DropboxApi.js +++ b/ReactNativeClient/lib/DropboxApi.js @@ -3,6 +3,7 @@ const { shim } = require('lib/shim.js'); const JoplinError = require('lib/JoplinError'); const URL = require('url-parse'); const { time } = require('lib/time-utils'); +const EventDispatcher = require('lib/EventDispatcher'); class DropboxApi { @@ -10,6 +11,7 @@ class DropboxApi { this.logger_ = new Logger(); this.options_ = options; this.authToken_ = null; + this.dispatcher_ = new EventDispatcher(); } clientId() { @@ -32,8 +34,13 @@ class DropboxApi { return this.authToken_; // Without the "Bearer " prefix } + on(eventName, callback) { + return this.dispatcher_.on(eventName, callback); + } + setAuthToken(v) { this.authToken_ = v; + this.dispatcher_.dispatch('authRefreshed', this.authToken()); } loginUrl() { @@ -90,6 +97,12 @@ class DropboxApi { return JSON.parse(responseText); } + isTokenError(status, responseText) { + if (status === 401) return true; + if (responseText.indexOf('OAuth 2 access token is malformed') >= 0) return true; + return false; + } + async exec(method, path = '', body = null, headers = null, options = null) { if (headers === null) headers = {}; if (options === null) options = {}; @@ -124,8 +137,7 @@ class DropboxApi { // console.info(this.requestToCurl_(url, fetchOptions)); - const now = Date.now(); - // console.info(now + ': ' + method + ' ' + url); + // console.info(method + ' ' + url); if (options.source == 'file' && (method == 'POST' || method == 'PUT')) { response = await shim.uploadBlob(url, fetchOptions); @@ -137,7 +149,7 @@ class DropboxApi { const responseText = await response.text(); - // console.info(now + ': Response: ' + responseText); + // console.info('Response: ' + responseText); let responseJson_ = null; const loadResponseJson = () => { @@ -162,10 +174,17 @@ class DropboxApi { // Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of // JSON. That way the error message will still show there's a problem but without filling up the log or screen. const shortResponseText = (responseText + '').substr(0, 1024); - return new JoplinError(method + ' ' + path + ': ' + message + ' (' + response.status + '): ' + shortResponseText, code); + const error = new JoplinError(method + ' ' + path + ': ' + message + ' (' + response.status + '): ' + shortResponseText, code); + error.httpStatus = response.status; + return error; } if (!response.ok) { + const json = loadResponseJson(); + if (this.isTokenError(response.status, responseText)) { + this.setAuthToken(null); + } + // When using fetchBlob we only get a string (not xml or json) back if (options.target === 'file') throw newError('fetchBlob error'); diff --git a/ReactNativeClient/lib/event-dispatcher.js b/ReactNativeClient/lib/EventDispatcher.js similarity index 94% rename from ReactNativeClient/lib/event-dispatcher.js rename to ReactNativeClient/lib/EventDispatcher.js index 66f1560bb..ce3c7bad2 100644 --- a/ReactNativeClient/lib/event-dispatcher.js +++ b/ReactNativeClient/lib/EventDispatcher.js @@ -32,4 +32,4 @@ class EventDispatcher { } -module.exports = { EventDispatcher }; \ No newline at end of file +module.exports = EventDispatcher; \ No newline at end of file diff --git a/ReactNativeClient/lib/SyncTargetDropbox.js b/ReactNativeClient/lib/SyncTargetDropbox.js index 60b609523..602c6fc92 100644 --- a/ReactNativeClient/lib/SyncTargetDropbox.js +++ b/ReactNativeClient/lib/SyncTargetDropbox.js @@ -40,10 +40,6 @@ class SyncTargetDropbox extends BaseSyncTarget { return fileApi.driver().api(); } - syncTargetId() { - return SyncTargetDropbox.id(); - } - async initFileApi() { const params = parameters().dropbox; @@ -52,15 +48,17 @@ class SyncTargetDropbox extends BaseSyncTarget { secret: params.secret, }); - const authJson = Setting.value('sync.' + SyncTargetDropbox.id() + '.auth'); - if (authJson) { - const auth = JSON.parse(authJson); - api.setAuthToken(auth.access_token); - } + api.on('authRefreshed', (auth) => { + this.logger().info('Saving updated OneDrive auth.'); + Setting.setValue('sync.' + SyncTargetDropbox.id() + '.auth', auth ? auth : null); + }); + + const authToken = Setting.value('sync.' + SyncTargetDropbox.id() + '.auth'); + api.setAuthToken(authToken); const appDir = ''; const fileApi = new FileApi(appDir, new FileApiDriverDropbox(api)); - fileApi.setSyncTargetId(this.syncTargetId()); + fileApi.setSyncTargetId(SyncTargetDropbox.id()); fileApi.setLogger(this.logger()); return fileApi; } diff --git a/ReactNativeClient/lib/file-api-driver-dropbox.js b/ReactNativeClient/lib/file-api-driver-dropbox.js index 1475f2dc9..69797b28a 100644 --- a/ReactNativeClient/lib/file-api-driver-dropbox.js +++ b/ReactNativeClient/lib/file-api-driver-dropbox.js @@ -66,22 +66,32 @@ class FileApiDriverDropbox { const context = options ? options.context : null; let cursor = context ? context.cursor : null; - const urlPath = cursor ? 'files/list_folder/continue' : 'files/list_folder'; - const body = cursor ? { cursor: cursor } : { path: this.makePath_(path), include_deleted: true }; - const response = await this.api().exec('POST', urlPath, body); - - const output = { - items: this.metadataToStats_(response.entries), - hasMore: response.has_more, - context: { cursor: response.cursor }, + while (true) { + const urlPath = cursor ? 'files/list_folder/continue' : 'files/list_folder'; + const body = cursor ? { cursor: cursor } : { path: this.makePath_(path), include_deleted: true }; + + try { + const response = await this.api().exec('POST', urlPath, body); + + const output = { + items: this.metadataToStats_(response.entries), + hasMore: response.has_more, + context: { cursor: response.cursor }, + } + + return output; + } catch (error) { + // If there's an error related to an invalid cursor, clear the cursor and retry. + if (cursor) { + if (error.httpStatus === 400 || error.code.indexOf('reset') >= 0) { + // console.info('Clearing cursor and retrying', error); + cursor = null; + continue; + } + } + throw error; + } } - - return output; - - - - - // TODO: handle error - reset cursor } async list(path, options) { From 96fb7c2087a971bb74a73a624c089733073358e4 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 27 Mar 2018 00:55:44 +0100 Subject: [PATCH 4/9] Getting Dropbox to work in mobile app --- ElectronClient/app/gui/DropboxLoginScreen.jsx | 69 +++-------------- .../lib/components/screens/dropbox-login.js | 57 ++++++++++++++ .../components/shared/dropbox-login-shared.js | 74 +++++++++++++++++++ ReactNativeClient/lib/dialogs.js | 5 ++ .../lib/file-api-driver-dropbox.js | 2 +- ReactNativeClient/lib/shim-init-node.js | 5 ++ ReactNativeClient/lib/shim-init-react.js | 5 ++ ReactNativeClient/lib/shim.js | 1 + ReactNativeClient/root.js | 14 +--- 9 files changed, 162 insertions(+), 70 deletions(-) create mode 100644 ReactNativeClient/lib/components/screens/dropbox-login.js create mode 100644 ReactNativeClient/lib/components/shared/dropbox-login-shared.js diff --git a/ElectronClient/app/gui/DropboxLoginScreen.jsx b/ElectronClient/app/gui/DropboxLoginScreen.jsx index ab4a8e3da..66cd6c6bb 100644 --- a/ElectronClient/app/gui/DropboxLoginScreen.jsx +++ b/ElectronClient/app/gui/DropboxLoginScreen.jsx @@ -6,71 +6,22 @@ const { Header } = require('./Header.min.js'); const { themeStyle } = require('../theme.js'); const SyncTargetRegistry = require('lib/SyncTargetRegistry'); const { _ } = require('lib/locale.js'); +const Shared = require('lib/components/shared/dropbox-login-shared'); class DropboxLoginScreenComponent extends React.Component { constructor() { super(); - this.dropboxApi_ = null; - - this.state = { - loginUrl: '', - authCode: '', - checkingAuthToken: false, - }; - - this.loginUrl_click = () => { - if (!this.state.loginUrl) return; - bridge().openExternal(this.state.loginUrl) - } - - this.authCodeInput_change = (event) => { - this.setState({ - authCode: event.target.value - }); - } - - this.submit_click = async () => { - this.setState({ checkingAuthToken: true }); - - const api = await this.dropboxApi(); - try { - const response = await api.execAuthToken(this.state.authCode); - Setting.setValue('sync.' + this.syncTargetId() + '.auth', response.access_token); - api.setAuthToken(response.access_token); - bridge().showInfoMessageBox(_('The application has been authorised!')); - this.props.dispatch({ type: 'NAV_BACK' }); - } catch (error) { - bridge().showErrorMessageBox(_('Could not authorise application:\n\n%s\n\nPlease try again.', error.message)); - } finally { - this.setState({ checkingAuthToken: false }); - } - } + this.shared_ = new Shared( + this, + (msg) => bridge().showInfoMessageBox(msg), + (msg) => bridge().showErrorMessageBox(msg) + ); } componentWillMount() { - this.refreshUrl(); - } - - syncTargetId() { - return SyncTargetRegistry.nameToId('dropbox'); - } - - async dropboxApi() { - if (this.dropboxApi_) return this.dropboxApi_; - - const syncTarget = reg.syncTarget(this.syncTargetId()); - this.dropboxApi_ = await syncTarget.api(); - return this.dropboxApi_; - } - - async refreshUrl() { - const api = await this.dropboxApi(); - - this.setState({ - loginUrl: api.loginUrl(), - }); + this.shared_.refreshUrl(); } render() { @@ -89,10 +40,10 @@ class DropboxLoginScreenComponent extends React.Component {

{_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')}

{_('Step 1: Open this URL in your browser to authorise the application:')}

- {this.state.loginUrl} + {this.state.loginUrl}

{_('Step 2: Enter the code provided by Dropbox:')}

-

- +

+
); diff --git a/ReactNativeClient/lib/components/screens/dropbox-login.js b/ReactNativeClient/lib/components/screens/dropbox-login.js new file mode 100644 index 000000000..4d3dbdf82 --- /dev/null +++ b/ReactNativeClient/lib/components/screens/dropbox-login.js @@ -0,0 +1,57 @@ +const React = require('react'); const Component = React.Component; +const { View, Button, Text, TextInput, TouchableOpacity } = require('react-native'); +const { connect } = require('react-redux'); +const { ScreenHeader } = require('lib/components/screen-header.js'); +const { _ } = require('lib/locale.js'); +const { BaseScreenComponent } = require('lib/components/base-screen.js'); +const DialogBox = require('react-native-dialogbox').default; +const { dialogs } = require('lib/dialogs.js'); +const Shared = require('lib/components/shared/dropbox-login-shared'); + +class DropboxLoginScreenComponent extends BaseScreenComponent { + + constructor() { + super(); + + this.shared_ = new Shared( + this, + (msg) => dialogs.info(this, msg), + (msg) => dialogs.error(this, msg) + ); + } + + componentWillMount() { + this.shared_.refreshUrl(); + } + + render() { + return ( + + + + {_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')} + {_('Step 1: Open this URL in your browser to authorise the application:')} + + + {this.state.loginUrl} + + + {_('Step 2: Enter the code provided by Dropbox:')} + + + + + { this.dialogbox = dialogbox }}/> + + ); + } + +} + +const DropboxLoginScreen = connect( + (state) => { + return {}; + } +)(DropboxLoginScreenComponent) + +module.exports = { DropboxLoginScreen }; \ No newline at end of file diff --git a/ReactNativeClient/lib/components/shared/dropbox-login-shared.js b/ReactNativeClient/lib/components/shared/dropbox-login-shared.js new file mode 100644 index 000000000..d8cc66e53 --- /dev/null +++ b/ReactNativeClient/lib/components/shared/dropbox-login-shared.js @@ -0,0 +1,74 @@ +const { shim } = require('lib/shim'); +const SyncTargetRegistry = require('lib/SyncTargetRegistry'); +const { reg } = require('lib/registry.js'); +const { _ } = require('lib/locale.js'); +const Setting = require('lib/models/Setting'); + +class Shared { + + constructor(comp, showInfoMessageBox, showErrorMessageBox) { + this.comp_ = comp; + + this.dropboxApi_ = null; + + this.comp_.state = { + loginUrl: '', + authCode: '', + checkingAuthToken: false, + }; + + this.loginUrl_click = () => { + if (!this.comp_.state.loginUrl) return; + shim.openUrl(this.comp_.state.loginUrl); + } + + this.authCodeInput_change = (event) => { + this.comp_.setState({ + authCode: typeof event === 'object' ? event.target.value : event + }); + } + + this.submit_click = async () => { + this.comp_.setState({ checkingAuthToken: true }); + + const api = await this.dropboxApi(); + try { + const response = await api.execAuthToken(this.comp_.state.authCode); + + Setting.setValue('sync.' + this.syncTargetId() + '.auth', response.access_token); + api.setAuthToken(response.access_token); + await showInfoMessageBox(_('The application has been authorised!')); + this.comp_.props.dispatch({ type: 'NAV_BACK' }); + reg.scheduleSync(); + } catch (error) { + console.error(error); + await showErrorMessageBox(_('Could not authorise application:\n\n%s\n\nPlease try again.', error.message)); + } finally { + this.comp_.setState({ checkingAuthToken: false }); + } + } + } + + syncTargetId() { + return SyncTargetRegistry.nameToId('dropbox'); + } + + async dropboxApi() { + if (this.dropboxApi_) return this.dropboxApi_; + + const syncTarget = reg.syncTarget(this.syncTargetId()); + this.dropboxApi_ = await syncTarget.api(); + return this.dropboxApi_; + } + + async refreshUrl() { + const api = await this.dropboxApi(); + + this.comp_.setState({ + loginUrl: api.loginUrl(), + }); + } + +} + +module.exports = Shared; \ No newline at end of file diff --git a/ReactNativeClient/lib/dialogs.js b/ReactNativeClient/lib/dialogs.js index f9ea0fca5..f611f0fdd 100644 --- a/ReactNativeClient/lib/dialogs.js +++ b/ReactNativeClient/lib/dialogs.js @@ -67,6 +67,11 @@ dialogs.error = (parentComponent, message) => { return parentComponent.dialogbox.alert(message); } +dialogs.info = (parentComponent, message) => { + Keyboard.dismiss(); + return parentComponent.dialogbox.alert(message); +} + dialogs.DialogBox = DialogBox module.exports = { dialogs }; \ No newline at end of file diff --git a/ReactNativeClient/lib/file-api-driver-dropbox.js b/ReactNativeClient/lib/file-api-driver-dropbox.js index 69797b28a..6d5c308da 100644 --- a/ReactNativeClient/lib/file-api-driver-dropbox.js +++ b/ReactNativeClient/lib/file-api-driver-dropbox.js @@ -150,7 +150,7 @@ class FileApiDriverDropbox { async put(path, content, options = null) { // See https://github.com/facebook/react-native/issues/14445#issuecomment-352965210 - if (typeof content === 'string') content = Buffer.from(content, 'utf8') + if (typeof content === 'string') content = shim.Buffer.from(content, 'utf8') await this.api().exec('POST', 'files/upload', content, { 'Dropbox-API-Arg': JSON.stringify({ diff --git a/ReactNativeClient/lib/shim-init-node.js b/ReactNativeClient/lib/shim-init-node.js index bf0065c09..d8ade679a 100644 --- a/ReactNativeClient/lib/shim-init-node.js +++ b/ReactNativeClient/lib/shim-init-node.js @@ -183,6 +183,11 @@ function shimInit() { shim.Buffer = Buffer; + shim.openUrl = (url) => { + const { bridge } = require('electron').remote.require('./bridge'); + bridge().openExternal(url) + } + } module.exports = { shimInit }; \ No newline at end of file diff --git a/ReactNativeClient/lib/shim-init-react.js b/ReactNativeClient/lib/shim-init-react.js index c8edf6e4e..19e9c1712 100644 --- a/ReactNativeClient/lib/shim-init-react.js +++ b/ReactNativeClient/lib/shim-init-react.js @@ -6,6 +6,7 @@ const { generateSecureRandom } = require('react-native-securerandom'); const FsDriverRN = require('lib/fs-driver-rn.js').FsDriverRN; const urlValidator = require('valid-url'); const { Buffer } = require('buffer'); +const { Linking } = require('react-native'); function shimInit() { shim.Geolocation = GeolocationReact; @@ -118,6 +119,10 @@ function shimInit() { } shim.Buffer = Buffer; + + shim.openUrl = (url) => { + Linking.openURL(url); + } } module.exports = { shimInit }; \ No newline at end of file diff --git a/ReactNativeClient/lib/shim.js b/ReactNativeClient/lib/shim.js index db8ab1efb..4c7d5d5c7 100644 --- a/ReactNativeClient/lib/shim.js +++ b/ReactNativeClient/lib/shim.js @@ -130,5 +130,6 @@ shim.stringByteLength = function(string) { throw new Error('Not implemented'); } shim.detectAndSetLocale = null; shim.attachFileToNote = async (note, filePath) => {} shim.Buffer = null; +shim.openUrl = () => { throw new Error('Not implemented'); } module.exports = { shim }; \ No newline at end of file diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 89abe22ad..64d5caa27 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -36,6 +36,7 @@ const { WelcomeScreen } = require('lib/components/screens/welcome.js'); const { SearchScreen } = require('lib/components/screens/search.js'); const { OneDriveLoginScreen } = require('lib/components/screens/onedrive-login.js'); const { EncryptionConfigScreen } = require('lib/components/screens/encryption-config.js'); +const { DropboxLoginScreen } = require('lib/components/screens/dropbox-login.js'); const Setting = require('lib/models/Setting.js'); const { MenuContext } = require('react-native-popup-menu'); const { SideMenu } = require('lib/components/side-menu.js'); @@ -55,10 +56,12 @@ const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js'); const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js'); const SyncTargetNextcloud = require('lib/SyncTargetNextcloud.js'); const SyncTargetWebDAV = require('lib/SyncTargetWebDAV.js'); +const SyncTargetDropbox = require('lib/SyncTargetDropbox.js'); SyncTargetRegistry.addClass(SyncTargetOneDrive); SyncTargetRegistry.addClass(SyncTargetOneDriveDev); SyncTargetRegistry.addClass(SyncTargetNextcloud); SyncTargetRegistry.addClass(SyncTargetWebDAV); +SyncTargetRegistry.addClass(SyncTargetDropbox); // Disabled because not fully working //SyncTargetRegistry.addClass(SyncTargetFilesystem); @@ -365,16 +368,6 @@ async function initialize(dispatch) { await db.open({ name: 'joplin.sqlite' }) } else { await db.open({ name: 'joplin-68.sqlite' }) - //await db.open({ name: 'joplin-67.sqlite' }) - - // await db.exec('DELETE FROM notes'); - // await db.exec('DELETE FROM folders'); - // await db.exec('DELETE FROM tags'); - // await db.exec('DELETE FROM note_tags'); - // await db.exec('DELETE FROM resources'); - // await db.exec('DELETE FROM deleted_items'); - - // await db.exec('UPDATE notes SET is_conflict = 1 where id like "546f%"'); } reg.logger().info('Database is ready.'); @@ -559,6 +552,7 @@ class AppComponent extends React.Component { Note: { screen: NoteScreen }, Folder: { screen: FolderScreen }, OneDriveLogin: { screen: OneDriveLoginScreen }, + DropboxLogin: { screen: DropboxLoginScreen }, EncryptionConfig: { screen: EncryptionConfigScreen }, Log: { screen: LogScreen }, Status: { screen: StatusScreen }, From 2280fb5c4380d911fdec98d283b38e355da205a9 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 27 Mar 2018 17:41:19 +0000 Subject: [PATCH 5/9] Styled Dropbox mobile GUI --- .../lib/components/global-style.js | 8 +++ .../lib/components/screens/dropbox-login.js | 58 ++++++++++++++----- .../components/shared/dropbox-login-shared.js | 1 - 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/ReactNativeClient/lib/components/global-style.js b/ReactNativeClient/lib/components/global-style.js index 9bf6bf730..7dff4c0e8 100644 --- a/ReactNativeClient/lib/components/global-style.js +++ b/ReactNativeClient/lib/components/global-style.js @@ -15,6 +15,7 @@ const globalStyle = { dividerColor: "#dddddd", selectedColor: '#e5e5e5', disabledOpacity: 0.2, + colorUrl: '#000CFF', raisedBackgroundColor: "#0080EF", raisedColor: "#003363", @@ -89,10 +90,17 @@ function addExtraStyles(style) { fontSize: style.fontSize, }; + style.urlText = { + color: style.colorUrl, + fontSize: style.fontSize, + }; + return style; } function themeStyle(theme) { + if (!theme) throw new Error('Theme not set'); + if (themeCache_[theme]) return themeCache_[theme]; let output = Object.assign({}, globalStyle); diff --git a/ReactNativeClient/lib/components/screens/dropbox-login.js b/ReactNativeClient/lib/components/screens/dropbox-login.js index 4d3dbdf82..42c31e755 100644 --- a/ReactNativeClient/lib/components/screens/dropbox-login.js +++ b/ReactNativeClient/lib/components/screens/dropbox-login.js @@ -1,5 +1,5 @@ const React = require('react'); const Component = React.Component; -const { View, Button, Text, TextInput, TouchableOpacity } = require('react-native'); +const { View, Button, Text, TextInput, TouchableOpacity, StyleSheet } = require('react-native'); const { connect } = require('react-redux'); const { ScreenHeader } = require('lib/components/screen-header.js'); const { _ } = require('lib/locale.js'); @@ -7,12 +7,15 @@ const { BaseScreenComponent } = require('lib/components/base-screen.js'); const DialogBox = require('react-native-dialogbox').default; const { dialogs } = require('lib/dialogs.js'); const Shared = require('lib/components/shared/dropbox-login-shared'); +const { themeStyle } = require('lib/components/global-style.js'); class DropboxLoginScreenComponent extends BaseScreenComponent { constructor() { super(); + this.styles_ = {}; + this.shared_ = new Shared( this, (msg) => dialogs.info(this, msg), @@ -24,22 +27,45 @@ class DropboxLoginScreenComponent extends BaseScreenComponent { this.shared_.refreshUrl(); } + styles() { + const themeId = this.props.theme; + const theme = themeStyle(themeId); + + if (this.styles_[themeId]) return this.styles_[themeId]; + this.styles_ = {}; + + let styles = { + container: { + padding: theme.margin, + }, + stepText: Object.assign({}, theme.normalText, { marginBottom: theme.margin }), + urlText: Object.assign({}, theme.urlText, { marginBottom: theme.margin }), + } + + this.styles_[themeId] = StyleSheet.create(styles); + return this.styles_[themeId]; + } + render() { + const theme = themeStyle(this.props.theme); + return ( - {_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')} - {_('Step 1: Open this URL in your browser to authorise the application:')} - - - {this.state.loginUrl} - - - {_('Step 2: Enter the code provided by Dropbox:')} - + + {_('To allow Joplin to synchronise with Dropbox, please follow the steps below:')} + {_('Step 1: Open this URL in your browser to authorise the application:')} + + + {this.state.loginUrl} + + + {_('Step 2: Enter the code provided by Dropbox:')} + - + + { this.dialogbox = dialogbox }}/> @@ -48,10 +74,10 @@ class DropboxLoginScreenComponent extends BaseScreenComponent { } -const DropboxLoginScreen = connect( - (state) => { - return {}; - } -)(DropboxLoginScreenComponent) +const DropboxLoginScreen = connect((state) => { + return { + theme: state.settings.theme, + }; +})(DropboxLoginScreenComponent) module.exports = { DropboxLoginScreen }; \ No newline at end of file diff --git a/ReactNativeClient/lib/components/shared/dropbox-login-shared.js b/ReactNativeClient/lib/components/shared/dropbox-login-shared.js index d8cc66e53..8523ee38e 100644 --- a/ReactNativeClient/lib/components/shared/dropbox-login-shared.js +++ b/ReactNativeClient/lib/components/shared/dropbox-login-shared.js @@ -41,7 +41,6 @@ class Shared { this.comp_.props.dispatch({ type: 'NAV_BACK' }); reg.scheduleSync(); } catch (error) { - console.error(error); await showErrorMessageBox(_('Could not authorise application:\n\n%s\n\nPlease try again.', error.message)); } finally { this.comp_.setState({ checkingAuthToken: false }); From 4f5e7367d0235cecd980523d7dea9946b8864855 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 27 Mar 2018 17:48:55 +0100 Subject: [PATCH 6/9] Minor tweals --- CliClient/tests/synchronizer.js | 14 +++++++------- CliClient/tests/test-utils.js | 9 +++++---- ReactNativeClient/lib/shim.js | 3 +++ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CliClient/tests/synchronizer.js b/CliClient/tests/synchronizer.js index dbdaf799e..4ef35e86d 100644 --- a/CliClient/tests/synchronizer.js +++ b/CliClient/tests/synchronizer.js @@ -372,7 +372,7 @@ describe('Synchronizer', function() { expect(items.length).toBe(1); expect(items[0].title).toBe('note1'); expect(items[0].is_conflict).toBe(1); - })); + })); it('should resolve conflict if note has been deleted remotely and locally', asyncTest(async () => { let folder = await Folder.save({ title: "folder" }); @@ -542,11 +542,11 @@ describe('Synchronizer', function() { let n1 = await Note.save({ title: "mynote" }); let n2 = await Note.save({ title: "mynote2" }); let tag = await Tag.save({ title: 'mytag' }); - await synchronizer().start(); + let context1 = await synchronizer().start(); await switchClient(2); - await synchronizer().start(); + let context2 = await synchronizer().start(); if (withEncryption) { const masterKey_2 = await MasterKey.load(masterKey.id); await encryptionService().loadMasterKey(masterKey_2, '123456', true); @@ -560,21 +560,21 @@ describe('Synchronizer', function() { await Tag.addNote(remoteTag.id, n2.id); let noteIds = await Tag.noteIds(tag.id); expect(noteIds.length).toBe(2); - await synchronizer().start(); + context2 = await synchronizer().start({ context: context2 }); await switchClient(1); - await synchronizer().start(); + context1 = await synchronizer().start({ context: context1 }); let remoteNoteIds = await Tag.noteIds(tag.id); expect(remoteNoteIds.length).toBe(2); await Tag.removeNote(tag.id, n1.id); remoteNoteIds = await Tag.noteIds(tag.id); expect(remoteNoteIds.length).toBe(1); - await synchronizer().start(); + context1 = await synchronizer().start({ context: context1 }); await switchClient(2); - await synchronizer().start(); + context2 = await synchronizer().start({ context: context2 }); noteIds = await Tag.noteIds(tag.id); expect(noteIds.length).toBe(1); expect(remoteNoteIds[0]).toBe(noteIds[0]); diff --git a/CliClient/tests/test-utils.js b/CliClient/tests/test-utils.js index 0dc7bf165..394f83cc5 100644 --- a/CliClient/tests/test-utils.js +++ b/CliClient/tests/test-utils.js @@ -57,9 +57,9 @@ SyncTargetRegistry.addClass(SyncTargetNextcloud); SyncTargetRegistry.addClass(SyncTargetDropbox); // const syncTargetId_ = SyncTargetRegistry.nameToId("nextcloud"); -// const syncTargetId_ = SyncTargetRegistry.nameToId("memory"); +const syncTargetId_ = SyncTargetRegistry.nameToId("memory"); //const syncTargetId_ = SyncTargetRegistry.nameToId('filesystem'); -const syncTargetId_ = SyncTargetRegistry.nameToId('dropbox'); +// const syncTargetId_ = SyncTargetRegistry.nameToId('dropbox'); const syncDir = __dirname + '/../tests/sync'; const sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;//400; @@ -256,7 +256,7 @@ function fileApi() { const api = new DropboxApi(); const authTokenPath = __dirname + '/support/dropbox-auth.txt'; const authToken = fs.readFileSync(authTokenPath, 'utf8'); - if (!authTokenPath) throw new Error('Dropbox auth token missing in ' + authTokenPath); + if (!authToken) throw new Error('Dropbox auth token missing in ' + authTokenPath); api.setAuthToken(authToken); fileApi_ = new FileApi('', new FileApiDriverDropbox(api)); } @@ -301,8 +301,9 @@ function asyncTest(callback) { await callback(); } catch (error) { console.error(error); + } finally { + done(); } - done(); } } diff --git a/ReactNativeClient/lib/shim.js b/ReactNativeClient/lib/shim.js index 4c7d5d5c7..0d1fa088e 100644 --- a/ReactNativeClient/lib/shim.js +++ b/ReactNativeClient/lib/shim.js @@ -85,6 +85,9 @@ shim.fetchRequestCanBeRetried = function(error) { // Code: ETIMEDOUT if (error.code === 'ETIMEDOUT') return true; + // ECONNREFUSED is generally temporary + if (error.code === 'ECONNREFUSED') return true; + return false; }; From d3cd3789223cc843b01d820903669d326e659dfc Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 27 Mar 2018 17:57:34 +0100 Subject: [PATCH 7/9] Android release v1.0.113 --- README.md | 2 +- ReactNativeClient/android/app/build.gradle | 4 ++-- docs/index.html | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index de9d02b71..40acaa05e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Linux | Get it on Google Play | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.112/joplin-v1.0.112.apk) +Android | Get it on Google Play | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.113/joplin-v1.0.113.apk) iOS | Get it on the App Store | - ## Terminal application diff --git a/ReactNativeClient/android/app/build.gradle b/ReactNativeClient/android/app/build.gradle index 11a9abff3..6d4446c01 100644 --- a/ReactNativeClient/android/app/build.gradle +++ b/ReactNativeClient/android/app/build.gradle @@ -90,8 +90,8 @@ android { applicationId "net.cozic.joplin" minSdkVersion 16 targetSdkVersion 22 - versionCode 2097290 - versionName "1.0.112" + versionCode 2097291 + versionName "1.0.113" ndk { abiFilters "armeabi-v7a", "x86" } diff --git a/docs/index.html b/docs/index.html index 25a5a737e..b83666124 100644 --- a/docs/index.html +++ b/docs/index.html @@ -226,7 +226,7 @@ -Windows +Windows (64-bit only) Get it on Windows @@ -252,7 +252,7 @@ Android Get it on Google Play -or Download APK File +or Download APK File iOS @@ -416,14 +416,14 @@ $$ Croatian hr_HR -Hrvoje Mandić trbuhom@net.hr +Hrvoje Mandić trbuhom@net.hr 64% Czech cs_CZ -Lukas Helebrandt lukas@aiya.cz +Lukas Helebrandt lukas@aiya.cz 99% @@ -437,7 +437,7 @@ $$ Deutsch de_DE -Tobias Grasse mail@tobias-grasse.net +Tobias Grasse mail@tobias-grasse.net 98% @@ -451,7 +451,7 @@ $$ Español es_ES -Fernando Martín f@mrtn.es +Fernando Martín f@mrtn.es 98% @@ -479,21 +479,21 @@ $$ Português (Brasil) pt_BR -Renato Nunes Bastos rnbastos@gmail.com +Renato Nunes Bastos rnbastos@gmail.com 97% Русский ru_RU -Artyom Karlov artyom.karlov@gmail.com +Artyom Karlov artyom.karlov@gmail.com 98% 中文 (简体) zh_CN -RCJacH RCJacH@outlook.com +RCJacH RCJacH@outlook.com 66% From aee7f5a8acd2d0a09767bc8dc1224f1a28f673b2 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 27 Mar 2018 22:55:46 +0100 Subject: [PATCH 8/9] Android release v1.0.114 --- CliClient/package-lock.json | 5 ----- README.md | 2 +- ReactNativeClient/android/app/build.gradle | 4 ++-- docs/index.html | 16 ++++++++-------- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/CliClient/package-lock.json b/CliClient/package-lock.json index b67f6c914..269922113 100644 --- a/CliClient/package-lock.json +++ b/CliClient/package-lock.json @@ -983,11 +983,6 @@ "wrappy": "1.0.2" } }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" - }, "parse-data-uri": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/parse-data-uri/-/parse-data-uri-0.2.0.tgz", diff --git a/README.md b/README.md index 40acaa05e..592e8de87 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Linux | Get it on Google Play | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.113/joplin-v1.0.113.apk) +Android | Get it on Google Play | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.114/joplin-v1.0.114.apk) iOS | Get it on the App Store | - ## Terminal application diff --git a/ReactNativeClient/android/app/build.gradle b/ReactNativeClient/android/app/build.gradle index 6d4446c01..dc073888f 100644 --- a/ReactNativeClient/android/app/build.gradle +++ b/ReactNativeClient/android/app/build.gradle @@ -90,8 +90,8 @@ android { applicationId "net.cozic.joplin" minSdkVersion 16 targetSdkVersion 22 - versionCode 2097291 - versionName "1.0.113" + versionCode 2097292 + versionName "1.0.114" ndk { abiFilters "armeabi-v7a", "x86" } diff --git a/docs/index.html b/docs/index.html index b83666124..7597acdfa 100644 --- a/docs/index.html +++ b/docs/index.html @@ -252,7 +252,7 @@ Android Get it on Google Play -or Download APK File +or Download APK File iOS @@ -416,14 +416,14 @@ $$ Croatian hr_HR -Hrvoje Mandić trbuhom@net.hr +Hrvoje Mandić trbuhom@net.hr 64% Czech cs_CZ -Lukas Helebrandt lukas@aiya.cz +Lukas Helebrandt lukas@aiya.cz 99% @@ -437,7 +437,7 @@ $$ Deutsch de_DE -Tobias Grasse mail@tobias-grasse.net +Tobias Grasse mail@tobias-grasse.net 98% @@ -451,7 +451,7 @@ $$ Español es_ES -Fernando Martín f@mrtn.es +Fernando Martín f@mrtn.es 98% @@ -479,21 +479,21 @@ $$ Português (Brasil) pt_BR -Renato Nunes Bastos rnbastos@gmail.com +Renato Nunes Bastos rnbastos@gmail.com 97% Русский ru_RU -Artyom Karlov artyom.karlov@gmail.com +Artyom Karlov artyom.karlov@gmail.com 98% 中文 (简体) zh_CN -RCJacH RCJacH@outlook.com +RCJacH RCJacH@outlook.com 66% From 3a9643c1ea511c899e0c20dd8e024c8889c48eec Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 27 Mar 2018 22:57:23 +0100 Subject: [PATCH 9/9] Electron release v1.0.80 --- ElectronClient/app/package-lock.json | 2 +- ElectronClient/app/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ElectronClient/app/package-lock.json b/ElectronClient/app/package-lock.json index 37e315131..0da2e3543 100644 --- a/ElectronClient/app/package-lock.json +++ b/ElectronClient/app/package-lock.json @@ -1,6 +1,6 @@ { "name": "Joplin", - "version": "1.0.79", + "version": "1.0.80", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/ElectronClient/app/package.json b/ElectronClient/app/package.json index fd9f4ddfb..b0aa10a16 100644 --- a/ElectronClient/app/package.json +++ b/ElectronClient/app/package.json @@ -1,6 +1,6 @@ { "name": "Joplin", - "version": "1.0.79", + "version": "1.0.80", "description": "Joplin for Desktop", "main": "main.js", "scripts": {