diff --git a/CliClient/app/app.js b/CliClient/app/app.js index 77ea6913d..978065881 100644 --- a/CliClient/app/app.js +++ b/CliClient/app/app.js @@ -260,8 +260,8 @@ class Application { } else if (syncTarget == 'memory') { fileApi = new FileApi('joplin', new FileApiDriverMemory()); fileApi.setLogger(this.logger_); - } else if (syncTarget == 'file') { - let syncDir = Setting.value('sync.local.path'); + } else if (syncTarget == 'filesystem') { + let syncDir = Setting.value('sync.filesystem.path'); if (!syncDir) syncDir = Setting.value('profileDir') + '/sync'; this.vorpal().log(_('Synchronizing with directory "%s"', syncDir)); await fs.mkdirp(syncDir, 0o755); diff --git a/CliClient/app/command-status.js b/CliClient/app/command-status.js index a00a7c620..fe3931e01 100644 --- a/CliClient/app/command-status.js +++ b/CliClient/app/command-status.js @@ -1,4 +1,6 @@ import { BaseCommand } from './base-command.js'; +import { Database } from 'lib/database.js'; +import { Setting } from 'lib/models/setting.js'; import { _ } from 'lib/locale.js'; import { ReportService } from 'lib/services/report.js'; @@ -14,7 +16,7 @@ class Command extends BaseCommand { async action(args) { let service = new ReportService(); - let report = await service.status(); + let report = await service.status(Database.enumId('syncTarget', Setting.value('sync.target'))); for (let i = 0; i < report.length; i++) { let section = report[i]; diff --git a/CliClient/app/fuzzing.js b/CliClient/app/fuzzing.js index 5395d97a5..c1ddc403a 100644 --- a/CliClient/app/fuzzing.js +++ b/CliClient/app/fuzzing.js @@ -42,7 +42,7 @@ async function createClients() { for (let clientId = 0; clientId < 2; clientId++) { let client = createClient(clientId); promises.push(fs.remove(client.profileDir)); - promises.push(execCommand(client, 'config sync.target local').then(() => { return execCommand(client, 'config sync.local.path ' + syncDir); })); + promises.push(execCommand(client, 'config sync.target filesystem').then(() => { return execCommand(client, 'config sync.filesystem.path ' + syncDir); })); output.push(client); } diff --git a/CliClient/package.json b/CliClient/package.json index 993949607..ea07e4391 100644 --- a/CliClient/package.json +++ b/CliClient/package.json @@ -7,7 +7,7 @@ "url": "https://github.com/laurent22/joplin" }, "url": "git://github.com/laurent22/joplin.git", - "version": "0.8.43", + "version": "0.8.44", "bin": { "joplin": "./main_launcher.js" }, diff --git a/CliClient/tests/synchronizer.js b/CliClient/tests/synchronizer.js index f8f10e121..4a8b5ec9c 100644 --- a/CliClient/tests/synchronizer.js +++ b/CliClient/tests/synchronizer.js @@ -75,8 +75,6 @@ describe('Synchronizer', function() { let note = await Note.save({ title: "un", parent_id: folder.id }); await synchronizer().start(); - await sleep(0.1); - await Note.save({ title: "un UPDATE", id: note.id }); let all = await allItems(); diff --git a/ReactNativeClient/android/app/build.gradle b/ReactNativeClient/android/app/build.gradle index 762eddd58..4b971ee6f 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 16 - versionName "0.9.3" + versionCode 18 + versionName "0.9.5" ndk { abiFilters "armeabi-v7a", "x86" } diff --git a/ReactNativeClient/lib/base-model.js b/ReactNativeClient/lib/base-model.js index a9aea35a1..92f7c965d 100644 --- a/ReactNativeClient/lib/base-model.js +++ b/ReactNativeClient/lib/base-model.js @@ -218,6 +218,7 @@ class BaseModel { } query.id = modelId; + query.modObject = o; return query; } @@ -241,6 +242,8 @@ class BaseModel { return this.db().transactionExecBatch(queries).then(() => { o = Object.assign({}, o); o.id = modelId; + if ('updated_time' in saveQuery.modObject) o.updated_time = saveQuery.modObject.updated_time; + if ('created_time' in saveQuery.modObject) o.created_time = saveQuery.modObject.created_time; o = this.addModelMd(o); return this.filter(o); }).catch((error) => { diff --git a/ReactNativeClient/lib/components/screen-header.js b/ReactNativeClient/lib/components/screen-header.js index 1129526a0..6361e02b6 100644 --- a/ReactNativeClient/lib/components/screen-header.js +++ b/ReactNativeClient/lib/components/screen-header.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import { connect } from 'react-redux' -import { View, Text, Button, StyleSheet, TouchableOpacity } from 'react-native'; +import { View, Text, Button, StyleSheet, TouchableOpacity, Picker } from 'react-native'; import { Log } from 'lib/log.js'; import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu'; import { _ } from 'lib/locale.js'; @@ -150,14 +150,33 @@ class ScreenHeaderComponent extends Component { {_('Status')} ); - let title = 'title' in this.props && this.props.title !== null ? this.props.title : _(this.props.navState.routeName); + const createTitleComponent = () => { + const p = this.props.titlePicker; + if (p) { + let items = []; + for (let i = 0; i < p.items.length; i++) { + let item = p.items[i]; + items.push(); + } + return ( + { if (p.onValueChange) p.onValueChange(itemValue, itemIndex); }}> + { items } + + ); + } else { + let title = 'title' in this.props && this.props.title !== null ? this.props.title : _(this.props.navState.routeName); + return {title} + } + } + + const titleComp = createTitleComponent(); return ( { sideMenuButton(styles, () => this.sideMenuButton_press()) } { backButton(styles, () => this.backButton_press(), !this.props.historyCanGoBack) } { saveButton(styles, () => { if (this.props.onSaveButtonPress) this.props.onSaveButtonPress() }, this.props.saveButtonDisabled === true, this.props.showSaveButton === true) } - {title} + { titleComp } this.menu_select(value)}> diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js index 9a39dbd50..677130b9d 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/note.js @@ -103,8 +103,12 @@ class NoteScreenComponent extends BaseScreenComponent { return Folder.load(folderId); } - async refreshFolder() { - this.setState({ folder: await this.currentFolder() }); + async refreshFolder(folderId = null) { + if (!folderId) { + this.setState({ folder: await this.currentFolder() }); + } else { + this.setState({ folder: await Folder.load(folderId) }); + } } noteComponent_change(propName, propValue) { @@ -183,23 +187,27 @@ class NoteScreenComponent extends BaseScreenComponent { ]; } - async todoCheckbox_change(checked) { + async saveOneProperty(name, value) { let note = Object.assign({}, this.state.note); - const todoCompleted = checked ? time.unixMs() : 0; - if (note.id) { - note = await Note.save({ id: note.id, todo_completed: todoCompleted }); + let toSave = { id: note.id }; + toSave[name] = value; + toSave = await Note.save(toSave); + note[name] = toSave[name]; this.setState({ lastSavedNote: Object.assign({}, note), note: note, }); } else { - note.todo_completed = todoCompleted; + note[name] = value; this.setState({ note: note }); } + } + async todoCheckbox_change(checked) { + return this.saveOneProperty('todo_completed', checked ? time.unixMs() : 0); } render() { @@ -271,9 +279,6 @@ class NoteScreenComponent extends BaseScreenComponent { ); } - let headerTitle = '' - if (folder) headerTitle = folder.title; - const renderActionButton = () => { let buttons = []; @@ -290,6 +295,15 @@ class NoteScreenComponent extends BaseScreenComponent { return } + const titlePickerItems = () => { + let output = []; + for (let i = 0; i < this.props.folders.length; i++) { + let f = this.props.folders[i]; + output.push({ label: f.title, value: f.id }); + } + return output; + } + const actionButtonComp = renderActionButton(); let showSaveButton = this.state.mode == 'edit'; @@ -298,7 +312,23 @@ class NoteScreenComponent extends BaseScreenComponent { return ( { + let note = Object.assign({}, this.state.note); + if (note.id) await Note.moveToFolder(note.id, itemValue); + note.parent_id = itemValue; + + const folder = await Folder.load(note.parent_id); + + this.setState({ + lastSavedNote: Object.assign({}, note), + note: note, + folder: folder, + }); + } + }} navState={this.props.navigation.state} menuOptions={this.menuOptions()} showSaveButton={showSaveButton} @@ -324,6 +354,7 @@ const NoteScreen = connect( noteId: state.selectedNoteId, folderId: state.selectedFolderId, itemType: state.selectedItemType, + folders: state.folders, }; } )(NoteScreenComponent) diff --git a/ReactNativeClient/lib/components/screens/status.js b/ReactNativeClient/lib/components/screens/status.js index 8e77cd88e..92b80a221 100644 --- a/ReactNativeClient/lib/components/screens/status.js +++ b/ReactNativeClient/lib/components/screens/status.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import { ListView, View, Text, Button } from 'react-native'; +import { Setting } from 'lib/models/setting.js'; import { connect } from 'react-redux' import { Log } from 'lib/log.js' import { reg } from 'lib/registry.js' @@ -7,6 +8,7 @@ import { ScreenHeader } from 'lib/components/screen-header.js'; import { time } from 'lib/time-utils' import { Logger } from 'lib/logger.js'; import { BaseItem } from 'lib/models/base-item.js'; +import { Database } from 'lib/database.js'; import { Folder } from 'lib/models/folder.js'; import { ReportService } from 'lib/services/report.js'; import { _ } from 'lib/locale.js'; @@ -31,7 +33,7 @@ class StatusScreenComponent extends BaseScreenComponent { async resfreshScreen() { let service = new ReportService(); - let report = await service.status(); + let report = await service.status(Database.enumId('syncTarget', Setting.value('sync.target'))); this.setState({ report: report }); } diff --git a/ReactNativeClient/lib/database.js b/ReactNativeClient/lib/database.js index acee77920..bdb6e00cd 100644 --- a/ReactNativeClient/lib/database.js +++ b/ReactNativeClient/lib/database.js @@ -151,7 +151,7 @@ class Database { } if (type == 'syncTarget') { if (s == 'memory') return 1; - if (s == 'file') return 2; + if (s == 'filesystem') return 2; if (s == 'onedrive') return 3; } throw new Error('Unknown enum type or value: ' + type + ', ' + s); @@ -160,7 +160,7 @@ class Database { static enumName(type, id) { if (type == 'syncTarget') { if (id === 1) return 'memory'; - if (id === 2) return 'file'; + if (id === 2) return 'filesystem'; if (id === 3) return 'onedrive'; } throw new Error('Unknown enum type or id: ' + type + ', ' + id); diff --git a/ReactNativeClient/lib/file-api-driver-local.js b/ReactNativeClient/lib/file-api-driver-local.js index ceb488a08..a1531a2b2 100644 --- a/ReactNativeClient/lib/file-api-driver-local.js +++ b/ReactNativeClient/lib/file-api-driver-local.js @@ -10,7 +10,7 @@ class FileApiDriverLocal { } syncTargetName() { - return 'file'; + return 'filesystem'; } fsErrorToJsError_(error) { diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js index 4db351972..1e54c8e94 100644 --- a/ReactNativeClient/lib/joplin-database.js +++ b/ReactNativeClient/lib/joplin-database.js @@ -192,6 +192,17 @@ class JoplinDatabase extends Database { for (let initLoopCount = 1; initLoopCount <= 2; initLoopCount++) { try { + // await this.exec('DROP TABLE folders'); + // await this.exec('DROP TABLE notes'); + // await this.exec('DROP TABLE deleted_items'); + // await this.exec('DROP TABLE tags'); + // await this.exec('DROP TABLE note_tags'); + // await this.exec('DROP TABLE resources'); + // await this.exec('DROP TABLE settings'); + // await this.exec('DROP TABLE table_fields'); + // await this.exec('DROP TABLE version'); + // await this.exec('DROP TABLE sync_items'); + let row = await this.selectOne('SELECT * FROM version LIMIT 1'); this.logger().info('Current database version', row); diff --git a/ReactNativeClient/lib/models/base-item.js b/ReactNativeClient/lib/models/base-item.js index 6619a1cb8..d39629340 100644 --- a/ReactNativeClient/lib/models/base-item.js +++ b/ReactNativeClient/lib/models/base-item.js @@ -32,14 +32,14 @@ class BaseItem extends BaseModel { throw new Error('Invalid class name: ' + name); } - static async syncedCount() { - // TODO - return 0; - // const ItemClass = this.itemClass(this.modelType()); - // let sql = 'SELECT count(*) as total FROM `' + ItemClass.tableName() + '` WHERE updated_time <= sync_time'; - // if (this.modelType() == BaseModel.TYPE_NOTE) sql += ' AND is_conflict = 0'; - // const r = await this.db().selectOne(sql); - // return r.total; + static async syncedCount(syncTarget) { + const ItemClass = this.itemClass(this.modelType()); + const itemType = ItemClass.modelType(); + // The fact that we don't check if the item_id still exist in the corresponding item table, means + // that the returned number might be innaccurate (for example if a sync operation was cancelled) + const sql = 'SELECT count(*) as total FROM sync_items WHERE sync_target = ? AND item_type = ?'; + const r = await this.db().selectOne(sql, [ syncTarget, itemType ]); + return r.total; } static systemPath(itemOrId) { @@ -279,23 +279,6 @@ class BaseItem extends BaseModel { } throw new Error('Unreachable'); - - //return this.modelSelectAll('SELECT * FROM folders WHERE sync_time < updated_time LIMIT ' + limit); - - // let items = await this.getClass('Folder').modelSelectAll('SELECT * FROM folders WHERE sync_time < updated_time LIMIT ' + limit); - // if (items.length) return { hasMore: true, items: items }; - - // items = await this.getClass('Resource').modelSelectAll('SELECT * FROM resources WHERE sync_time < updated_time LIMIT ' + limit); - // if (items.length) return { hasMore: true, items: items }; - - // items = await this.getClass('Note').modelSelectAll('SELECT * FROM notes WHERE sync_time < updated_time AND is_conflict = 0 LIMIT ' + limit); - // if (items.length) return { hasMore: true, items: items }; - - // items = await this.getClass('Tag').modelSelectAll('SELECT * FROM tags WHERE sync_time < updated_time LIMIT ' + limit); - // if (items.length) return { hasMore: true, items: items }; - - // items = await this.getClass('NoteTag').modelSelectAll('SELECT * FROM note_tags WHERE sync_time < updated_time LIMIT ' + limit); - // return { hasMore: items.length >= limit, items: items }; } static syncItemClassNames() { @@ -341,7 +324,10 @@ class BaseItem extends BaseModel { const className = classNames[i]; const ItemClass = this.getClass(className); - queries.push('DELETE FROM sync_items WHERE item_type = ' + ItemClass.modelType() + ' AND item_id NOT IN (SELECT id FROM ' + ItemClass.tableName() + ')'); + let selectSql = 'SELECT id FROM ' + ItemClass.tableName(); + if (ItemClass.modelType() == this.TYPE_NOTE) selectSql += ' WHERE is_conflict = 0'; + + queries.push('DELETE FROM sync_items WHERE item_type = ' + ItemClass.modelType() + ' AND item_id NOT IN (' + selectSql + ')'); } await this.db().transactionExecBatch(queries); diff --git a/ReactNativeClient/lib/models/setting.js b/ReactNativeClient/lib/models/setting.js index 47c52cc5c..70e1fecad 100644 --- a/ReactNativeClient/lib/models/setting.js +++ b/ReactNativeClient/lib/models/setting.js @@ -146,7 +146,7 @@ class Setting extends BaseModel { Setting.defaults_ = { 'activeFolderId': { value: '', type: 'string', public: false }, 'sync.onedrive.auth': { value: '', type: 'string', public: false }, - 'sync.local.path': { value: '', type: 'string', public: true }, + 'sync.filesystem.path': { value: '', type: 'string', public: true }, 'sync.target': { value: 'onedrive', type: 'string', public: true }, 'editor': { value: '', type: 'string', public: true }, }; diff --git a/ReactNativeClient/lib/services/report.js b/ReactNativeClient/lib/services/report.js index c65482c83..41af3b9d1 100644 --- a/ReactNativeClient/lib/services/report.js +++ b/ReactNativeClient/lib/services/report.js @@ -6,7 +6,7 @@ import { _ } from 'lib/locale.js'; class ReportService { - async syncStatus() { + async syncStatus(syncTarget) { let output = { items: {}, total: {}, @@ -19,8 +19,7 @@ class ReportService { let ItemClass = BaseItem.getClass(d.className); let o = { total: await ItemClass.count(), - // synced: await ItemClass.syncedCount(), // TODO - synced: 0, + synced: await ItemClass.syncedCount(syncTarget), }; output.items[d.className] = o; itemCount += o.total; @@ -47,8 +46,8 @@ class ReportService { return output; } - async status() { - let r = await this.syncStatus(); + async status(syncTarget) { + let r = await this.syncStatus(syncTarget); let sections = []; let section = {}; diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 7d3144468..419b62335 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -68,6 +68,17 @@ function historyCanGoBackTo(route) { return true; } +function reducerActionsAreSame(a1, a2) { + if (Object.getOwnPropertyNames(a1).length !== Object.getOwnPropertyNames(a2).length) return false; + + for (let n in a1) { + if (!a1.hasOwnProperty(n)) continue; + if (a1[n] !== a2[n]) return false; + } + + return true; +} + const reducer = (state = defaultState, action) => { reg.logger().info('Reducer action', action.type); @@ -116,10 +127,16 @@ const reducer = (state = defaultState, action) => { newState.selectedItemType = action.itemType; } + newState.route = action; + + // If the route *name* is the same (even if the other parameters are different), we + // overwrite the last route in the history with the current one. If the route name + // is different, we push a new history entry. + if (currentRouteName == action.routeName) { + if (navHistory.length) navHistory[navHistory.length - 1] = action; // If the current screen is already the requested screen, don't do anything } else { - newState.route = action; if (action.routeName == 'Welcome') navHistory = []; navHistory.push(action); } @@ -151,20 +168,27 @@ const reducer = (state = defaultState, action) => { // update it within the note array if it already exists. case 'NOTES_UPDATE_ONE': - if (action.note.parent_id != state.selectedFolderId) break; + const modNote = action.note; let newNotes = state.notes.splice(0); var found = false; for (let i = 0; i < newNotes.length; i++) { let n = newNotes[i]; - if (n.id == action.note.id) { - newNotes[i] = Object.assign(newNotes[i], action.note); + if (n.id == modNote.id) { + + if (!('parent_id' in modNote) || modNote.parent_id == n.parent_id) { + // Merge the properties that have changed (in modNote) into + // the object we already have. + newNotes[i] = Object.assign(newNotes[i], action.note); + } else { + newNotes.splice(i, 1); + } found = true; break; } } - if (!found) newNotes.push(action.note); + if (!found && ('parent_id' in modNote) && modNote.parent_id == state.selectedFolderId) newNotes.push(modNote); newNotes = Note.sortNotes(newNotes, state.notesOrder); newState = Object.assign({}, state);