diff --git a/ReactNativeClient/android/app/build.gradle b/ReactNativeClient/android/app/build.gradle index 5cea267a65..50ae7929cb 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 32 - versionName "0.9.19" + versionCode 33 + versionName "0.9.20" ndk { abiFilters "armeabi-v7a", "x86" } diff --git a/ReactNativeClient/lib/components/screen-header.js b/ReactNativeClient/lib/components/screen-header.js index 93b59394e0..309eac9118 100644 --- a/ReactNativeClient/lib/components/screen-header.js +++ b/ReactNativeClient/lib/components/screen-header.js @@ -279,6 +279,7 @@ const ScreenHeader = connect( (state) => { return { historyCanGoBack: state.historyCanGoBack, + locale: state.settings.locale, }; } )(ScreenHeaderComponent) diff --git a/ReactNativeClient/lib/components/screens/config.js b/ReactNativeClient/lib/components/screens/config.js index 31f8bba08b..4a0a1bce56 100644 --- a/ReactNativeClient/lib/components/screens/config.js +++ b/ReactNativeClient/lib/components/screens/config.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { View, Switch, StyleSheet, Picker, Text, Button } from 'react-native'; +import { View, Switch, Slider, StyleSheet, Picker, Text, Button } from 'react-native'; import { connect } from 'react-redux' import { ScreenHeader } from 'lib/components/screen-header.js'; import { _, setLocale } from 'lib/locale.js'; @@ -21,10 +21,17 @@ let styles = { color: globalStyle.color, }, settingControl: { - //color: globalStyle.color, + color: globalStyle.color, }, } +styles.switchSettingContainer = Object.assign({}, styles.settingContainer); +styles.switchSettingContainer.flexDirection = 'row'; +styles.switchSettingContainer.justifyContent = 'space-between'; + +styles.switchSettingControl = Object.assign({}, styles.settingControl); +delete styles.switchSettingControl.color; + styles = StyleSheet.create(styles); class ConfigScreenComponent extends BaseScreenComponent { @@ -82,12 +89,19 @@ class ConfigScreenComponent extends BaseScreenComponent { ); } else if (setting.type == Setting.TYPE_BOOL) { return ( - + {setting.label()} - updateSettingValue(key, value)} /> + updateSettingValue(key, value)} /> ); - + } else if (setting.type == Setting.TYPE_INT) { + return ( + + {setting.label()} + updateSettingValue(key, value)} /> + + ); + } else { //throw new Error('Unsupported setting type: ' + setting.type); } diff --git a/ReactNativeClient/lib/components/side-menu-content.js b/ReactNativeClient/lib/components/side-menu-content.js index 4ba7d368d7..d4153169ed 100644 --- a/ReactNativeClient/lib/components/side-menu-content.js +++ b/ReactNativeClient/lib/components/side-menu-content.js @@ -231,6 +231,7 @@ const SideMenuContent = connect( selectedFolderId: state.selectedFolderId, selectedTagId: state.selectedTagId, notesParentType: state.notesParentType, + locale: state.settings.locale, }; } )(SideMenuContentComponent) diff --git a/ReactNativeClient/lib/database.js b/ReactNativeClient/lib/database.js index 1507b8a6a7..e1160d2e72 100644 --- a/ReactNativeClient/lib/database.js +++ b/ReactNativeClient/lib/database.js @@ -147,6 +147,7 @@ class Database { } if (type == 'fieldType') { if (s == 'INTEGER') s = 'INT'; + if (!(('TYPE_' + s) in this)) throw new Error('Unkonwn fieldType: ' + s); return this['TYPE_' + s]; } if (type == 'syncTarget') { @@ -238,19 +239,32 @@ class Database { }; } - alterColumnQueries(tableName, fieldsAfter) { + alterColumnQueries(tableName, fields) { + let fieldsNoType = []; + for (let n in fields) { + if (!fields.hasOwnProperty(n)) continue; + fieldsNoType.push(n); + } + + let fieldsWithType = []; + for (let n in fields) { + if (!fields.hasOwnProperty(n)) continue; + fieldsWithType.push(this.escapeField(n) + ' ' + fields[n]); + } + let sql = ` - CREATE TEMPORARY TABLE _BACKUP_TABLE_NAME_(_FIELDS_AFTER_); - INSERT INTO _BACKUP_TABLE_NAME_ SELECT _FIELDS_AFTER_ FROM _TABLE_NAME_; + CREATE TEMPORARY TABLE _BACKUP_TABLE_NAME_(_FIELDS_TYPE_); + INSERT INTO _BACKUP_TABLE_NAME_ SELECT _FIELDS_NO_TYPE_ FROM _TABLE_NAME_; DROP TABLE _TABLE_NAME_; - CREATE TABLE _TABLE_NAME_(_FIELDS_AFTER_); - INSERT INTO _TABLE_NAME_ SELECT _FIELDS_AFTER_ FROM _BACKUP_TABLE_NAME_; + CREATE TABLE _TABLE_NAME_(_FIELDS_TYPE_); + INSERT INTO _TABLE_NAME_ SELECT _FIELDS_NO_TYPE_ FROM _BACKUP_TABLE_NAME_; DROP TABLE _BACKUP_TABLE_NAME_; `; sql = sql.replace(/_BACKUP_TABLE_NAME_/g, this.escapeField(tableName + '_backup')); sql = sql.replace(/_TABLE_NAME_/g, this.escapeField(tableName)); - sql = sql.replace(/_FIELDS_AFTER_/g, this.escapeFields(fieldsAfter).join(',')); + sql = sql.replace(/_FIELDS_NO_TYPE_/g, this.escapeFields(fieldsNoType).join(',')); + sql = sql.replace(/_FIELDS_TYPE_/g, fieldsWithType.join(',')); return sql.trim().split("\n"); } diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js index 5746823ba5..adff9b6732 100644 --- a/ReactNativeClient/lib/joplin-database.js +++ b/ReactNativeClient/lib/joplin-database.js @@ -193,7 +193,7 @@ class JoplinDatabase extends Database { // 1. Add the new version number to the existingDatabaseVersions array // 2. Add the upgrade logic to the "switch (targetVersion)" statement below - const existingDatabaseVersions = [1, 2, 3]; + const existingDatabaseVersions = [0, 1, 2, 3]; let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion); if (currentVersionIndex == existingDatabaseVersions.length - 1) return false; @@ -203,6 +203,10 @@ class JoplinDatabase extends Database { this.logger().info("Converting database to version " + targetVersion); let queries = []; + + if (targetVersion == 1) { + queries = this.wrapQueries(this.sqlStringToLines(structureSql)); + } if (targetVersion == 2) { const newTableSql = ` @@ -221,7 +225,7 @@ class JoplinDatabase extends Database { } if (targetVersion == 3) { - queries = this.alterColumnQueries('settings', ['key', 'value']); + queries = this.alterColumnQueries('settings', { key: 'TEXT PRIMARY KEY', value: 'TEXT' }); } queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] }); @@ -236,53 +240,32 @@ class JoplinDatabase extends Database { async initialize() { this.logger().info('Checking for database schema update...'); - for (let initLoopCount = 1; initLoopCount <= 2; initLoopCount++) { - try { - let row = await this.selectOne('SELECT * FROM version LIMIT 1'); - let currentVersion = row.version; - this.logger().info('Current database version', currentVersion); + let versionRow = null; + try { + // Will throw if the database has not been created yet, but this is handled below + versionRow = await this.selectOne('SELECT * FROM version LIMIT 1'); + } catch (error) { + console.info(error); + } - const upgraded = await this.upgradeDatabase(currentVersion); - if (upgraded) await this.refreshTableFields(); - } catch (error) { - if (error && error.code != 0 && error.code != 'SQLITE_ERROR') throw this.sqliteErrorToJsError(error); - - // Assume that error was: - // { message: 'no such table: version (code 1): , while compiling: SELECT * FROM version', code: 0 } - // which means the database is empty and the tables need to be created. - // If it's any other error there's nothing we can do anyway. + const version = !versionRow ? 0 : versionRow.version; + this.logger().info('Current database version', version); - this.logger().info('Database is new - creating the schema...'); + const upgraded = await this.upgradeDatabase(version); + if (upgraded) await this.refreshTableFields(); - let queries = this.wrapQueries(this.sqlStringToLines(structureSql)); + this.tableFields_ = {}; - try { - await this.transactionExecBatch(queries); - this.logger().info('Database schema created successfully'); - await this.refreshTableFields(); - } catch (error) { - throw this.sqliteErrorToJsError(error); - } + let rows = await this.selectAll('SELECT * FROM table_fields'); - // Now that the database has been created, go through the normal initialisation process - continue; - } - - this.tableFields_ = {}; - - let rows = await this.selectAll('SELECT * FROM table_fields'); - - for (let i = 0; i < rows.length; i++) { - let row = rows[i]; - if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = []; - this.tableFields_[row.table_name].push({ - name: row.field_name, - type: row.field_type, - default: Database.formatValue(row.field_type, row.field_default), - }); - } - - break; + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = []; + this.tableFields_[row.table_name].push({ + name: row.field_name, + type: row.field_type, + default: Database.formatValue(row.field_type, row.field_default), + }); } } diff --git a/ReactNativeClient/lib/models/setting.js b/ReactNativeClient/lib/models/setting.js index 54aac799be..d46c23b4ef 100644 --- a/ReactNativeClient/lib/models/setting.js +++ b/ReactNativeClient/lib/models/setting.js @@ -39,7 +39,7 @@ class Setting extends BaseModel { } static load() { - this.cancelScheduleUpdate(); + this.cancelScheduleSave(); this.cache_ = []; return this.modelSelectAll('SELECT * FROM settings').then((rows) => { this.cache_ = rows; @@ -49,11 +49,25 @@ class Setting extends BaseModel { if (c.key == 'clientId') continue; // For older clients if (c.key == 'sync.onedrive.auth') continue; // For older clients + if (c.key == 'syncInterval') continue; // For older clients + + // console.info(c.key + ' = ' + c.value); c.value = this.formatValue(c.key, c.value); this.cache_[i] = c; } + + const keys = this.keys(); + let keyToValues = {}; + for (let i = 0; i < keys.length; i++) { + keyToValues[keys[i]] = this.value(keys[i]); + } + + this.dispatch({ + type: 'SETTINGS_UPDATE_ALL', + settings: keyToValues, + }); }); } @@ -81,7 +95,14 @@ class Setting extends BaseModel { this.logger().info('Setting: ' + key + ' = ' + value); c.value = this.formatValue(key, value); - this.scheduleUpdate(); + + this.dispatch({ + type: 'SETTINGS_UPDATE_ONE', + key: key, + value: c.value, + }); + + this.scheduleSave(); return; } } @@ -91,13 +112,30 @@ class Setting extends BaseModel { value: this.formatValue(key, value), }); - this.scheduleUpdate(); + this.dispatch({ + type: 'SETTINGS_UPDATE_ONE', + key: key, + value: this.formatValue(key, value), + }); + + this.scheduleSave(); + } + + static valueToString(key, value) { + const md = this.settingMetadata(key); + value = this.formatValue(key, value); + if (md.type == Setting.TYPE_INT) return value.toFixed(0); + if (md.type == Setting.TYPE_BOOL) return value ? '1' : '0'; + return value; } static formatValue(key, value) { const md = this.settingMetadata(key); if (md.type == Setting.TYPE_INT) return Math.floor(Number(value)); - if (md.type == Setting.TYPE_BOOL) return !!value; + if (md.type == Setting.TYPE_BOOL) { + if (typeof value === 'string') value = Number(value); + return !!value; + } return value; } @@ -186,16 +224,18 @@ class Setting extends BaseModel { } static saveAll() { - if (!this.updateTimeoutId_) return Promise.resolve(); + if (!this.saveTimeoutId_) return Promise.resolve(); this.logger().info('Saving settings...'); - clearTimeout(this.updateTimeoutId_); - this.updateTimeoutId_ = null; + clearTimeout(this.saveTimeoutId_); + this.saveTimeoutId_ = null; let queries = []; queries.push('DELETE FROM settings'); for (let i = 0; i < this.cache_.length; i++) { - queries.push(Database.insertQuery(this.tableName(), this.cache_[i])); + let s = Object.assign({}, this.cache_[i]); + s.value = this.valueToString(s.key, s.value); + queries.push(Database.insertQuery(this.tableName(), s)); } return BaseModel.db().transactionExecBatch(queries).then(() => { @@ -203,17 +243,17 @@ class Setting extends BaseModel { }); } - static scheduleUpdate() { - if (this.updateTimeoutId_) clearTimeout(this.updateTimeoutId_); + static scheduleSave() { + if (this.saveTimeoutId_) clearTimeout(this.saveTimeoutId_); - this.updateTimeoutId_ = setTimeout(() => { + this.saveTimeoutId_ = setTimeout(() => { this.saveAll(); }, 500); } - static cancelScheduleUpdate() { - if (this.updateTimeoutId_) clearTimeout(this.updateTimeoutId_); - this.updateTimeoutId_ = null; + static cancelScheduleSave() { + if (this.saveTimeoutId_) clearTimeout(this.saveTimeoutId_); + this.saveTimeoutId_ = null; } static publicSettings(appType) { @@ -264,6 +304,16 @@ Setting.metadata_ = { nonCompleted: _('Non-completed ones only'), })}, 'trackLocation': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Save location with notes') }, + 'sync.interval': { value: 300, type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation interval'), options: () => { + return { + 300: _('%d minutes', 5), + 600: _('%d minutes', 10), + 1800: _('%d minutes', 30), + 3600: _('%d hour', 1), + 43200: _('%d hour', 12), + 86400: _('%d hours', 24), + }; + }}, }; // Contains constants that are set by the application and diff --git a/ReactNativeClient/lib/registry.js b/ReactNativeClient/lib/registry.js index 56b2ac2366..f907770a71 100644 --- a/ReactNativeClient/lib/registry.js +++ b/ReactNativeClient/lib/registry.js @@ -8,6 +8,7 @@ import { Synchronizer } from 'lib/synchronizer.js'; import { FileApiDriverOneDrive } from 'lib/file-api-driver-onedrive.js'; import { shim } from 'lib/shim.js'; import { FileApiDriverMemory } from 'lib/file-api-driver-memory.js'; +import { PoorManIntervals } from 'lib/poor-man-intervals.js'; const reg = {}; @@ -96,6 +97,14 @@ reg.synchronizer = async (syncTargetId) => { return sync; } +reg.syncHasAuth = async (syncTargetId) => { + if (syncTargetId == Setting.SYNC_TARGET_ONEDRIVE && !reg.oneDriveApi().auth()) { + return false; + } + + return true; +} + reg.scheduleSync = async (delay = null) => { if (delay === null) delay = 1000 * 10; @@ -110,12 +119,14 @@ reg.scheduleSync = async (delay = null) => { reg.scheduleSyncId_ = null; reg.logger().info('Doing scheduled sync'); - if (!reg.oneDriveApi().auth()) { + const syncTargetId = Setting.value('sync.target'); + + if (!reg.syncHasAuth()) { reg.logger().info('Synchronizer is missing credentials - manual sync required to authenticate.'); return; } - const sync = await reg.synchronizer(Setting.value('sync.target')); + const sync = await reg.synchronizer(syncTargetId); let context = Setting.value('sync.context'); context = context ? JSON.parse(context) : {}; @@ -129,6 +140,8 @@ reg.scheduleSync = async (delay = null) => { throw error; } } + + reg.setupRecurrentSync(); }; if (delay === 0) { @@ -138,6 +151,26 @@ reg.scheduleSync = async (delay = null) => { } } +reg.syncStarted = async () => { + if (!reg.syncHasAuth()) return false; + const sync = await reg.synchronizer(Setting.value('sync.target')); + return sync.state() != 'idle'; +} + +reg.setupRecurrentSync = () => { + if (this.recurrentSyncId_) { + PoorManIntervals.clearInterval(this.recurrentSyncId_); + this.recurrentSyncId_ = null; + } + + console.info('Setting up recurrent sync with interval ' + Setting.value('sync.interval')); + + this.recurrentSyncId_ = PoorManIntervals.setInterval(() => { + reg.logger().info('Running background sync on timer...'); + reg.scheduleSync(0); + }, 1000 * Setting.value('sync.interval')); +} + reg.setDb = (v) => { reg.db_ = v; } diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 604d051102..98f4b9d152 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -56,6 +56,7 @@ let defaultState = { syncStarted: false, syncReport: {}, searchQuery: '', + settings: {}, }; const initialRoute = { @@ -164,13 +165,7 @@ const reducer = (state = defaultState, action) => { } newState.route = action; - newState.historyCanGoBack = !!navHistory.length; - - if (newState.route.routeName == 'Notes') { - Setting.setValue('activeFolderId', newState.selectedFolderId); - } - break; // Replace all the notes with the provided array @@ -180,6 +175,20 @@ const reducer = (state = defaultState, action) => { newState.loading = false; break; + case 'SETTINGS_UPDATE_ALL': + + newState = Object.assign({}, state); + newState.settings = action.settings; + break; + + case 'SETTINGS_UPDATE_ONE': + + newState = Object.assign({}, state); + let newSettings = Object.assign({}, state.settings); + newSettings[action.key] = action.value; + newState.settings = newSettings; + break; + // Replace all the notes with the provided array case 'NOTES_UPDATE_ALL': @@ -319,23 +328,32 @@ const reducer = (state = defaultState, action) => { } } catch (error) { - error.message = 'In reducer: ' + error.message; + error.message = 'In reducer: ' + error.message + ' Action: ' + JSON.stringify(action); throw error; } return newState; } -const generalMiddleware = store => next => action => { +const generalMiddleware = store => next => async (action) => { reg.logger().info('Reducer action', action.type); PoorManIntervals.update(); // This function needs to be called regularly so put it here const result = next(action); + const newState = store.getState(); if (action.type == 'NAV_GO') Keyboard.dismiss(); if (['NOTES_UPDATE_ONE', 'NOTES_DELETE', 'FOLDERS_UPDATE_ONE', 'FOLDER_DELETE'].indexOf(action.type) >= 0) { - reg.scheduleSync(); + if (!await reg.syncStarted()) reg.scheduleSync(); + } + + if (action.type == 'SETTINGS_UPDATE_ONE' && action.key == 'sync.interval' || action.type == 'SETTINGS_UPDATE_ALL') { + reg.setupRecurrentSync(); + } + + if (action.type == 'NAV_GO' && action.routeName == 'Notes') { + Setting.setValue('activeFolderId', newState.selectedFolderId); } return result; @@ -399,8 +417,7 @@ async function initialize(dispatch, backButtonHandler) { if (Setting.value('env') == 'prod') { await db.open({ name: 'joplin.sqlite' }) } else { - //await db.open({ name: 'joplin-56.sqlite' }) - await db.open({ name: 'joplin-56.sqlite' }) + await db.open({ name: 'joplin-66.sqlite' }) // await db.exec('DELETE FROM notes'); // await db.exec('DELETE FROM folders'); @@ -465,10 +482,7 @@ async function initialize(dispatch, backButtonHandler) { return backButtonHandler(); }); - PoorManIntervals.setInterval(() => { - reg.logger().info('Running background sync on timer...'); - reg.scheduleSync(0); - }, 1000 * 60 * 5); + reg.setupRecurrentSync(); if (Setting.value('env') == 'dev') {