1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +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 { class BaseModel {
static ITEM_TYPE_NOTE = 1;
static ITEM_TYPE_FOLDER = 2;
static tableInfo_ = null;
static tableKeys_ = null;
static tableName() { static tableName() {
throw new Error('Must be overriden'); throw new Error('Must be overriden');
} }
@@ -13,6 +18,14 @@ class BaseModel {
return false; return false;
} }
static itemType() {
throw new Error('Must be overriden');
}
static trackChanges() {
return false;
}
static byId(items, id) { static byId(items, id) {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return items[i]; if (items[i].id == id) return items[i];
@@ -20,12 +33,31 @@ class BaseModel {
return null; return null;
} }
static save(o) { static fieldNames() {
let isNew = !o.id; 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 query = '';
let itemId = o.id;
if (isNew) { 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); query = Database.insertQuery(this.tableName(), o);
} else { } else {
let where = { id: o.id }; let where = { id: o.id };
@@ -34,7 +66,44 @@ class BaseModel {
query = Database.updateQuery(this.tableName(), temp, where); 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) { 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) => { deleteFolder_onPress = (folderId) => {
Folder.delete(folderId).then(() => { Folder.delete(folderId).then(() => {
this.props.dispatch({ this.props.dispatch({
@@ -77,10 +66,6 @@ class NotesScreenComponent extends React.Component {
<View style={{flex: 1}}> <View style={{flex: 1}}>
<ScreenHeader title={title} navState={this.props.navigation.state} menuOptions={this.menuOptions()} /> <ScreenHeader title={title} navState={this.props.navigation.state} menuOptions={this.menuOptions()} />
<NoteList style={{flex: 1}}/> <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> <ActionButton parentFolderId={this.props.selectedFolderId}></ActionButton>
</View> </View>
); );

View File

@@ -1,6 +1,7 @@
import SQLite from 'react-native-sqlite-storage'; import SQLite from 'react-native-sqlite-storage';
import { Log } from 'src/log.js'; import { Log } from 'src/log.js';
import { uuid } from 'src/uuid.js'; import { uuid } from 'src/uuid.js';
import { PromiseChain } from 'src/promise-chain.js';
const structureSql = ` const structureSql = `
CREATE TABLE folders ( CREATE TABLE folders (
@@ -65,7 +66,7 @@ CREATE TABLE version (
); );
CREATE TABLE changes ( CREATE TABLE changes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
\`type\` INT, \`type\` INT,
item_id TEXT, item_id TEXT,
item_type INT, item_type INT,
@@ -73,11 +74,17 @@ CREATE TABLE changes (
); );
CREATE TABLE settings ( CREATE TABLE settings (
\`key\` TEXT PRIMARY KEY, \`key\` TEXT PRIMARY KEY,
\`value\` TEXT, \`value\` TEXT,
\`type\` INT \`type\` INT
); );
CREATE TABLE table_fields (
id INTEGER PRIMARY KEY,
table_name TEXT,
field_name TEXT
);
INSERT INTO version (version) VALUES (1); INSERT INTO version (version) VALUES (1);
`; `;
@@ -86,6 +93,7 @@ class Database {
constructor() { constructor() {
this.debugMode_ = false; this.debugMode_ = false;
this.initialized_ = false; this.initialized_ = false;
this.tableFields_ = null;
} }
setDebugEnabled(v) { setDebugEnabled(v) {
@@ -102,13 +110,13 @@ class Database {
} }
open() { 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'); Log.info('Database was open successfully');
}, (error) => { }, (error) => {
Log.error('Cannot open database: ', error); Log.error('Cannot open database: ', error);
}); });
return this.updateSchema(); return this.initialize();
} }
static enumToId(type, s) { static enumToId(type, s) {
@@ -119,6 +127,12 @@ class Database {
throw new Error('Unknown enum type or value: ' + type + ', ' + s); 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) { sqlStringToLines(sql) {
let output = []; let output = [];
let lines = sql.split("\n"); let lines = sql.split("\n");
@@ -138,7 +152,7 @@ class Database {
logQuery(sql, params = null) { logQuery(sql, params = null) {
if (!this.debugMode()) return; if (!this.debugMode()) return;
Log.debug('DB: ' + sql, params); //Log.debug('DB: ' + sql, params);
} }
selectOne(sql, params = null) { selectOne(sql, params = null) {
@@ -220,36 +234,121 @@ class Database {
}); });
} }
updateSchema() { refreshTableFields() {
Log.info('Checking for database schema update...'); 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;
return new Promise((resolve, reject) => { chain.push((queries) => {
this.selectOne('SELECT * FROM version LIMIT 1').then((row) => { if (!queries) queries = [];
Log.info('Current database version', row); return this.exec('PRAGMA table_info("' + tableName + '")').then((pragmaResult) => {
resolve(); for (let i = 0; i < pragmaResult.rows.length; i++) {
// TODO: version update logic let q = Database.insertQuery('table_fields', {
}).catch((error) => { table_name: tableName,
// Assume that error was: field_name: pragmaResult.rows.item(i).name,
// { 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. queries.push(q);
}
return queries;
});
});
}
Log.info('Database is new - creating the schema...'); return PromiseChain.exec(chain).then((queries) => {
return this.transaction((tx) => {
let statements = this.sqlStringToLines(structureSql) tx.executeSql('DELETE FROM table_fields');
this.transaction((tx) => { for (let i = 0; i < queries.length; i++) {
for (let i = 0; i < statements.length; i++) { tx.executeSql(queries[i].sql, queries[i].params);
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);
}); });
}); });
}); });
} }
initialize() {
Log.info('Checking for database schema update...');
return 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.
// 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)
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(() => {
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);
// });
// });
// });
}
} }
export { Database }; export { Database };

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; return true;
} }
static itemType() {
return BaseModel.ITEM_TYPE_FOLDER;
}
static trackChanges() {
return true;
}
static newFolder() { static newFolder() {
return { return {
id: null, id: null,

View File

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

View File

@@ -7,9 +7,9 @@ class Setting extends BaseModel {
static defaults_ = { static defaults_ = {
'clientId': { value: '', type: 'string' }, 'clientId': { value: '', type: 'string' },
'sessionId': { value: '', type: 'string' }, 'sessionId': { value: '', type: 'string' },
'lastUpdateTime': { value: '', type: 'int' },
'user.email': { value: '', type: 'string' }, 'user.email': { value: '', type: 'string' },
'user.session': { value: '', type: 'string' }, 'user.session': { value: '', type: 'string' },
'sync.lastRevId': { value: 0, type: 'int' },
}; };
static tableName() { 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 { Log } from 'src/log.js'
import { Note } from 'src/models/note.js' import { Note } from 'src/models/note.js'
import { Folder } from 'src/models/folder.js' import { Folder } from 'src/models/folder.js'
import { BaseModel } from 'src/base-model.js'
import { Database } from 'src/database.js' import { Database } from 'src/database.js'
import { Registry } from 'src/registry.js' import { Registry } from 'src/registry.js'
import { ItemList } from 'src/components/item-list.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 { FoldersScreen } from 'src/components/screens/folders.js'
import { LoginScreen } from 'src/components/screens/login.js' import { LoginScreen } from 'src/components/screens/login.js'
import { Setting } from 'src/models/setting.js' import { Setting } from 'src/models/setting.js'
import { Synchronizer } from 'src/synchronizer.js'
import { MenuContext } from 'react-native-popup-menu'; import { MenuContext } from 'react-native-popup-menu';
let defaultState = { let defaultState = {
@@ -163,6 +165,8 @@ class AppComponent extends React.Component {
let db = new Database(); let db = new Database();
db.setDebugEnabled(Registry.debugMode()); db.setDebugEnabled(Registry.debugMode());
BaseModel.dispatch = this.props.dispatch;
db.open().then(() => { db.open().then(() => {
Log.info('Database is ready.'); Log.info('Database is ready.');
Registry.setDb(db); Registry.setDb(db);
@@ -174,6 +178,8 @@ class AppComponent extends React.Component {
Log.info('Client ID', Setting.value('clientId')); Log.info('Client ID', Setting.value('clientId'));
Log.info('User', user); Log.info('User', user);
Registry.api().setSession(user.session);
this.props.dispatch({ this.props.dispatch({
type: 'USER_SET', type: 'USER_SET',
user: user, user: user,
@@ -189,8 +195,11 @@ class AppComponent extends React.Component {
}).catch((error) => { }).catch((error) => {
Log.warn('Cannot load folders', error); Log.warn('Cannot load folders', error);
}); });
}).then(() => {
let synchronizer = new Synchronizer();
synchronizer.start();
}).catch((error) => { }).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) { constructor(baseUrl) {
this.baseUrl_ = baseUrl; this.baseUrl_ = baseUrl;
this.session_ = null;
}
setSession(v) {
this.session_ = v;
}
session() {
return this.session_;
} }
makeRequest(method, path, query, data) { makeRequest(method, path, query, data) {
@@ -38,14 +47,18 @@ class WebApi {
if (o.method != 'GET' && o.method != 'DELETE') { if (o.method != 'GET' && o.method != 'DELETE') {
cmd.push("--data '" + stringify(data) + "'"); cmd.push("--data '" + stringify(data) + "'");
} }
cmd.push(r.url); cmd.push("'" + r.url + "'");
return cmd.join(' '); return cmd.join(' ');
} }
exec(method, path, query, data) { exec(method, path, query, data) {
let that = this; return new Promise((resolve, reject) => {
return new Promise(function(resolve, reject) { if (this.session_) {
let r = that.makeRequest(method, path, query, data); 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)); Log.debug(WebApi.toCurl(r, data));

View File

@@ -26,7 +26,7 @@ function config($name) {
'baseUrl' => $baseUrl, 'baseUrl' => $baseUrl,
'clientId' => 'E3E3E3E3E3E3E3E3E3E3E3E3E3E3E3E3', 'clientId' => 'E3E3E3E3E3E3E3E3E3E3E3E3E3E3E3E3',
'email' => 'laurent@cozic.net', 'email' => 'laurent@cozic.net',
'password' => '123456789', 'password' => '12345678',
); );
if (isset($config[$name])) return $config[$name]; if (isset($config[$name])) return $config[$name];
throw new Exception('Unknown 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 ($this->isVersioned) {
if (count($changedFields)) { if (count($changedFields)) {
$this->recordChanges($isNew ? 'create' : 'update', $changedFields); $this->trackChanges($isNew ? 'create' : 'update', $changedFields);
} }
$this->changedDiffableFields = array(); $this->changedDiffableFields = array();
} }
@@ -395,13 +395,13 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
$output = parent::delete(); $output = parent::delete();
if (count($this->isVersioned)) { if (count($this->isVersioned)) {
$this->recordChanges('delete'); $this->trackChanges('delete');
} }
return $output; return $output;
} }
protected function recordChanges($type, $changedFields = array()) { protected function trackChanges($type, $changedFields = array()) {
if ($type == 'delete') { if ($type == 'delete') {
$change = $this->newChange($type); $change = $this->newChange($type);
$change->save(); $change->save();

View File

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