diff --git a/.travis.yml b/.travis.yml index 41a1490f6..96df8d0be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ +# Only build tags +if: tag IS present + rvm: 2.3.3 matrix: diff --git a/CliClient/app/autocompletion.js b/CliClient/app/autocompletion.js index cca40e206..a03f1c5e5 100644 --- a/CliClient/app/autocompletion.js +++ b/CliClient/app/autocompletion.js @@ -48,7 +48,7 @@ async function handleAutocompletionPromise(line) { if (options.length > 1 && options[1].indexOf(next) === 0) { l.push(options[1]); } else if (options[0].indexOf(next) === 0) { - l.push(options[2]); + l.push(options[0]); } } if (l.length === 0) { diff --git a/ElectronClient/app/gui/ConfigScreen.jsx b/ElectronClient/app/gui/ConfigScreen.jsx index ed72bd11f..9187c71ae 100644 --- a/ElectronClient/app/gui/ConfigScreen.jsx +++ b/ElectronClient/app/gui/ConfigScreen.jsx @@ -89,18 +89,19 @@ class ConfigScreenComponent extends React.Component { updateSettingValue(key, !value) } + // Hack: The {key+value.toString()} is needed as otherwise the checkbox doesn't update when the state changes. + // There's probably a better way to do this but can't figure it out. + return ( -
+
- { onCheckboxClick(event) }}/> + { onCheckboxClick(event) }}/>
); } else if (md.type === Setting.TYPE_STRING) { const onTextChange = (event) => { - const settings = Object.assign({}, this.state.settings); - settings[key] = event.target.value; - this.setState({ settings: settings }); + updateSettingValue(key, event.target.value); } return ( @@ -109,6 +110,17 @@ class ConfigScreenComponent extends React.Component { {onTextChange(event)}} />
); + } else if (md.type === Setting.TYPE_INT) { + const onNumChange = (event) => { + updateSettingValue(key, event.target.value); + }; + + return ( +
+
+ {onNumChange(event)}} min={md.minimum} max={md.maximum} step={md.step}/> +
+ ); } else { console.warn('Type not implemented: ' + key); } diff --git a/ElectronClient/app/package-lock.json b/ElectronClient/app/package-lock.json index 271555646..02ea987da 100644 --- a/ElectronClient/app/package-lock.json +++ b/ElectronClient/app/package-lock.json @@ -1,6 +1,6 @@ { "name": "Joplin", - "version": "0.10.47", + "version": "0.10.48", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -29,9 +29,9 @@ "optional": true }, "@types/node": { - "version": "7.0.46", - "resolved": "https://registry.npmjs.org/@types/node/-/node-7.0.46.tgz", - "integrity": "sha512-u+JAi1KtmaUoU/EHJkxoiuvzyo91FCE41Z9TZWWcOUU3P8oUdlDLdrGzCGWySPgbRMD17B0B+1aaJLYI9egQ6A==", + "version": "7.0.52", + "resolved": "https://registry.npmjs.org/@types/node/-/node-7.0.52.tgz", + "integrity": "sha512-jjpyQsKGsOF/wUElNjfPULk+d8PKvJOIXk3IUeBYYmNCy5dMWfrI+JiixYNw8ppKOlcRwWTXFl0B+i5oGrf95Q==", "dev": true }, "ajv": { @@ -1302,12 +1302,12 @@ "dev": true }, "electron": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/electron/-/electron-1.7.9.tgz", - "integrity": "sha1-rdVOn4+D7QL2UZ7BATX2mLGTNs8=", + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-1.7.11.tgz", + "integrity": "sha1-mTtqp54OeafPzDafTIE/vZoLCNk=", "dev": true, "requires": { - "@types/node": "7.0.46", + "@types/node": "7.0.52", "electron-download": "3.3.0", "extract-zip": "1.6.6" } diff --git a/ElectronClient/app/package.json b/ElectronClient/app/package.json index 7a0564d36..22cd60b0e 100644 --- a/ElectronClient/app/package.json +++ b/ElectronClient/app/package.json @@ -1,6 +1,6 @@ { "name": "Joplin", - "version": "0.10.47", + "version": "0.10.48", "description": "Joplin for Desktop", "main": "main.js", "scripts": { @@ -41,7 +41,7 @@ "devDependencies": { "babel-cli": "^6.26.0", "babel-preset-react": "^6.24.1", - "electron": "^1.7.9", + "electron": "^1.7.11", "electron-builder": "^19.45.4" }, "optionalDependencies": { diff --git a/ElectronClient/app/theme.js b/ElectronClient/app/theme.js index ca27c8945..bf194838d 100644 --- a/ElectronClient/app/theme.js +++ b/ElectronClient/app/theme.js @@ -1,7 +1,7 @@ const Setting = require('lib/models/Setting.js'); const globalStyle = { - fontSize: 12, + fontSize: 12 * Setting.value('style.zoom')/100, fontFamily: 'sans-serif', margin: 15, // No text and no interactive component should be within this margin itemMarginTop: 10, diff --git a/ReactNativeClient/lib/file-api-driver-local.js b/ReactNativeClient/lib/file-api-driver-local.js index 8ee94173b..7d664cc4b 100644 --- a/ReactNativeClient/lib/file-api-driver-local.js +++ b/ReactNativeClient/lib/file-api-driver-local.js @@ -1,5 +1,5 @@ -const BaseItem = require('lib/models/BaseItem.js'); const { time } = require('lib/time-utils.js'); +const { basicDelta } = require('lib/file-api'); // NOTE: when synchronising with the file system the time resolution is the second (unlike milliseconds for OneDrive for instance). // What it means is that if, for example, client 1 changes a note at time t, and client 2 changes the same note within the same second, @@ -65,113 +65,15 @@ class FileApiDriverLocal { } } - contextFromOptions_(options) { - let output = { - timestamp: 0, - filesAtTimestamp: [], - statsCache: null, + async delta(path, options) { + const getStatFn = async (path) => { + const stats = await this.fsDriver().readDirStats(path); + return this.metadataFromStats_(stats); }; - if (!options || !options.context) return output; - const d = new Date(options.context.timestamp); - - output.timestamp = isNaN(d.getTime()) ? 0 : options.context.timestamp; - output.filesAtTimestamp = Array.isArray(options.context.filesAtTimestamp) ? options.context.filesAtTimestamp.slice() : []; - output.statsCache = options.context && options.context.statsCache ? options.context.statsCache : null; - - return output; - } - - async delta(path, options) { - const outputLimit = 1000; - const itemIds = await options.allItemIdsHandler(); - try { - const context = this.contextFromOptions_(options); - - let newContext = { - timestamp: context.timestamp, - filesAtTimestamp: context.filesAtTimestamp.slice(), - statsCache: context.statsCache, - }; - - // Stats are cached until all items have been processed (until hasMore is false) - if (newContext.statsCache === null) { - const stats = await this.fsDriver().readDirStats(path); - newContext.statsCache = this.metadataFromStats_(stats); - newContext.statsCache.sort(function(a, b) { - return a.updated_time - b.updated_time; - }); - } - - let output = []; - - // Find out which files have been changed since the last time. Note that we keep - // both the timestamp of the most recent change, *and* the items that exactly match - // this timestamp. This to handle cases where an item is modified while this delta - // function is running. For example: - // t0: Item 1 is changed - // t0: Sync items - run delta function - // t0: While delta() is running, modify Item 2 - // Since item 2 was modified within the same millisecond, it would be skipped in the - // next sync if we relied exclusively on a timestamp. - for (let i = 0; i < newContext.statsCache.length; i++) { - const stat = newContext.statsCache[i]; - - if (stat.isDir) continue; - - if (stat.updated_time < context.timestamp) continue; - - // Special case for items that exactly match the timestamp - if (stat.updated_time === context.timestamp) { - if (context.filesAtTimestamp.indexOf(stat.path) >= 0) continue; - } - - if (stat.updated_time > newContext.timestamp) { - newContext.timestamp = stat.updated_time; - newContext.filesAtTimestamp = []; - } - - newContext.filesAtTimestamp.push(stat.path); - output.push(stat); - - if (output.length >= outputLimit) break; - } - - if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided'); - - let deletedItems = []; - for (let i = 0; i < itemIds.length; i++) { - if (output.length + deletedItems.length >= outputLimit) break; - - const itemId = itemIds[i]; - let found = false; - for (let j = 0; j < newContext.statsCache.length; j++) { - const item = newContext.statsCache[j]; - if (BaseItem.pathToId(item.path) == itemId) { - found = true; - break; - } - } - - if (!found) { - deletedItems.push({ - path: BaseItem.systemPath(itemId), - isDeleted: true, - }); - } - } - - output = output.concat(deletedItems); - - const hasMore = output.length >= outputLimit; - if (!hasMore) newContext.statsCache = null; - - return { - hasMore: hasMore, - context: newContext, - items: output, - }; + const output = await basicDelta(path, getStatFn, options); + return output; } catch(error) { throw this.fsErrorToJsError_(error, path); } diff --git a/ReactNativeClient/lib/file-api-driver-memory.js b/ReactNativeClient/lib/file-api-driver-memory.js index 317796ec2..29ac3fdb3 100644 --- a/ReactNativeClient/lib/file-api-driver-memory.js +++ b/ReactNativeClient/lib/file-api-driver-memory.js @@ -1,5 +1,6 @@ const { time } = require('lib/time-utils.js'); const fs = require('fs-extra'); +const { basicDelta } = require('lib/file-api'); class FileApiDriverMemory { @@ -144,48 +145,17 @@ class FileApiDriverMemory { } async delta(path, options = null) { - let limit = 3; - - let output = { - hasMore: false, - context: {}, - items: [], + const getStatFn = async (path) => { + let output = this.items_.slice(); + for (let i = 0; i < output.length; i++) { + const item = Object.assign({}, output[i]); + item.path = item.path.substr(path.length + 1); + output[i] = item; + } + return output; }; - let context = options ? options.context : null; - let fromTime = 0; - - if (context) fromTime = context.fromTime; - - let sortedItems = this.items_.slice().concat(this.deletedItems_); - sortedItems.sort((a, b) => { - if (a.updated_time < b.updated_time) return -1; - if (a.updated_time > b.updated_time) return +1; - return 0; - }); - - let hasMore = false; - let items = []; - let maxTime = 0; - for (let i = 0; i < sortedItems.length; i++) { - let item = sortedItems[i]; - if (item.updated_time >= fromTime) { - item = Object.assign({}, item); - item.path = item.path.substr(path.length + 1); - items.push(item); - if (item.updated_time > maxTime) maxTime = item.updated_time; - } - - if (items.length >= limit) { - hasMore = true; - break; - } - } - - output.items = items; - output.hasMore = hasMore; - output.context = { fromTime: maxTime }; - + const output = await basicDelta(path, getStatFn, options); return output; } diff --git a/ReactNativeClient/lib/file-api.js b/ReactNativeClient/lib/file-api.js index ebb387686..4c25d33af 100644 --- a/ReactNativeClient/lib/file-api.js +++ b/ReactNativeClient/lib/file-api.js @@ -1,6 +1,7 @@ const { isHidden } = require('lib/path-utils.js'); const { Logger } = require('lib/logger.js'); const { shim } = require('lib/shim'); +const BaseItem = require('lib/models/BaseItem.js'); const JoplinError = require('lib/JoplinError'); class FileApi { @@ -120,4 +121,113 @@ class FileApi { } -module.exports = { FileApi }; \ No newline at end of file +function basicDeltaContextFromOptions_(options) { + let output = { + timestamp: 0, + filesAtTimestamp: [], + statsCache: null, + }; + + if (!options || !options.context) return output; + const d = new Date(options.context.timestamp); + + output.timestamp = isNaN(d.getTime()) ? 0 : options.context.timestamp; + output.filesAtTimestamp = Array.isArray(options.context.filesAtTimestamp) ? options.context.filesAtTimestamp.slice() : []; + output.statsCache = options.context && options.context.statsCache ? options.context.statsCache : null; + + return output; +} + +// This is the basic delta algorithm, which can be used in case the cloud service does not have +// a built-on delta API. OneDrive and Dropbox have one for example, but Nextcloud and obviously +// the file system do not. +async function basicDelta(path, getStatFn, options) { + const outputLimit = 1000; + const itemIds = await options.allItemIdsHandler(); + if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided'); + + const context = basicDeltaContextFromOptions_(options); + + let newContext = { + timestamp: context.timestamp, + filesAtTimestamp: context.filesAtTimestamp.slice(), + statsCache: context.statsCache, + }; + + // Stats are cached until all items have been processed (until hasMore is false) + if (newContext.statsCache === null) { + newContext.statsCache = await getStatFn(path); + newContext.statsCache.sort(function(a, b) { + return a.updated_time - b.updated_time; + }); + } + + let output = []; + + // Find out which files have been changed since the last time. Note that we keep + // both the timestamp of the most recent change, *and* the items that exactly match + // this timestamp. This to handle cases where an item is modified while this delta + // function is running. For example: + // t0: Item 1 is changed + // t0: Sync items - run delta function + // t0: While delta() is running, modify Item 2 + // Since item 2 was modified within the same millisecond, it would be skipped in the + // next sync if we relied exclusively on a timestamp. + for (let i = 0; i < newContext.statsCache.length; i++) { + const stat = newContext.statsCache[i]; + + if (stat.isDir) continue; + + if (stat.updated_time < context.timestamp) continue; + + // Special case for items that exactly match the timestamp + if (stat.updated_time === context.timestamp) { + if (context.filesAtTimestamp.indexOf(stat.path) >= 0) continue; + } + + if (stat.updated_time > newContext.timestamp) { + newContext.timestamp = stat.updated_time; + newContext.filesAtTimestamp = []; + } + + newContext.filesAtTimestamp.push(stat.path); + output.push(stat); + + if (output.length >= outputLimit) break; + } + + let deletedItems = []; + for (let i = 0; i < itemIds.length; i++) { + if (output.length + deletedItems.length >= outputLimit) break; + + const itemId = itemIds[i]; + let found = false; + for (let j = 0; j < newContext.statsCache.length; j++) { + const item = newContext.statsCache[j]; + if (BaseItem.pathToId(item.path) == itemId) { + found = true; + break; + } + } + + if (!found) { + deletedItems.push({ + path: BaseItem.systemPath(itemId), + isDeleted: true, + }); + } + } + + output = output.concat(deletedItems); + + const hasMore = output.length >= outputLimit; + if (!hasMore) newContext.statsCache = null; + + return { + hasMore: hasMore, + context: newContext, + items: output, + }; +} + +module.exports = { FileApi, basicDelta }; \ No newline at end of file diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index ff57cb96a..68472b5d1 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -63,6 +63,8 @@ class Setting extends BaseModel { 'encryption.enabled': { value: false, type: Setting.TYPE_BOOL, public: false }, 'encryption.activeMasterKeyId': { value: '', type: Setting.TYPE_STRING, public: false }, 'encryption.passwordCache': { value: {}, type: Setting.TYPE_OBJECT, public: false }, + 'style.zoom': {value: "100", type: Setting.TYPE_INT, public: true, appTypes: ['desktop'], label: () => _('Set application zoom percentage'), minimum: "50", maximum: "500", step: "10"}, + 'autoUpdateEnabled': { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['desktop'], label: () => _('Automatically update the application') }, 'sync.interval': { value: 300, type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation interval'), options: () => { return { 0: _('Disabled'), @@ -75,7 +77,6 @@ class Setting extends BaseModel { }; }}, 'noteVisiblePanes': { value: ['editor', 'viewer'], type: Setting.TYPE_ARRAY, public: false, appTypes: ['desktop'] }, - 'autoUpdateEnabled': { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['desktop'], label: () => _('Automatically update the application') }, '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: () => _('The target to synchonise to. If synchronising with the file system, set `sync.2.path` to specify the target directory.'), options: () => { return SyncTargetRegistry.idAndLabelPlainObject(); diff --git a/ReactNativeClient/lib/synchronizer.js b/ReactNativeClient/lib/synchronizer.js index 1ea1621bf..743f889b5 100644 --- a/ReactNativeClient/lib/synchronizer.js +++ b/ReactNativeClient/lib/synchronizer.js @@ -489,7 +489,7 @@ class Synchronizer { if (action == 'createLocal' || action == 'updateLocal') { if (content === null) { - this.logger().warn('Remote has been deleted between now and the list() call? In that case it will be handled during the next sync: ' + path); + this.logger().warn('Remote has been deleted between now and the delta() call? In that case it will be handled during the next sync: ' + path); continue; } content = ItemClass.filter(content);