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

Started synchronizer

This commit is contained in:
Laurent Cozic 2017-05-18 19:58:01 +00:00
parent 0b77715a93
commit db4b6cf0d6
14 changed files with 369 additions and 56 deletions

View File

@ -5,6 +5,11 @@ import { uuid } from 'src/uuid.js';
class BaseModel {
static ITEM_TYPE_NOTE = 1;
static ITEM_TYPE_FOLDER = 2;
static tableInfo_ = null;
static tableKeys_ = null;
static tableName() {
throw new Error('Must be overriden');
}
@ -13,6 +18,14 @@ class BaseModel {
return false;
}
static itemType() {
throw new Error('Must be overriden');
}
static trackChanges() {
return false;
}
static byId(items, id) {
for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return items[i];
@ -20,12 +33,31 @@ class BaseModel {
return null;
}
static save(o) {
let isNew = !o.id;
static fieldNames() {
return this.db().tableFieldNames(this.tableName());
}
static fromApiResult(apiResult) {
let fieldNames = this.fieldNames();
let output = {};
for (let i = 0; i < fieldNames.length; i++) {
let f = fieldNames[i];
output[f] = f in apiResult ? apiResult[f] : null;
}
return output;
}
static saveQuery(o, isNew = 'auto') {
if (isNew == 'auto') isNew = !o.id;
let query = '';
let itemId = o.id;
if (isNew) {
if (this.useUuid()) o.id = uuid.create();
if (this.useUuid()) {
o = Object.assign({}, o);
itemId = uuid.create();
o.id = itemId;
}
query = Database.insertQuery(this.tableName(), o);
} else {
let where = { id: o.id };
@ -34,7 +66,44 @@ class BaseModel {
query = Database.updateQuery(this.tableName(), temp, where);
}
return this.db().exec(query.sql, query.params).then(() => { return o; });
query.id = itemId;
return query;
}
static save(o, trackChanges = true, isNew = 'auto') {
if (isNew == 'auto') isNew = !o.id;
let query = this.saveQuery(o, isNew);
return this.db().transaction((tx) => {
tx.executeSql(query.sql, query.params);
if (trackChanges && this.trackChanges()) {
// Cannot import this class the normal way due to cyclical dependencies between Change and BaseModel
// which are not handled by React Native.
const { Change } = require('src/models/change.js');
let change = Change.newChange();
change.type = isNew ? Change.TYPE_CREATE : Change.TYPE_UPDATE;
change.item_id = query.id;
change.item_type = this.itemType();
let changeQuery = Change.saveQuery(change);
tx.executeSql(changeQuery.sql, changeQuery.params);
// TODO: item field for UPDATE
}
}).then(() => {
o = Object.assign({}, o);
o.id = query.id;
this.dispatch({
type: 'FOLDERS_UPDATE_ONE',
folder: o,
});
return o;
});
}
static delete(id) {

View File

@ -29,17 +29,6 @@ class NotesScreenComponent extends React.Component {
});
}
loginButton_press = () => {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Login',
});
}
syncButton_press = () => {
Log.info('SYNC');
}
deleteFolder_onPress = (folderId) => {
Folder.delete(folderId).then(() => {
this.props.dispatch({
@ -77,10 +66,6 @@ class NotesScreenComponent extends React.Component {
<View style={{flex: 1}}>
<ScreenHeader title={title} navState={this.props.navigation.state} menuOptions={this.menuOptions()} />
<NoteList style={{flex: 1}}/>
<View style={{flexDirection: 'row'}}>
<Button title="Login" onPress={this.loginButton_press} />
<Button title="Sync" onPress={this.syncButton_press} />
</View>
<ActionButton parentFolderId={this.props.selectedFolderId}></ActionButton>
</View>
);

View File

@ -1,6 +1,7 @@
import SQLite from 'react-native-sqlite-storage';
import { Log } from 'src/log.js';
import { uuid } from 'src/uuid.js';
import { PromiseChain } from 'src/promise-chain.js';
const structureSql = `
CREATE TABLE folders (
@ -78,6 +79,12 @@ CREATE TABLE settings (
\`type\` INT
);
CREATE TABLE table_fields (
id INTEGER PRIMARY KEY,
table_name TEXT,
field_name TEXT
);
INSERT INTO version (version) VALUES (1);
`;
@ -86,6 +93,7 @@ class Database {
constructor() {
this.debugMode_ = false;
this.initialized_ = false;
this.tableFields_ = null;
}
setDebugEnabled(v) {
@ -102,13 +110,13 @@ class Database {
}
open() {
this.db_ = SQLite.openDatabase({ name: '/storage/emulated/0/Download/joplin-7.sqlite' }, (db) => {
this.db_ = SQLite.openDatabase({ name: '/storage/emulated/0/Download/joplin-10.sqlite' }, (db) => {
Log.info('Database was open successfully');
}, (error) => {
Log.error('Cannot open database: ', error);
});
return this.updateSchema();
return this.initialize();
}
static enumToId(type, s) {
@ -119,6 +127,12 @@ class Database {
throw new Error('Unknown enum type or value: ' + type + ', ' + s);
}
tableFieldNames(tableName) {
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
if (!this.tableFields_[tableName]) throw new Error('Unknown table: ' + tableName);
return this.tableFields_[tableName];
}
sqlStringToLines(sql) {
let output = [];
let lines = sql.split("\n");
@ -138,7 +152,7 @@ class Database {
logQuery(sql, params = null) {
if (!this.debugMode()) return;
Log.debug('DB: ' + sql, params);
//Log.debug('DB: ' + sql, params);
}
selectOne(sql, params = null) {
@ -220,34 +234,119 @@ class Database {
});
}
updateSchema() {
refreshTableFields() {
return this.exec('SELECT name FROM sqlite_master WHERE type="table"').then((tableResults) => {
let chain = [];
for (let i = 0; i < tableResults.rows.length; i++) {
let row = tableResults.rows.item(i);
let tableName = row.name;
if (tableName == 'android_metadata') continue;
if (tableName == 'table_fields') continue;
chain.push((queries) => {
if (!queries) queries = [];
return this.exec('PRAGMA table_info("' + tableName + '")').then((pragmaResult) => {
for (let i = 0; i < pragmaResult.rows.length; i++) {
let q = Database.insertQuery('table_fields', {
table_name: tableName,
field_name: pragmaResult.rows.item(i).name,
});
queries.push(q);
}
return queries;
});
});
}
return PromiseChain.exec(chain).then((queries) => {
return this.transaction((tx) => {
tx.executeSql('DELETE FROM table_fields');
for (let i = 0; i < queries.length; i++) {
tx.executeSql(queries[i].sql, queries[i].params);
}
});
});
});
}
initialize() {
Log.info('Checking for database schema update...');
return new Promise((resolve, reject) => {
this.selectOne('SELECT * FROM version LIMIT 1').then((row) => {
return this.selectOne('SELECT * FROM version LIMIT 1').then((row) => {
Log.info('Current database version', row);
resolve();
// TODO: version update logic
// TODO: only do this if db has been updated:
return this.refreshTableFields();
}).then(() => {
return this.exec('SELECT * FROM table_fields').then((r) => {
this.tableFields_ = {};
for (let i = 0; i < r.rows.length; i++) {
let row = r.rows.item(i);
if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = [];
this.tableFields_[row.table_name].push(row.field_name);
}
});
}).catch((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.
Log.info('Database is new - creating the schema...');
let statements = this.sqlStringToLines(structureSql)
this.transaction((tx) => {
return this.transaction((tx) => {
for (let i = 0; i < statements.length; i++) {
tx.executeSql(statements[i]);
}
tx.executeSql('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumToId('settings', 'string') + '")');
}).then(() => {
resolve('Database schema created successfully');
}).catch((error) => {
reject(error);
});
});
Log.info('Database schema created successfully');
// Calling initialize() now that the db has been created will make it go through
// the normal db update process (applying any additional patch).
return this.initialize();
})
});
// return new Promise((resolve, reject) => {
// this.selectOne('SELECT * FROM version LIMIT 1').then((row) => {
// Log.info('Current database version', row);
// // TODO: version update logic
// // TODO: only do this if db has been updated
// return this.refreshTableFields();
// }).then(() => {
// return this.exec('SELECT * FROM table_fields').then((r) => {
// this.tableFields_ = {};
// for (let i = 0; i < r.rows.length; i++) {
// let row = r.rows.item(i);
// if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = [];
// this.tableFields_[row.table_name].push(row.field_name);
// }
// });
// }).catch((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.
// Log.info('Database is new - creating the schema...');
// let statements = this.sqlStringToLines(structureSql)
// this.transaction((tx) => {
// for (let i = 0; i < statements.length; i++) {
// tx.executeSql(statements[i]);
// }
// tx.executeSql('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumToId('settings', 'string') + '")');
// }).then(() => {
// Log.info('Database schema created successfully');
// // Calling initialize() now that the db has been created will make it go through
// // the normal db update process (applying any additional patch).
// return this.initialize();
// }).catch((error) => {
// reject(error);
// });
// });
// });
}
}

View File

@ -0,0 +1,37 @@
import { BaseModel } from 'src/base-model.js';
import { Log } from 'src/log.js';
class Change extends BaseModel {
static TYPE_UNKNOWN = 0;
static TYPE_CREATE = 1;
static TYPE_UPDATE = 2;
static TYPE_DELETE = 3;
static tableName() {
return 'changes';
}
static newChange() {
return {
id: null,
type: null,
item_id: null,
item_type: null,
item_field: null,
};
}
// static all() {
// return this.db().selectAll('SELECT * FROM folders').then((r) => {
// let output = [];
// for (let i = 0; i < r.rows.length; i++) {
// output.push(r.rows.item(i));
// }
// return output;
// });
// }
}
export { Change };

View File

@ -11,6 +11,14 @@ class Folder extends BaseModel {
return true;
}
static itemType() {
return BaseModel.ITEM_TYPE_FOLDER;
}
static trackChanges() {
return true;
}
static newFolder() {
return {
id: null,

View File

@ -11,6 +11,14 @@ class Note extends BaseModel {
return true;
}
static itemType() {
return BaseModel.ITEM_TYPE_NOTE;
}
static trackChanges() {
return true;
}
static newNote(parentId = null) {
return {
id: null,

View File

@ -7,9 +7,9 @@ class Setting extends BaseModel {
static defaults_ = {
'clientId': { value: '', type: 'string' },
'sessionId': { value: '', type: 'string' },
'lastUpdateTime': { value: '', type: 'int' },
'user.email': { value: '', type: 'string' },
'user.session': { value: '', type: 'string' },
'sync.lastRevId': { value: 0, type: 'int' },
};
static tableName() {

View File

@ -0,0 +1,14 @@
class PromiseChain {
static exec(chain) {
let output = new Promise((resolve, reject) => { resolve(); });
for (let i = 0; i < chain.length; i++) {
let f = chain[i];
output = output.then(f);
}
return output;
}
}
export { PromiseChain };

View File

@ -9,6 +9,7 @@ import { addNavigationHelpers } from 'react-navigation';
import { Log } from 'src/log.js'
import { Note } from 'src/models/note.js'
import { Folder } from 'src/models/folder.js'
import { BaseModel } from 'src/base-model.js'
import { Database } from 'src/database.js'
import { Registry } from 'src/registry.js'
import { ItemList } from 'src/components/item-list.js'
@ -18,6 +19,7 @@ import { FolderScreen } from 'src/components/screens/folder.js'
import { FoldersScreen } from 'src/components/screens/folders.js'
import { LoginScreen } from 'src/components/screens/login.js'
import { Setting } from 'src/models/setting.js'
import { Synchronizer } from 'src/synchronizer.js'
import { MenuContext } from 'react-native-popup-menu';
let defaultState = {
@ -163,6 +165,8 @@ class AppComponent extends React.Component {
let db = new Database();
db.setDebugEnabled(Registry.debugMode());
BaseModel.dispatch = this.props.dispatch;
db.open().then(() => {
Log.info('Database is ready.');
Registry.setDb(db);
@ -174,6 +178,8 @@ class AppComponent extends React.Component {
Log.info('Client ID', Setting.value('clientId'));
Log.info('User', user);
Registry.api().setSession(user.session);
this.props.dispatch({
type: 'USER_SET',
user: user,
@ -189,8 +195,11 @@ class AppComponent extends React.Component {
}).catch((error) => {
Log.warn('Cannot load folders', error);
});
}).then(() => {
let synchronizer = new Synchronizer();
synchronizer.start();
}).catch((error) => {
Log.error('Cannot initialize database:', error);
Log.error('Initialization error:', error);
});
}

View File

@ -0,0 +1,70 @@
import { Registry } from 'src/registry.js';
import { Log } from 'src/log.js';
import { Setting } from 'src/models/setting.js';
import { Change } from 'src/models/change.js';
import { Folder } from 'src/models/folder.js';
class Synchronizer {
constructor() {
this.state_ = 'idle';
}
state() {
return this.state_;
}
db() {
return Registry.db();
}
api() {
return Registry.api();
}
switchState(state) {
Log.info('Sync: switching state to: ' + state);
if (state == 'downloadChanges') {
this.api().get('synchronizer', { last_id: Setting.value('sync.lastRevId') }).then((syncOperations) => {
let promise = new Promise((resolve, reject) => { resolve(); });
for (let i = 0; i < syncOperations.items.length; i++) {
let syncOp = syncOperations.items[i];
if (syncOp.item_type == 'folder') {
if (syncOp.type == 'create') {
promise = promise.then(() => {
let folder = Folder.fromApiResult(syncOp.item);
// TODO: automatically handle NULL fields by checking type and default value of field
if (!folder.parent_id) folder.parent_id = '';
return Folder.save(folder, true, true);
});
}
}
}
promise.then(() => {
Log.info('All items synced.');
}).catch((error) => {
Log.warn('Sync error', error);
});
});
} else {
}
}
start() {
if (this.state() != 'idle') {
Log.info("Sync: cannot start synchronizer because synchronization already in progress. State: " + this.state());
return;
}
Log.info('Sync: start');
this.switchState('downloadChanges');
}
}
export { Synchronizer };

View File

@ -5,6 +5,15 @@ class WebApi {
constructor(baseUrl) {
this.baseUrl_ = baseUrl;
this.session_ = null;
}
setSession(v) {
this.session_ = v;
}
session() {
return this.session_;
}
makeRequest(method, path, query, data) {
@ -38,14 +47,18 @@ class WebApi {
if (o.method != 'GET' && o.method != 'DELETE') {
cmd.push("--data '" + stringify(data) + "'");
}
cmd.push(r.url);
cmd.push("'" + r.url + "'");
return cmd.join(' ');
}
exec(method, path, query, data) {
let that = this;
return new Promise(function(resolve, reject) {
let r = that.makeRequest(method, path, query, data);
return new Promise((resolve, reject) => {
if (this.session_) {
query = query ? Object.assign({}, query) : {};
if (!query.session) query.session = this.session_;
}
let r = this.makeRequest(method, path, query, data);
Log.debug(WebApi.toCurl(r, data));

View File

@ -26,7 +26,7 @@ function config($name) {
'baseUrl' => $baseUrl,
'clientId' => 'E3E3E3E3E3E3E3E3E3E3E3E3E3E3E3E3',
'email' => 'laurent@cozic.net',
'password' => '123456789',
'password' => '12345678',
);
if (isset($config[$name])) return $config[$name];
throw new Exception('Unknown config: ' . $name);

View File

@ -381,7 +381,7 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
if ($this->isVersioned) {
if (count($changedFields)) {
$this->recordChanges($isNew ? 'create' : 'update', $changedFields);
$this->trackChanges($isNew ? 'create' : 'update', $changedFields);
}
$this->changedDiffableFields = array();
}
@ -395,13 +395,13 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
$output = parent::delete();
if (count($this->isVersioned)) {
$this->recordChanges('delete');
$this->trackChanges('delete');
}
return $output;
}
protected function recordChanges($type, $changedFields = array()) {
protected function trackChanges($type, $changedFields = array()) {
if ($type == 'delete') {
$change = $this->newChange($type);
$change->save();

View File

@ -46,6 +46,7 @@ class Change extends BaseModel {
$itemIdToChange[$change->item_id] = $change;
}
$output = array();
foreach ($itemIdToChange as $itemId => $change) {
if (in_array($itemId, $createdItems) && in_array($itemId, $deletedItems)) {