You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-29 22:48:10 +02:00
Started fixing ReactNative app
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../lib
|
||||
292
ReactNativeClient/lib/base-model.js
Normal file
292
ReactNativeClient/lib/base-model.js
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Log } from 'lib/log.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { uuid } from 'lib/uuid.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
|
||||
class BaseModel {
|
||||
|
||||
static modelType() {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
|
||||
static tableName() {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
|
||||
static addModelMd(model) {
|
||||
if (!model) return model;
|
||||
|
||||
if (Array.isArray(model)) {
|
||||
let output = [];
|
||||
for (let i = 0; i < model.length; i++) {
|
||||
output.push(this.addModelMd(model[i]));
|
||||
}
|
||||
return output;
|
||||
} else {
|
||||
model = Object.assign({}, model);
|
||||
model.type_ = this.modelType();
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
static logger() {
|
||||
return this.db().logger();
|
||||
}
|
||||
|
||||
static useUuid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static byId(items, id) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].id == id) return items[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static hasField(name) {
|
||||
let fields = this.fieldNames();
|
||||
return fields.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
static fieldNames() {
|
||||
return this.db().tableFieldNames(this.tableName());
|
||||
}
|
||||
|
||||
static fieldType(name) {
|
||||
let fields = this.fields();
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (fields[i].name == name) return fields[i].type;
|
||||
}
|
||||
throw new Error('Unknown field: ' + name);
|
||||
}
|
||||
|
||||
static fields() {
|
||||
return this.db().tableFields(this.tableName());
|
||||
}
|
||||
|
||||
static new() {
|
||||
let fields = this.fields();
|
||||
let output = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let f = fields[i];
|
||||
output[f.name] = f.default;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static modOptions(options) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
} else {
|
||||
options = Object.assign({}, options);
|
||||
}
|
||||
if (!('isNew' in options)) options.isNew = 'auto';
|
||||
if (!('autoTimestamp' in options)) options.autoTimestamp = true;
|
||||
return options;
|
||||
}
|
||||
|
||||
static count() {
|
||||
return this.db().selectOne('SELECT count(*) as total FROM `' + this.tableName() + '`').then((r) => {
|
||||
return r ? r['total'] : 0;
|
||||
});
|
||||
}
|
||||
|
||||
static load(id) {
|
||||
return this.loadByField('id', id);
|
||||
}
|
||||
|
||||
static applySqlOptions(options, sql, params = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
if (options.orderBy) {
|
||||
sql += ' ORDER BY ' + options.orderBy;
|
||||
if (options.caseInsensitive === true) sql += ' COLLATE NOCASE';
|
||||
if (options.orderByDir) sql += ' ' + options.orderByDir;
|
||||
}
|
||||
if (options.limit) sql += ' LIMIT ' + options.limit;
|
||||
|
||||
return { sql: sql, params: params };
|
||||
}
|
||||
|
||||
static async all(options = null) {
|
||||
let q = this.applySqlOptions(options, 'SELECT * FROM `' + this.tableName() + '`');
|
||||
return this.modelSelectAll(q.sql);
|
||||
}
|
||||
|
||||
static async search(options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.fields) options.fields = '*';
|
||||
|
||||
let conditions = options.conditions ? options.conditions.slice(0) : [];
|
||||
let params = options.conditionsParams ? options.conditionsParams.slice(0) : [];
|
||||
|
||||
if (options.titlePattern) {
|
||||
let pattern = options.titlePattern.replace(/\*/g, '%');
|
||||
conditions.push('title LIKE ?');
|
||||
params.push(pattern);
|
||||
}
|
||||
|
||||
let sql = 'SELECT ' + this.db().escapeFields(options.fields) + ' FROM `' + this.tableName() + '`';
|
||||
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
|
||||
|
||||
let query = this.applySqlOptions(options, sql, params);
|
||||
return this.modelSelectAll(query.sql, query.params);
|
||||
}
|
||||
|
||||
static modelSelectOne(sql, params = null) {
|
||||
if (params === null) params = [];
|
||||
return this.db().selectOne(sql, params).then((model) => {
|
||||
return this.filter(this.addModelMd(model));
|
||||
});
|
||||
}
|
||||
|
||||
static modelSelectAll(sql, params = null) {
|
||||
if (params === null) params = [];
|
||||
return this.db().selectAll(sql, params).then((models) => {
|
||||
return this.filterArray(this.addModelMd(models));
|
||||
});
|
||||
}
|
||||
|
||||
static loadByField(fieldName, fieldValue) {
|
||||
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `' + fieldName + '` = ?', [fieldValue]);
|
||||
}
|
||||
|
||||
static loadByTitle(fieldValue) {
|
||||
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `title` = ?', [fieldValue]);
|
||||
}
|
||||
|
||||
static diffObjects(oldModel, newModel) {
|
||||
let output = {};
|
||||
let type = null;
|
||||
for (let n in newModel) {
|
||||
if (n == 'type_') {
|
||||
type = n;
|
||||
continue;
|
||||
}
|
||||
if (!newModel.hasOwnProperty(n)) continue;
|
||||
if (!(n in oldModel) || newModel[n] !== oldModel[n]) {
|
||||
output[n] = newModel[n];
|
||||
}
|
||||
}
|
||||
if (type !== null) output.type_ = type;
|
||||
return output;
|
||||
}
|
||||
|
||||
static saveQuery(o, options) {
|
||||
let temp = {}
|
||||
let fieldNames = this.fieldNames();
|
||||
for (let i = 0; i < fieldNames.length; i++) {
|
||||
let n = fieldNames[i];
|
||||
if (n in o) temp[n] = o[n];
|
||||
}
|
||||
o = temp;
|
||||
|
||||
let query = {};
|
||||
let modelId = o.id;
|
||||
|
||||
if (options.autoTimestamp && this.hasField('updated_time')) {
|
||||
o.updated_time = time.unixMs();
|
||||
}
|
||||
|
||||
if (options.isNew) {
|
||||
if (this.useUuid() && !o.id) {
|
||||
modelId = uuid.create();
|
||||
o.id = modelId;
|
||||
}
|
||||
|
||||
if (!o.created_time && this.hasField('created_time')) {
|
||||
o.created_time = time.unixMs();
|
||||
}
|
||||
|
||||
query = Database.insertQuery(this.tableName(), o);
|
||||
} else {
|
||||
let where = { id: o.id };
|
||||
let temp = Object.assign({}, o);
|
||||
delete temp.id;
|
||||
query = Database.updateQuery(this.tableName(), temp, where);
|
||||
}
|
||||
|
||||
query.id = modelId;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
static save(o, options = null) {
|
||||
options = this.modOptions(options);
|
||||
options.isNew = this.isNew(o, options);
|
||||
|
||||
o = this.filter(o);
|
||||
|
||||
let queries = [];
|
||||
let saveQuery = this.saveQuery(o, options);
|
||||
let modelId = saveQuery.id;
|
||||
|
||||
queries.push(saveQuery);
|
||||
|
||||
return this.db().transactionExecBatch(queries).then(() => {
|
||||
o = Object.assign({}, o);
|
||||
o.id = modelId;
|
||||
o = this.addModelMd(o);
|
||||
return this.filter(o);
|
||||
}).catch((error) => {
|
||||
Log.error('Cannot save model', error);
|
||||
});
|
||||
}
|
||||
|
||||
static isNew(object, options) {
|
||||
if (options && ('isNew' in options)) {
|
||||
// options.isNew can be "auto" too
|
||||
if (options.isNew === true) return true;
|
||||
if (options.isNew === false) return false;
|
||||
}
|
||||
|
||||
return !object.id;
|
||||
}
|
||||
|
||||
static filterArray(models) {
|
||||
let output = [];
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
output.push(this.filter(models[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static filter(model) {
|
||||
if (!model) return model;
|
||||
|
||||
let output = Object.assign({}, model);
|
||||
for (let n in output) {
|
||||
if (!output.hasOwnProperty(n)) continue;
|
||||
// The SQLite database doesn't have booleans so cast everything to int
|
||||
if (output[n] === true) output[n] = 1;
|
||||
if (output[n] === false) output[n] = 0;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static delete(id, options = null) {
|
||||
options = this.modOptions(options);
|
||||
if (!id) throw new Error('Cannot delete object without an ID');
|
||||
return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
static db() {
|
||||
if (!this.db_) throw new Error('Accessing database before it has been initialised');
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BaseModel.TYPE_NOTE = 1;
|
||||
BaseModel.TYPE_FOLDER = 2;
|
||||
BaseModel.TYPE_SETTING = 3;
|
||||
BaseModel.TYPE_RESOURCE = 4;
|
||||
BaseModel.TYPE_TAG = 5;
|
||||
BaseModel.TYPE_NOTE_TAG = 6;
|
||||
|
||||
BaseModel.db_ = null;
|
||||
BaseModel.dispatch = function(o) {};
|
||||
|
||||
export { BaseModel };
|
||||
73
ReactNativeClient/lib/components/action-button.js
Normal file
73
ReactNativeClient/lib/components/action-button.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { Component } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import ReactNativeActionButton from 'react-native-action-button';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
actionButtonIcon: {
|
||||
fontSize: 20,
|
||||
height: 22,
|
||||
color: 'white',
|
||||
},
|
||||
});
|
||||
|
||||
class ActionButtonComponent extends React.Component {
|
||||
|
||||
newTodo_press() {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Note',
|
||||
noteId: null,
|
||||
folderId: this.props.parentFolderId,
|
||||
itemType: 'todo',
|
||||
});
|
||||
}
|
||||
|
||||
newNote_press() {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Note',
|
||||
noteId: null,
|
||||
folderId: this.props.parentFolderId,
|
||||
itemType: 'note',
|
||||
});
|
||||
}
|
||||
|
||||
newFolder_press() {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Folder',
|
||||
folderId: null,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReactNativeActionButton buttonColor="rgba(231,76,60,1)">
|
||||
|
||||
<ReactNativeActionButton.Item buttonColor='#9b59b6' title="New todo" onPress={() => { this.newTodo_press() }}>
|
||||
<Icon name="md-checkbox-outline" style={styles.actionButtonIcon} />
|
||||
</ReactNativeActionButton.Item>
|
||||
|
||||
<ReactNativeActionButton.Item buttonColor='#9b59b6' title="New note" onPress={() => { this.newNote_press() }}>
|
||||
<Icon name="md-document" style={styles.actionButtonIcon} />
|
||||
</ReactNativeActionButton.Item>
|
||||
|
||||
<ReactNativeActionButton.Item buttonColor='#3498db' title="New folder" onPress={() => { this.newFolder_press() }}>
|
||||
<Icon name="md-folder" style={styles.actionButtonIcon} />
|
||||
</ReactNativeActionButton.Item>
|
||||
|
||||
</ReactNativeActionButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ActionButton = connect(
|
||||
(state) => {
|
||||
return {};
|
||||
}
|
||||
)(ActionButtonComponent)
|
||||
|
||||
export { ActionButton };
|
||||
44
ReactNativeClient/lib/components/checkbox.js
Normal file
44
ReactNativeClient/lib/components/checkbox.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { Component } from 'react';
|
||||
import { StyleSheet, TouchableHighlight } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
checkboxIcon: {
|
||||
fontSize: 20,
|
||||
height: 22,
|
||||
marginRight: 10,
|
||||
},
|
||||
});
|
||||
|
||||
class Checkbox extends Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.state = { checked: this.props.checked };
|
||||
}
|
||||
|
||||
onPress() {
|
||||
let newChecked = !this.state.checked;
|
||||
this.setState({ checked: newChecked });
|
||||
if (this.props.onChange) this.props.onChange(newChecked);
|
||||
}
|
||||
|
||||
render() {
|
||||
const iconName = this.state.checked ? 'md-checkbox-outline' : 'md-square-outline';
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress={() => this.onPress()} style={{justifyContent: 'center', alignItems: 'center'}}>
|
||||
<Icon name={iconName} style={styles.checkboxIcon}/>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
27
ReactNativeClient/lib/components/folder-list.js
Normal file
27
ReactNativeClient/lib/components/folder-list.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { ListView, Text, TouchableHighlight } from 'react-native';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { ItemListComponent } from 'lib/components/item-list.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { NoteFolderService } from 'lib/services/note-folder-service.js';
|
||||
|
||||
class FolderListComponent extends ItemListComponent {
|
||||
|
||||
listView_itemPress(folderId) {
|
||||
NoteFolderService.openNoteList(folderId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const FolderList = connect(
|
||||
(state) => {
|
||||
return {
|
||||
items: state.folders,
|
||||
};
|
||||
}
|
||||
)(FolderListComponent)
|
||||
|
||||
export { FolderList };
|
||||
77
ReactNativeClient/lib/components/item-list.js
Normal file
77
ReactNativeClient/lib/components/item-list.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { ListView, Text, TouchableHighlight, Switch, View } from 'react-native';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { Checkbox } from 'lib/components/checkbox.js';
|
||||
import { NoteFolderService } from 'lib/services/note-folder-service.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
|
||||
class ItemListComponent extends Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const ds = new ListView.DataSource({
|
||||
rowHasChanged: (r1, r2) => { return r1 !== r2; }
|
||||
});
|
||||
this.state = {
|
||||
dataSource: ds,
|
||||
items: [],
|
||||
selectedItemIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const newDataSource = this.state.dataSource.cloneWithRows(this.props.items);
|
||||
this.state = { dataSource: newDataSource };
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
// https://stackoverflow.com/questions/38186114/react-native-redux-and-listview
|
||||
this.setState({
|
||||
dataSource: this.state.dataSource.cloneWithRows(newProps.items),
|
||||
});
|
||||
}
|
||||
|
||||
todoCheckbox_change(itemId, checked) {
|
||||
NoteFolderService.setField('note', itemId, 'todo_completed', checked);
|
||||
|
||||
// Note.load(itemId).then((oldNote) => {
|
||||
// let newNote = Object.assign({}, oldNote);
|
||||
// newNote.todo_completed = checked;
|
||||
// return NoteFolderService.save('note', newNote, oldNote);
|
||||
// });
|
||||
}
|
||||
|
||||
listView_itemPress(itemId) {}
|
||||
|
||||
render() {
|
||||
let renderRow = (item) => {
|
||||
let onPress = () => {
|
||||
this.listView_itemPress(item.id);
|
||||
}
|
||||
let onLongPress = () => {
|
||||
this.listView_itemLongPress(item.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress={onPress} onLongPress={onLongPress}>
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
{ !!Number(item.is_todo) && <Checkbox checked={!!Number(item.todo_completed)} onChange={(checked) => { this.todoCheckbox_change(item.id, checked) }}/> }<Text>{item.title} [{item.id}]</Text>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
|
||||
return (
|
||||
<ListView
|
||||
dataSource={this.state.dataSource}
|
||||
renderRow={renderRow}
|
||||
enableEmptySections={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { ItemListComponent };
|
||||
26
ReactNativeClient/lib/components/note-list.js
Normal file
26
ReactNativeClient/lib/components/note-list.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { ListView, Text, TouchableHighlight } from 'react-native';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { ItemListComponent } from 'lib/components/item-list.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
|
||||
class NoteListComponent extends ItemListComponent {
|
||||
|
||||
listView_itemPress(noteId) {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Note',
|
||||
noteId: noteId,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const NoteList = connect(
|
||||
(state) => {
|
||||
return { items: state.notes };
|
||||
}
|
||||
)(NoteListComponent)
|
||||
|
||||
export { NoteList };
|
||||
120
ReactNativeClient/lib/components/screen-header.js
Normal file
120
ReactNativeClient/lib/components/screen-header.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { View, Text, Button, StyleSheet } from 'react-native';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
divider: {
|
||||
marginVertical: 5,
|
||||
marginHorizontal: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: '#ccc'
|
||||
},
|
||||
});
|
||||
|
||||
class ScreenHeaderComponent extends Component {
|
||||
|
||||
showBackButton() {
|
||||
// Note: this is hardcoded for now because navigation.state doesn't tell whether
|
||||
// it's possible to go back or not. Maybe it's possible to get this information
|
||||
// from somewhere else.
|
||||
return this.props.navState.routeName != 'Notes';
|
||||
}
|
||||
|
||||
sideMenuButton_press() {
|
||||
this.props.dispatch({ type: 'SIDE_MENU_TOGGLE' });
|
||||
}
|
||||
|
||||
backButton_press() {
|
||||
this.props.dispatch({ type: 'Navigation/BACK' });
|
||||
}
|
||||
|
||||
menu_select(value) {
|
||||
if (typeof(value) == 'function') {
|
||||
value();
|
||||
}
|
||||
}
|
||||
|
||||
menu_login() {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Login',
|
||||
});
|
||||
}
|
||||
|
||||
menu_logout() {
|
||||
let user = { email: null, session: null };
|
||||
Setting.setObject('user', user);
|
||||
this.props.dispatch({
|
||||
type: 'USER_SET',
|
||||
user: user,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let key = 0;
|
||||
let menuOptionComponents = [];
|
||||
for (let i = 0; i < this.props.menuOptions.length; i++) {
|
||||
let o = this.props.menuOptions[i];
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={o.onPress} key={'menuOption_' + key++}>
|
||||
<Text>{o.title}</Text>
|
||||
</MenuOption>);
|
||||
}
|
||||
|
||||
if (menuOptionComponents.length) {
|
||||
menuOptionComponents.push(<View key={'menuOption_' + key++} style={styles.divider}/>);
|
||||
}
|
||||
|
||||
if (this.props.user && this.props.user.session) {
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.menu_logout()} key={'menuOption_' + key++}>
|
||||
<Text>{_('Logout')}</Text>
|
||||
</MenuOption>);
|
||||
} else {
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.menu_login()} key={'menuOption_' + key++}>
|
||||
<Text>{_('Login')}</Text>
|
||||
</MenuOption>);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={1} key={'menuOption_' + key++}>
|
||||
<Text>{_('Configuration')}</Text>
|
||||
</MenuOption>);
|
||||
|
||||
let title = 'title' in this.props && this.props.title !== null ? this.props.title : _(this.props.navState.routeName);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', padding: 10, backgroundColor: '#ffffff', alignItems: 'center' }} >
|
||||
<Button title="☰" onPress={() => this.sideMenuButton_press()} />
|
||||
<Button disabled={!this.showBackButton()} title="<" onPress={() => this.backButton_press()}></Button>
|
||||
<Text style={{ flex:1, marginLeft: 10 }} >{title}</Text>
|
||||
<Menu onSelect={(value) => this.menu_select(value)}>
|
||||
<MenuTrigger>
|
||||
<Text style={{ fontSize: 20 }}> ⋮ </Text>
|
||||
</MenuTrigger>
|
||||
<MenuOptions>
|
||||
{ menuOptionComponents }
|
||||
</MenuOptions>
|
||||
</Menu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ScreenHeaderComponent.defaultProps = {
|
||||
menuOptions: [],
|
||||
};
|
||||
|
||||
const ScreenHeader = connect(
|
||||
(state) => {
|
||||
return { user: state.user };
|
||||
}
|
||||
)(ScreenHeaderComponent)
|
||||
|
||||
export { ScreenHeader };
|
||||
78
ReactNativeClient/lib/components/screens/folder.js
Normal file
78
ReactNativeClient/lib/components/screens/folder.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { Folder } from 'lib/models/folder.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { NoteFolderService } from 'lib/services/note-folder-service.js';
|
||||
|
||||
class FolderScreenComponent extends React.Component {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { folder: Folder.new() };
|
||||
this.originalFolder = null;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
if (!this.props.folderId) {
|
||||
this.setState({ folder: Folder.new() });
|
||||
} else {
|
||||
Folder.load(this.props.folderId).then((folder) => {
|
||||
this.originalFolder = Object.assign({}, folder);
|
||||
this.setState({ folder: folder });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
folderComponent_change(propName, propValue) {
|
||||
this.setState((prevState, props) => {
|
||||
let folder = Object.assign({}, prevState.folder);
|
||||
folder[propName] = propValue;
|
||||
return { folder: folder }
|
||||
});
|
||||
}
|
||||
|
||||
title_changeText(text) {
|
||||
this.folderComponent_change('title', text);
|
||||
}
|
||||
|
||||
saveFolderButton_press() {
|
||||
console.warn('CHANGE NOT TESTED');
|
||||
let toSave = BaseModel.diffObjects(this.originalFolder, this.state.folder);
|
||||
toSave.id = this.state.folder.id;
|
||||
Folder.save(toSave).then((folder) => {
|
||||
this.originalFolder = Object.assign({}, folder);
|
||||
this.setState({ folder: folder });
|
||||
});
|
||||
// NoteFolderService.save('folder', this.state.folder, this.originalFolder).then((folder) => {
|
||||
// this.originalFolder = Object.assign({}, folder);
|
||||
// this.setState({ folder: folder });
|
||||
// });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<ScreenHeader navState={this.props.navigation.state} />
|
||||
<TextInput value={this.state.folder.title} onChangeText={(text) => this.title_changeText(text)} />
|
||||
<Button title="Save folder" onPress={() => this.saveFolderButton_press()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const FolderScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folderId: state.selectedFolderId,
|
||||
};
|
||||
}
|
||||
)(FolderScreenComponent)
|
||||
|
||||
export { FolderScreen };
|
||||
35
ReactNativeClient/lib/components/screens/folders.js
Normal file
35
ReactNativeClient/lib/components/screens/folders.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, Picker, Text, StyleSheet } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { FolderList } from 'lib/components/folder-list.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { ActionButton } from 'lib/components/action-button.js';
|
||||
|
||||
class FoldersScreenComponent extends React.Component {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<ScreenHeader navState={this.props.navigation.state} />
|
||||
<FolderList style={{flex: 1}}/>
|
||||
<ActionButton></ActionButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const FoldersScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
};
|
||||
}
|
||||
)(FoldersScreenComponent)
|
||||
|
||||
export { FoldersScreen };
|
||||
31
ReactNativeClient/lib/components/screens/loading.js
Normal file
31
ReactNativeClient/lib/components/screens/loading.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { Folder } from 'lib/models/folder.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { NoteFolderService } from 'lib/services/note-folder-service.js';
|
||||
|
||||
class LoadingScreenComponent extends React.Component {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<Text>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const LoadingScreen = connect(
|
||||
(state) => {
|
||||
return {};
|
||||
}
|
||||
)(LoadingScreenComponent)
|
||||
|
||||
export { LoadingScreen };
|
||||
91
ReactNativeClient/lib/components/screens/login.js
Normal file
91
ReactNativeClient/lib/components/screens/login.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput, Text } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
|
||||
class LoginScreenComponent extends React.Component {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
email: '',
|
||||
password: '',
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.setState({ email: this.props.user.email });
|
||||
}
|
||||
|
||||
email_changeText(text) {
|
||||
this.setState({ email: text });
|
||||
}
|
||||
|
||||
password_changeText(text) {
|
||||
this.setState({ password: text });
|
||||
}
|
||||
|
||||
loginButton_press() {
|
||||
this.setState({ errorMessage: null });
|
||||
|
||||
// return Registry.api().post('sessions', null, {
|
||||
// 'email': this.state.email,
|
||||
// 'password': this.state.password,
|
||||
// 'client_id': Setting.value('clientId'),
|
||||
// }).then((session) => {
|
||||
// Log.info('Got session', session);
|
||||
|
||||
// let user = {
|
||||
// email: this.state.email,
|
||||
// session: session.id,
|
||||
// };
|
||||
// Setting.setObject('user', user);
|
||||
|
||||
// this.props.dispatch({
|
||||
// type: 'USER_SET',
|
||||
// user: user,
|
||||
// });
|
||||
|
||||
// this.props.dispatch({
|
||||
// type: 'Navigation/BACK',
|
||||
// });
|
||||
|
||||
// Registry.api().setSession(session.id);
|
||||
|
||||
// //Registry.synchronizer().start();
|
||||
// }).catch((error) => {
|
||||
// this.setState({ errorMessage: _('Could not login: %s)', error.message) });
|
||||
// });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<ScreenHeader navState={this.props.navigation.state} />
|
||||
<TextInput value={this.state.email} onChangeText={(text) => this.email_changeText(text)} keyboardType="email-address" />
|
||||
<TextInput value={this.state.password} onChangeText={(text) => this.password_changeText(text)} secureTextEntry={true} />
|
||||
{ this.state.errorMessage && <Text style={{color:'#ff0000'}}>{this.state.errorMessage}</Text> }
|
||||
<Button title="Login" onPress={() => this.loginButton_press()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const LoginScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
user: state.user,
|
||||
};
|
||||
}
|
||||
)(LoginScreenComponent)
|
||||
|
||||
export { LoginScreen };
|
||||
123
ReactNativeClient/lib/components/screens/note.js
Normal file
123
ReactNativeClient/lib/components/screens/note.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { Note } from 'lib/models/note.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { Checkbox } from 'lib/components/checkbox.js'
|
||||
import { NoteFolderService } from 'lib/services/note-folder-service.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
|
||||
class NoteScreenComponent extends React.Component {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { note: Note.new() }
|
||||
this.originalNote = null;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
if (!this.props.noteId) {
|
||||
let note = this.props.itemType == 'todo' ? Note.newTodo(this.props.folderId) : Note.new(this.props.folderId);
|
||||
Log.info(note);
|
||||
this.setState({ note: note });
|
||||
} else {
|
||||
Note.load(this.props.noteId).then((note) => {
|
||||
this.originalNote = Object.assign({}, note);
|
||||
this.setState({ note: note });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
noteComponent_change(propName, propValue) {
|
||||
this.setState((prevState, props) => {
|
||||
let note = Object.assign({}, prevState.note);
|
||||
note[propName] = propValue;
|
||||
return { note: note }
|
||||
});
|
||||
}
|
||||
|
||||
title_changeText(text) {
|
||||
this.noteComponent_change('title', text);
|
||||
}
|
||||
|
||||
body_changeText(text) {
|
||||
this.noteComponent_change('body', text);
|
||||
}
|
||||
|
||||
saveNoteButton_press() {
|
||||
|
||||
console.warn('CHANGE NOT TESTED');
|
||||
|
||||
let isNew = !this.state.note.id;
|
||||
let toSave = BaseModel.diffObjects(this.originalNote, this.state.note);
|
||||
toSave.id = this.state.note.id;
|
||||
Note.save(toSave).then((note) => {
|
||||
this.originalNote = Object.assign({}, note);
|
||||
this.setState({ note: note });
|
||||
if (isNew) return Note.updateGeolocation(note.id);
|
||||
});
|
||||
|
||||
// NoteFolderService.save('note', this.state.note, this.originalNote).then((note) => {
|
||||
// this.originalNote = Object.assign({}, note);
|
||||
// this.setState({ note: note });
|
||||
// });
|
||||
}
|
||||
|
||||
deleteNote_onPress(noteId) {
|
||||
Log.info('DELETE', noteId);
|
||||
}
|
||||
|
||||
attachFile_onPress(noteId) {
|
||||
}
|
||||
|
||||
menuOptions() {
|
||||
return [
|
||||
{ title: _('Attach file'), onPress: () => { this.attachFile_onPress(this.state.note.id); } },
|
||||
{ title: _('Delete note'), onPress: () => { this.deleteNote_onPress(this.state.note.id); } },
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
const note = this.state.note;
|
||||
const isTodo = !!Number(note.is_todo);
|
||||
let todoComponents = null;
|
||||
|
||||
if (note.is_todo) {
|
||||
todoComponents = (
|
||||
<View>
|
||||
<Button title="test" onPress={this.saveNoteButton_press} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<ScreenHeader navState={this.props.navigation.state} menuOptions={this.menuOptions()} />
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{ isTodo && <Checkbox checked={!!Number(note.todo_completed)} /> }<TextInput style={{flex:1}} value={note.title} onChangeText={(text) => this.title_changeText(text)} />
|
||||
</View>
|
||||
<TextInput style={{flex: 1, textAlignVertical: 'top'}} multiline={true} value={note.body} onChangeText={(text) => this.body_changeText(text)} />
|
||||
{ todoComponents }
|
||||
<Button title="Save note" onPress={() => this.saveNoteButton_press()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const NoteScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
noteId: state.selectedNoteId,
|
||||
folderId: state.selectedFolderId,
|
||||
itemType: state.selectedItemType,
|
||||
};
|
||||
}
|
||||
)(NoteScreenComponent)
|
||||
|
||||
export { NoteScreen };
|
||||
68
ReactNativeClient/lib/components/screens/notes.js
Normal file
68
ReactNativeClient/lib/components/screens/notes.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, Picker } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { NoteList } from 'lib/components/note-list.js'
|
||||
import { Folder } from 'lib/models/folder.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { MenuOption, Text } from 'react-native-popup-menu';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { ActionButton } from 'lib/components/action-button.js';
|
||||
|
||||
class NotesScreenComponent extends React.Component {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
deleteFolder_onPress(folderId) {
|
||||
Folder.delete(folderId).then(() => {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Folders',
|
||||
});
|
||||
}).catch((error) => {
|
||||
alert(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
editFolder_onPress(folderId) {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Folder',
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
menuOptions() {
|
||||
return [
|
||||
{ title: _('Delete folder'), onPress: () => { this.deleteFolder_onPress(this.props.selectedFolderId); } },
|
||||
{ title: _('Edit folder'), onPress: () => { this.editFolder_onPress(this.props.selectedFolderId); } },
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
let folder = Folder.byId(this.props.folders, this.props.selectedFolderId);
|
||||
let title = folder ? folder.title : null;
|
||||
|
||||
const { navigate } = this.props.navigation;
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<ScreenHeader title={title} navState={this.props.navigation.state} menuOptions={this.menuOptions()} />
|
||||
<NoteList style={{flex: 1}}/>
|
||||
<ActionButton parentFolderId={this.props.selectedFolderId}></ActionButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const NotesScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
};
|
||||
}
|
||||
)(NotesScreenComponent)
|
||||
|
||||
export { NotesScreen };
|
||||
78
ReactNativeClient/lib/components/side-menu-content.js
Normal file
78
ReactNativeClient/lib/components/side-menu-content.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { connect } from 'react-redux'
|
||||
import { Button } from 'react-native';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { NoteFolderService } from 'lib/services/note-folder-service.js';
|
||||
|
||||
const React = require('react');
|
||||
const {
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
View,
|
||||
Image,
|
||||
Text,
|
||||
} = require('react-native');
|
||||
const { Component } = React;
|
||||
|
||||
const window = Dimensions.get('window');
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
menu: {
|
||||
flex: 1,
|
||||
backgroundColor: 'white',
|
||||
padding: 20,
|
||||
},
|
||||
name: {
|
||||
position: 'absolute',
|
||||
left: 70,
|
||||
top: 20,
|
||||
},
|
||||
item: {
|
||||
fontSize: 14,
|
||||
fontWeight: '300',
|
||||
paddingTop: 5,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
}
|
||||
});
|
||||
|
||||
class SideMenuContentComponent extends Component {
|
||||
|
||||
folder_press(folder) {
|
||||
this.props.dispatch({
|
||||
type: 'SIDE_MENU_CLOSE',
|
||||
});
|
||||
|
||||
NoteFolderService.openNoteList(folder.id);
|
||||
}
|
||||
|
||||
render() {
|
||||
let buttons = [];
|
||||
for (let i = 0; i < this.props.folders.length; i++) {
|
||||
let f = this.props.folders[i];
|
||||
let title = f.title;
|
||||
buttons.push(
|
||||
<Button style={styles.button} title={title} onPress={() => { this.folder_press(f) }} key={f.id} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView scrollsToTop={false} style={styles.menu}>
|
||||
{ buttons }
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SideMenuContent = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
};
|
||||
}
|
||||
)(SideMenuContentComponent)
|
||||
|
||||
export { SideMenuContent };
|
||||
16
ReactNativeClient/lib/components/side-menu.js
Normal file
16
ReactNativeClient/lib/components/side-menu.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js';
|
||||
import SideMenu_ from 'react-native-side-menu';
|
||||
|
||||
class SideMenuComponent extends SideMenu_ {};
|
||||
|
||||
const SideMenu = connect(
|
||||
(state) => {
|
||||
return {
|
||||
isOpen: state.showSideMenu,
|
||||
};
|
||||
}
|
||||
)(SideMenuComponent)
|
||||
|
||||
export { SideMenu };
|
||||
63
ReactNativeClient/lib/database-driver-node.js
Normal file
63
ReactNativeClient/lib/database-driver-node.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const Promise = require('promise');
|
||||
|
||||
class DatabaseDriverNode {
|
||||
|
||||
open(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_ = new sqlite3.Database(options.name, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setDebugMode(v) {
|
||||
// ??
|
||||
}
|
||||
|
||||
selectOne(sql, params = null) {
|
||||
if (!params) params = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectAll(sql, params = null) {
|
||||
if (!params) params = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.all(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exec(sql, params = null) {
|
||||
if (!params) params = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.run(sql, params, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { DatabaseDriverNode };
|
||||
52
ReactNativeClient/lib/database-driver-react-native.js
Normal file
52
ReactNativeClient/lib/database-driver-react-native.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import SQLite from 'react-native-sqlite-storage';
|
||||
|
||||
class DatabaseDriverReactNative {
|
||||
|
||||
open(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
SQLite.openDatabase({ name: options.name }, (db) => {
|
||||
this.db_ = db;
|
||||
resolve();
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setDebugMode(v) {
|
||||
//SQLite.DEBUG(v);
|
||||
}
|
||||
|
||||
selectOne(sql, params = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.executeSql(sql, params, (r) => {
|
||||
resolve(r.rows.length ? r.rows.item(0) : null);
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectAll(sql, params = null) {
|
||||
return this.exec(sql, params).then((r) => {
|
||||
let output = []
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
output.push(r.rows.item(i));
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
exec(sql, params = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.executeSql(sql, params, (r) => {
|
||||
resolve(r);
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { DatabaseDriverReactNative }
|
||||
475
ReactNativeClient/lib/database.js
Normal file
475
ReactNativeClient/lib/database.js
Normal file
@@ -0,0 +1,475 @@
|
||||
import { uuid } from 'lib/uuid.js';
|
||||
import { promiseChain } from 'lib/promise-utils.js';
|
||||
import { Logger } from 'lib/logger.js'
|
||||
import { time } from 'lib/time-utils.js'
|
||||
import { _ } from 'lib/locale.js'
|
||||
import { sprintf } from 'sprintf-js';
|
||||
|
||||
const structureSql = `
|
||||
CREATE TABLE folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT NOT NULL DEFAULT "",
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL,
|
||||
sync_time INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX folders_title ON folders (title);
|
||||
CREATE INDEX folders_updated_time ON folders (updated_time);
|
||||
CREATE INDEX folders_sync_time ON folders (sync_time);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT NOT NULL DEFAULT "",
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
body TEXT NOT NULL DEFAULT "",
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL,
|
||||
sync_time INT NOT NULL DEFAULT 0,
|
||||
is_conflict INT NOT NULL DEFAULT 0,
|
||||
latitude NUMERIC NOT NULL DEFAULT 0,
|
||||
longitude NUMERIC NOT NULL DEFAULT 0,
|
||||
altitude NUMERIC NOT NULL DEFAULT 0,
|
||||
author TEXT NOT NULL DEFAULT "",
|
||||
source_url TEXT NOT NULL DEFAULT "",
|
||||
is_todo INT NOT NULL DEFAULT 0,
|
||||
todo_due INT NOT NULL DEFAULT 0,
|
||||
todo_completed INT NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT "",
|
||||
source_application TEXT NOT NULL DEFAULT "",
|
||||
application_data TEXT NOT NULL DEFAULT "",
|
||||
\`order\` INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX notes_title ON notes (title);
|
||||
CREATE INDEX notes_updated_time ON notes (updated_time);
|
||||
CREATE INDEX notes_sync_time ON notes (sync_time);
|
||||
CREATE INDEX notes_is_conflict ON notes (is_conflict);
|
||||
CREATE INDEX notes_is_todo ON notes (is_todo);
|
||||
CREATE INDEX notes_order ON notes (\`order\`);
|
||||
|
||||
CREATE TABLE deleted_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
item_type INT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
deleted_time INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL,
|
||||
sync_time INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE note_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL,
|
||||
sync_time INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE resources (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
mime TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL,
|
||||
sync_time INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
\`key\` TEXT PRIMARY KEY,
|
||||
\`value\` TEXT,
|
||||
\`type\` INT
|
||||
);
|
||||
|
||||
CREATE TABLE table_fields (
|
||||
id INTEGER PRIMARY KEY,
|
||||
table_name TEXT,
|
||||
field_name TEXT,
|
||||
field_type INT,
|
||||
field_default TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE version (
|
||||
version INT
|
||||
);
|
||||
|
||||
INSERT INTO version (version) VALUES (1);
|
||||
`;
|
||||
|
||||
class Database {
|
||||
|
||||
constructor(driver) {
|
||||
this.debugMode_ = false;
|
||||
this.initialized_ = false;
|
||||
this.tableFields_ = null;
|
||||
this.driver_ = driver;
|
||||
this.inTransaction_ = false;
|
||||
|
||||
this.logger_ = new Logger();
|
||||
this.logger_.addTarget('console');
|
||||
this.logger_.setLevel(Logger.LEVEL_DEBUG);
|
||||
}
|
||||
|
||||
// Converts the SQLite error to a regular JS error
|
||||
// so that it prints a stacktrace when passed to
|
||||
// console.error()
|
||||
sqliteErrorToJsError(error, sql = null, params = null) {
|
||||
let msg = [error.toString()];
|
||||
if (sql) msg.push(sql);
|
||||
if (params) msg.push(params);
|
||||
let output = new Error(msg.join(': '));
|
||||
if (error.code) output.code = error.code;
|
||||
return output;
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
initialized() {
|
||||
return this.initialized_;
|
||||
}
|
||||
|
||||
driver() {
|
||||
return this.driver_;
|
||||
}
|
||||
|
||||
async open(options) {
|
||||
await this.driver().open(options);
|
||||
this.logger().info('Database was open successfully');
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
escapeField(field) {
|
||||
if (field == '*') return '*';
|
||||
return '`' + field + '`';
|
||||
}
|
||||
|
||||
escapeFields(fields) {
|
||||
if (fields == '*') return '*';
|
||||
|
||||
let output = [];
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
output.push(this.escapeField(fields[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async tryCall(callName, sql, params) {
|
||||
if (typeof sql === 'object') {
|
||||
params = sql.params;
|
||||
sql = sql.sql;
|
||||
}
|
||||
|
||||
let waitTime = 50;
|
||||
let totalWaitTime = 0;
|
||||
while (true) {
|
||||
try {
|
||||
this.logQuery(sql, params);
|
||||
let result = await this.driver()[callName](sql, params);
|
||||
return result; // No exception was thrown
|
||||
} catch (error) {
|
||||
if (error && (error.code == 'SQLITE_IOERR' || error.code == 'SQLITE_BUSY')) {
|
||||
if (totalWaitTime >= 20000) throw this.sqliteErrorToJsError(error, sql, params);
|
||||
this.logger().warn(sprintf('Error %s: will retry in %s milliseconds', error.code, waitTime));
|
||||
this.logger().warn('Error was: ' + error.toString());
|
||||
await time.msleep(waitTime);
|
||||
totalWaitTime += waitTime;
|
||||
waitTime *= 1.5;
|
||||
} else {
|
||||
throw this.sqliteErrorToJsError(error, sql, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selectOne(sql, params = null) {
|
||||
return this.tryCall('selectOne', sql, params);
|
||||
}
|
||||
|
||||
async selectAll(sql, params = null) {
|
||||
return this.tryCall('selectAll', sql, params);
|
||||
}
|
||||
|
||||
async exec(sql, params = null) {
|
||||
return this.tryCall('exec', sql, params);
|
||||
}
|
||||
|
||||
transactionExecBatch(queries) {
|
||||
if (queries.length <= 0) return Promise.resolve();
|
||||
|
||||
if (queries.length == 1) {
|
||||
let q = this.wrapQuery(queries[0]);
|
||||
return this.exec(q.sql, q.params);
|
||||
}
|
||||
|
||||
// There can be only one transaction running at a time so queue
|
||||
// any new transaction here.
|
||||
if (this.inTransaction_) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let iid = setInterval(() => {
|
||||
if (!this.inTransaction_) {
|
||||
clearInterval(iid);
|
||||
this.transactionExecBatch(queries).then(() => {
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
this.inTransaction_ = true;
|
||||
|
||||
queries.splice(0, 0, 'BEGIN TRANSACTION');
|
||||
queries.push('COMMIT'); // Note: ROLLBACK is currently not supported
|
||||
|
||||
let chain = [];
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
let query = this.wrapQuery(queries[i]);
|
||||
chain.push(() => {
|
||||
return this.exec(query.sql, query.params);
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain).then(() => {
|
||||
this.inTransaction_ = false;
|
||||
});
|
||||
}
|
||||
|
||||
static enumId(type, s) {
|
||||
if (type == 'settings') {
|
||||
if (s == 'int') return 1;
|
||||
if (s == 'string') return 2;
|
||||
}
|
||||
if (type == 'fieldType') {
|
||||
return this['TYPE_' + s];
|
||||
}
|
||||
throw new Error('Unknown enum type or value: ' + type + ', ' + s);
|
||||
}
|
||||
|
||||
tableFieldNames(tableName) {
|
||||
let tf = this.tableFields(tableName);
|
||||
let output = [];
|
||||
for (let i = 0; i < tf.length; i++) {
|
||||
output.push(tf[i].name);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
tableFields(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];
|
||||
}
|
||||
|
||||
static formatValue(type, value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (type == this.TYPE_INT) return Number(value);
|
||||
if (type == this.TYPE_TEXT) return value;
|
||||
if (type == this.TYPE_NUMERIC) return Number(value);
|
||||
throw new Error('Unknown type: ' + type);
|
||||
}
|
||||
|
||||
sqlStringToLines(sql) {
|
||||
let output = [];
|
||||
let lines = sql.split("\n");
|
||||
let statement = '';
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line == '') continue;
|
||||
if (line.substr(0, 2) == "--") continue;
|
||||
statement += line;
|
||||
if (line[line.length - 1] == ';') {
|
||||
output.push(statement);
|
||||
statement = '';
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
logQuery(sql, params = null) {
|
||||
this.logger().debug(sql);
|
||||
if (params !== null && params.length) this.logger().debug(JSON.stringify(params));
|
||||
}
|
||||
|
||||
static insertQuery(tableName, data) {
|
||||
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
|
||||
|
||||
let keySql= '';
|
||||
let valueSql = '';
|
||||
let params = [];
|
||||
for (let key in data) {
|
||||
if (!data.hasOwnProperty(key)) continue;
|
||||
if (key[key.length - 1] == '_') continue;
|
||||
if (keySql != '') keySql += ', ';
|
||||
if (valueSql != '') valueSql += ', ';
|
||||
keySql += '`' + key + '`';
|
||||
valueSql += '?';
|
||||
params.push(data[key]);
|
||||
}
|
||||
return {
|
||||
sql: 'INSERT INTO `' + tableName + '` (' + keySql + ') VALUES (' + valueSql + ')',
|
||||
params: params,
|
||||
};
|
||||
}
|
||||
|
||||
static updateQuery(tableName, data, where) {
|
||||
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
|
||||
|
||||
let sql = '';
|
||||
let params = [];
|
||||
for (let key in data) {
|
||||
if (!data.hasOwnProperty(key)) continue;
|
||||
if (key[key.length - 1] == '_') continue;
|
||||
if (sql != '') sql += ', ';
|
||||
sql += '`' + key + '`=?';
|
||||
params.push(data[key]);
|
||||
}
|
||||
|
||||
if (typeof where != 'string') {
|
||||
params.push(where.id);
|
||||
where = 'id=?';
|
||||
}
|
||||
|
||||
return {
|
||||
sql: 'UPDATE `' + tableName + '` SET ' + sql + ' WHERE ' + where,
|
||||
params: params,
|
||||
};
|
||||
}
|
||||
|
||||
wrapQueries(queries) {
|
||||
let output = [];
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
output.push(this.wrapQuery(queries[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
wrapQuery(sql, params = null) {
|
||||
if (!sql) throw new Error('Cannot wrap empty string: ' + sql);
|
||||
|
||||
if (sql.constructor === Array) {
|
||||
let output = {};
|
||||
output.sql = sql[0];
|
||||
output.params = sql.length >= 2 ? sql[1] : null;
|
||||
return output;
|
||||
} else if (typeof sql === 'string') {
|
||||
return { sql: sql, params: params };
|
||||
} else {
|
||||
return sql; // Already wrapped
|
||||
}
|
||||
}
|
||||
|
||||
refreshTableFields() {
|
||||
this.logger().info('Initializing tables...');
|
||||
let queries = [];
|
||||
queries.push(this.wrapQuery('DELETE FROM table_fields'));
|
||||
|
||||
return this.selectAll('SELECT name FROM sqlite_master WHERE type="table"').then((tableRows) => {
|
||||
let chain = [];
|
||||
for (let i = 0; i < tableRows.length; i++) {
|
||||
let tableName = tableRows[i].name;
|
||||
if (tableName == 'android_metadata') continue;
|
||||
if (tableName == 'table_fields') continue;
|
||||
chain.push(() => {
|
||||
return this.selectAll('PRAGMA table_info("' + tableName + '")').then((pragmas) => {
|
||||
for (let i = 0; i < pragmas.length; i++) {
|
||||
let item = pragmas[i];
|
||||
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
|
||||
let defaultValue = item.dflt_value;
|
||||
if (typeof defaultValue == 'string' && defaultValue.length >= 2 && defaultValue[0] == '"' && defaultValue[defaultValue.length - 1] == '"') {
|
||||
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
|
||||
}
|
||||
let q = Database.insertQuery('table_fields', {
|
||||
table_name: tableName,
|
||||
field_name: item.name,
|
||||
field_type: Database.enumId('fieldType', item.type),
|
||||
field_default: defaultValue,
|
||||
});
|
||||
queries.push(q);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain);
|
||||
}).then(() => {
|
||||
return this.transactionExecBatch(queries);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
this.logger().info('Current database version', row);
|
||||
|
||||
// TODO: version update logic
|
||||
// TODO: only do this if db has been updated:
|
||||
// return 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.
|
||||
|
||||
this.logger().info('Database is new - creating the schema...');
|
||||
|
||||
let queries = this.wrapQueries(this.sqlStringToLines(structureSql));
|
||||
queries.push(this.wrapQuery('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumId('settings', 'string') + '")'));
|
||||
|
||||
try {
|
||||
await this.transactionExecBatch(queries);
|
||||
this.logger().info('Database schema created successfully');
|
||||
await this.refreshTableFields();
|
||||
} catch (error) {
|
||||
throw this.sqliteErrorToJsError(error);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Database.TYPE_INT = 1;
|
||||
Database.TYPE_TEXT = 2;
|
||||
Database.TYPE_NUMERIC = 3;
|
||||
|
||||
export { Database };
|
||||
179
ReactNativeClient/lib/file-api-driver-local.js
Normal file
179
ReactNativeClient/lib/file-api-driver-local.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import fs from 'fs-extra';
|
||||
import { promiseChain } from 'lib/promise-utils.js';
|
||||
import moment from 'moment';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
|
||||
class FileApiDriverLocal {
|
||||
|
||||
fsErrorToJsError_(error) {
|
||||
let msg = error.toString();
|
||||
let output = new Error(msg);
|
||||
if (error.code) output.code = error.code;
|
||||
return output;
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.stat(path, (error, s) => {
|
||||
if (error) {
|
||||
if (error.code == 'ENOENT') {
|
||||
resolve(null);
|
||||
} else {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
resolve(this.metadataFromStats_(path, s));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
statTimeToTimestampMs_(time) {
|
||||
let m = moment(time, 'YYYY-MM-DDTHH:mm:ss.SSSZ');
|
||||
if (!m.isValid()) {
|
||||
throw new Error('Invalid date: ' + time);
|
||||
}
|
||||
return m.toDate().getTime();
|
||||
}
|
||||
|
||||
metadataFromStats_(path, stats) {
|
||||
return {
|
||||
path: path,
|
||||
created_time: this.statTimeToTimestampMs_(stats.birthtime),
|
||||
updated_time: this.statTimeToTimestampMs_(stats.mtime),
|
||||
created_time_orig: stats.birthtime,
|
||||
updated_time_orig: stats.mtime,
|
||||
isDir: stats.isDirectory(),
|
||||
};
|
||||
}
|
||||
|
||||
setTimestamp(path, timestampMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let t = Math.floor(timestampMs / 1000);
|
||||
fs.utimes(path, t, t, (error) => {
|
||||
if (error) {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async list(path, options) {
|
||||
try {
|
||||
let items = await fs.readdir(path);
|
||||
let output = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let stat = await this.stat(path + '/' + items[i]);
|
||||
if (!stat) continue; // Has been deleted between the readdir() call and now
|
||||
stat.path = items[i];
|
||||
output.push(stat);
|
||||
}
|
||||
|
||||
return {
|
||||
items: output,
|
||||
hasMore: false,
|
||||
context: null,
|
||||
};
|
||||
} catch(error) {
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
}
|
||||
|
||||
async get(path, options) {
|
||||
let output = null;
|
||||
|
||||
try {
|
||||
if (options.encoding == 'binary') {
|
||||
output = fs.readFile(path);
|
||||
} else {
|
||||
output = fs.readFile(path, options.encoding);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code == 'ENOENT') return null;
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.exists(path, (exists) => {
|
||||
if (exists) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const mkdirp = require('mkdirp');
|
||||
|
||||
mkdirp(path, (error) => {
|
||||
if (error) {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
put(path, content) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.writeFile(path, content, function(error) {
|
||||
if (error) {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.unlink(path, function(error) {
|
||||
if (error) {
|
||||
if (error && error.code == 'ENOENT') {
|
||||
// File doesn't exist - it's fine
|
||||
resolve();
|
||||
} else {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
}
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async move(oldPath, newPath) {
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
let output = await fs.move(oldPath, newPath, { overwrite: true });
|
||||
return output;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// Normally cannot happen with the `overwrite` flag but sometime it still does.
|
||||
// In this case, retry.
|
||||
if (error.code == 'EEXIST') {
|
||||
await time.sleep(1);
|
||||
continue;
|
||||
}
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
format() {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { FileApiDriverLocal };
|
||||
117
ReactNativeClient/lib/file-api-driver-memory.js
Normal file
117
ReactNativeClient/lib/file-api-driver-memory.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { time } from 'lib/time-utils.js';
|
||||
|
||||
class FileApiDriverMemory {
|
||||
|
||||
constructor() {
|
||||
this.items_ = [];
|
||||
}
|
||||
|
||||
itemIndexByPath(path) {
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
if (this.items_[i].path == path) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
itemByPath(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
return index < 0 ? null : this.items_[index];
|
||||
}
|
||||
|
||||
newItem(path, isDir = false) {
|
||||
let now = time.unixMs();
|
||||
return {
|
||||
path: path,
|
||||
isDir: isDir,
|
||||
updated_time: now, // In milliseconds!!
|
||||
created_time: now, // In milliseconds!!
|
||||
content: '',
|
||||
};
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
let item = this.itemByPath(path);
|
||||
return Promise.resolve(item ? Object.assign({}, item) : null);
|
||||
}
|
||||
|
||||
setTimestamp(path, timestampMs) {
|
||||
let item = this.itemByPath(path);
|
||||
if (!item) return Promise.reject(new Error('File not found: ' + path));
|
||||
item.updated_time = timestampMs;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(path, options) {
|
||||
let output = [];
|
||||
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
let item = this.items_[i];
|
||||
if (item.path == path) continue;
|
||||
if (item.path.indexOf(path + '/') === 0) {
|
||||
let s = item.path.substr(path.length + 1);
|
||||
if (s.split('/').length === 1) {
|
||||
let it = Object.assign({}, item);
|
||||
it.path = it.path.substr(path.length + 1);
|
||||
output.push(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
items: output,
|
||||
hasMore: false,
|
||||
context: null,
|
||||
});
|
||||
}
|
||||
|
||||
get(path) {
|
||||
let item = this.itemByPath(path);
|
||||
if (!item) return Promise.resolve(null);
|
||||
if (item.isDir) return Promise.reject(new Error(path + ' is a directory, not a file'));
|
||||
return Promise.resolve(item.content);
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index >= 0) return Promise.resolve();
|
||||
this.items_.push(this.newItem(path, true));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
put(path, content) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index < 0) {
|
||||
let item = this.newItem(path, false);
|
||||
item.content = content;
|
||||
this.items_.push(item);
|
||||
} else {
|
||||
this.items_[index].content = content;
|
||||
this.items_[index].updated_time = time.unix();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index >= 0) {
|
||||
this.items_.splice(index, 1);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
move(oldPath, newPath) {
|
||||
let sourceItem = this.itemByPath(oldPath);
|
||||
if (!sourceItem) return Promise.reject(new Error('Path not found: ' + oldPath));
|
||||
this.delete(newPath); // Overwrite if newPath already exists
|
||||
sourceItem.path = newPath;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
format() {
|
||||
this.items_ = [];
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { FileApiDriverMemory };
|
||||
149
ReactNativeClient/lib/file-api-driver-onedrive.js
Normal file
149
ReactNativeClient/lib/file-api-driver-onedrive.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import moment from 'moment';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { dirname, basename } from 'lib/path-utils.js';
|
||||
import { OneDriveApi } from 'lib/onedrive-api.js';
|
||||
|
||||
class FileApiDriverOneDrive {
|
||||
|
||||
constructor(clientId, clientSecret) {
|
||||
this.api_ = new OneDriveApi(clientId, clientSecret);
|
||||
}
|
||||
|
||||
api() {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
itemFilter_() {
|
||||
return {
|
||||
select: 'name,file,folder,fileSystemInfo',
|
||||
}
|
||||
}
|
||||
|
||||
makePath_(path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
makeItems_(odItems) {
|
||||
let output = [];
|
||||
for (let i = 0; i < odItems.length; i++) {
|
||||
output.push(this.makeItem_(odItems[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
makeItem_(odItem) {
|
||||
return {
|
||||
path: odItem.name,
|
||||
isDir: ('folder' in odItem),
|
||||
created_time: moment(odItem.fileSystemInfo.createdDateTime, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x'),
|
||||
updated_time: moment(odItem.fileSystemInfo.lastModifiedDateTime, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x'),
|
||||
};
|
||||
}
|
||||
|
||||
async statRaw_(path) {
|
||||
let item = null;
|
||||
try {
|
||||
item = await this.api_.execJson('GET', this.makePath_(path), this.itemFilter_());
|
||||
} catch (error) {
|
||||
if (error.code == 'itemNotFound') return null;
|
||||
throw error;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async stat(path) {
|
||||
let item = await this.statRaw_(path);
|
||||
if (!item) return null;
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
async setTimestamp(path, timestamp) {
|
||||
let body = {
|
||||
fileSystemInfo: {
|
||||
lastModifiedDateTime: moment.unix(timestamp / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z',
|
||||
}
|
||||
};
|
||||
let item = await this.api_.execJson('PATCH', this.makePath_(path), null, body);
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
async list(path, options = null) {
|
||||
let query = this.itemFilter_();
|
||||
let url = this.makePath_(path) + ':/children';
|
||||
|
||||
if (options.context) {
|
||||
query = null;
|
||||
url = options.context;
|
||||
}
|
||||
|
||||
let r = await this.api_.execJson('GET', url, query);
|
||||
|
||||
return {
|
||||
hasMore: !!r['@odata.nextLink'],
|
||||
items: this.makeItems_(r.value),
|
||||
context: r["@odata.nextLink"],
|
||||
}
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
let content = null;
|
||||
try {
|
||||
content = await this.api_.execText('GET', this.makePath_(path) + ':/content');
|
||||
} catch (error) {
|
||||
if (error.code == 'itemNotFound') return null;
|
||||
throw error;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async mkdir(path) {
|
||||
let item = await this.stat(path);
|
||||
if (item) return item;
|
||||
|
||||
let parentPath = dirname(path);
|
||||
item = await this.api_.execJson('POST', this.makePath_(parentPath) + ':/children', this.itemFilter_(), {
|
||||
name: basename(path),
|
||||
folder: {},
|
||||
});
|
||||
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
put(path, content) {
|
||||
let options = {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
};
|
||||
return this.api_.exec('PUT', this.makePath_(path) + ':/content', null, content, options);
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
return this.api_.exec('DELETE', this.makePath_(path));
|
||||
}
|
||||
|
||||
async move(oldPath, newPath) {
|
||||
let previousItem = await this.statRaw_(oldPath);
|
||||
|
||||
let newDir = dirname(newPath);
|
||||
let newName = basename(newPath);
|
||||
|
||||
// We don't want the modification date to change when we move the file so retrieve it
|
||||
// now set it in the PATCH operation.
|
||||
|
||||
let item = await this.api_.execJson('PATCH', this.makePath_(oldPath), this.itemFilter_(), {
|
||||
name: newName,
|
||||
parentReference: { path: newDir },
|
||||
fileSystemInfo: {
|
||||
lastModifiedDateTime: previousItem.fileSystemInfo.lastModifiedDateTime,
|
||||
},
|
||||
});
|
||||
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
format() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { FileApiDriverOneDrive };
|
||||
92
ReactNativeClient/lib/file-api.js
Normal file
92
ReactNativeClient/lib/file-api.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { isHidden } from 'lib/path-utils.js';
|
||||
import { Logger } from 'lib/logger.js';
|
||||
|
||||
class FileApi {
|
||||
|
||||
constructor(baseDir, driver) {
|
||||
this.baseDir_ = baseDir;
|
||||
this.driver_ = driver;
|
||||
this.logger_ = new Logger();
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
fullPath_(path) {
|
||||
let output = this.baseDir_;
|
||||
if (path != '') output += '/' + path;
|
||||
return output;
|
||||
}
|
||||
|
||||
// DRIVER MUST RETURN PATHS RELATIVE TO `path`
|
||||
list(path = '', options = null) {
|
||||
if (!options) options = {};
|
||||
if (!('includeHidden' in options)) options.includeHidden = false;
|
||||
if (!('context' in options)) options.context = null;
|
||||
|
||||
this.logger().debug('list ' + this.baseDir_);
|
||||
|
||||
return this.driver_.list(this.baseDir_, options).then((result) => {
|
||||
if (!options.includeHidden) {
|
||||
let temp = [];
|
||||
for (let i = 0; i < result.items.length; i++) {
|
||||
if (!isHidden(result.items[i].path)) temp.push(result.items[i]);
|
||||
}
|
||||
result.items = temp;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
setTimestamp(path, timestampMs) {
|
||||
this.logger().debug('setTimestamp ' + this.fullPath_(path));
|
||||
return this.driver_.setTimestamp(this.fullPath_(path), timestampMs);
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
this.logger().debug('mkdir ' + this.fullPath_(path));
|
||||
return this.driver_.mkdir(this.fullPath_(path));
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
this.logger().debug('stat ' + this.fullPath_(path));
|
||||
return this.driver_.stat(this.fullPath_(path)).then((output) => {
|
||||
if (!output) return output;
|
||||
output.path = path;
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
get(path, options = {}) {
|
||||
if (!options.encoding) options.encoding = 'utf8';
|
||||
this.logger().debug('get ' + this.fullPath_(path));
|
||||
return this.driver_.get(this.fullPath_(path), options);
|
||||
}
|
||||
|
||||
put(path, content) {
|
||||
this.logger().debug('put ' + this.fullPath_(path));
|
||||
return this.driver_.put(this.fullPath_(path), content);
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
this.logger().debug('delete ' + this.fullPath_(path));
|
||||
return this.driver_.delete(this.fullPath_(path));
|
||||
}
|
||||
|
||||
move(oldPath, newPath) {
|
||||
this.logger().debug('move ' + this.fullPath_(oldPath) + ' => ' + this.fullPath_(newPath));
|
||||
return this.driver_.move(this.fullPath_(oldPath), this.fullPath_(newPath));
|
||||
}
|
||||
|
||||
format() {
|
||||
return this.driver_.format();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { FileApi };
|
||||
39
ReactNativeClient/lib/geolocation-react.js
Normal file
39
ReactNativeClient/lib/geolocation-react.js
Normal file
@@ -0,0 +1,39 @@
|
||||
class GeolocationReact {
|
||||
|
||||
static currentPosition_testResponse() {
|
||||
return {
|
||||
mocked: false,
|
||||
timestamp: (new Date()).getTime(),
|
||||
coords: {
|
||||
speed: 0,
|
||||
heading: 0,
|
||||
accuracy: 20,
|
||||
longitude: -3.4596633911132812,
|
||||
altitude: 0,
|
||||
latitude: 48.73219093634444
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static currentPosition(options = null) {
|
||||
if (typeof navigator === 'undefined') {
|
||||
// TODO
|
||||
return Promise.resolve(this.currentPosition_testResponse());
|
||||
}
|
||||
|
||||
if (!options) options = {};
|
||||
if (!('enableHighAccuracy' in options)) options.enableHighAccuracy = true;
|
||||
if (!('timeout' in options)) options.timeout = 10000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition((data) => {
|
||||
resolve(data);
|
||||
}, (error) => {
|
||||
rejec(error);
|
||||
}, options);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { GeolocationReact };
|
||||
9
ReactNativeClient/lib/locale.js
Normal file
9
ReactNativeClient/lib/locale.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { sprintf } from 'sprintf-js';
|
||||
|
||||
// This function does nothing for now, but later will return
|
||||
// a different string depending on the language.
|
||||
function _(s, ...args) {
|
||||
return sprintf(s, ...args);
|
||||
}
|
||||
|
||||
export { _ };
|
||||
40
ReactNativeClient/lib/log.js
Normal file
40
ReactNativeClient/lib/log.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Custom wrapper for `console` to allow for custom logging (to file, etc.) if needed.
|
||||
|
||||
class Log {
|
||||
|
||||
static setLevel(v) {
|
||||
this.level_ = v;
|
||||
}
|
||||
|
||||
static level() {
|
||||
return this.level_ === undefined ? Log.LEVEL_DEBUG : this.level_;
|
||||
}
|
||||
|
||||
static debug(...o) {
|
||||
if (Log.level() > Log.LEVEL_DEBUG) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
static info(...o) {
|
||||
if (Log.level() > Log.LEVEL_INFO) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
static warn(...o) {
|
||||
if (Log.level() > Log.LEVEL_WARN) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
static error(...o) {
|
||||
if (Log.level() > Log.LEVEL_ERROR) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Log.LEVEL_DEBUG = 0;
|
||||
Log.LEVEL_INFO = 10;
|
||||
Log.LEVEL_WARN = 20;
|
||||
Log.LEVEL_ERROR = 30;
|
||||
|
||||
export { Log };
|
||||
121
ReactNativeClient/lib/logger.js
Normal file
121
ReactNativeClient/lib/logger.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import moment from 'moment';
|
||||
import fs from 'fs-extra';
|
||||
import { _ } from 'lib/locale.js';
|
||||
|
||||
class Logger {
|
||||
|
||||
constructor() {
|
||||
this.targets_ = [];
|
||||
this.level_ = Logger.LEVEL_ERROR;
|
||||
this.fileAppendQueue_ = []
|
||||
}
|
||||
|
||||
setLevel(level) {
|
||||
this.level_ = level;
|
||||
}
|
||||
|
||||
level() {
|
||||
return this.level_;
|
||||
}
|
||||
|
||||
clearTargets() {
|
||||
this.targets_.clear();
|
||||
}
|
||||
|
||||
addTarget(type, options = null) {
|
||||
let target = { type: type };
|
||||
for (let n in options) {
|
||||
if (!options.hasOwnProperty(n)) continue;
|
||||
target[n] = options[n];
|
||||
}
|
||||
|
||||
this.targets_.push(target);
|
||||
}
|
||||
|
||||
log(level, object) {
|
||||
if (this.level() < level || !this.targets_.length) return;
|
||||
|
||||
let levelString = '';
|
||||
if (this.level() == Logger.LEVEL_INFO) levelString = '[info] ';
|
||||
if (this.level() == Logger.LEVEL_WARN) levelString = '[warn] ';
|
||||
if (this.level() == Logger.LEVEL_ERROR) levelString = '[error] ';
|
||||
let line = moment().format('YYYY-MM-DD HH:mm:ss') + ': ' + levelString;
|
||||
|
||||
for (let i = 0; i < this.targets_.length; i++) {
|
||||
let t = this.targets_[i];
|
||||
if (t.type == 'console') {
|
||||
let fn = 'debug';
|
||||
if (level = Logger.LEVEL_ERROR) fn = 'error';
|
||||
if (level = Logger.LEVEL_WARN) fn = 'warn';
|
||||
if (level = Logger.LEVEL_INFO) fn = 'info';
|
||||
if (typeof object === 'object') {
|
||||
console[fn](line, object);
|
||||
} else {
|
||||
console[fn](line + object);
|
||||
}
|
||||
} else if (t.type == 'file') {
|
||||
let serializedObject = '';
|
||||
|
||||
if (typeof object === 'object') {
|
||||
if (object instanceof Error) {
|
||||
serializedObject = object.toString();
|
||||
if (object.stack) serializedObject += "\n" + object.stack;
|
||||
} else {
|
||||
serializedObject = JSON.stringify(object);
|
||||
}
|
||||
} else {
|
||||
serializedObject = object;
|
||||
}
|
||||
|
||||
fs.appendFileSync(t.path, line + serializedObject + "\n");
|
||||
|
||||
// this.fileAppendQueue_.push({
|
||||
// path: t.path,
|
||||
// line: line + serializedObject + "\n",
|
||||
// });
|
||||
|
||||
// this.scheduleFileAppendQueueProcessing_();
|
||||
} else if (t.type == 'vorpal') {
|
||||
t.vorpal.log(object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleFileAppendQueueProcessing_() {
|
||||
// if (this.fileAppendQueueTID_) return;
|
||||
|
||||
// this.fileAppendQueueTID_ = setTimeout(async () => {
|
||||
// this.fileAppendQueueTID_ = null;
|
||||
|
||||
// let queue = this.fileAppendQueue_.slice(0);
|
||||
// for (let i = 0; i < queue.length; i++) {
|
||||
// let t = queue[i];
|
||||
// await fs.appendFile(t.path, t.line);
|
||||
// }
|
||||
// this.fileAppendQueue_.splice(0, queue.length);
|
||||
// }, 1);
|
||||
// }
|
||||
|
||||
error(object) { return this.log(Logger.LEVEL_ERROR, object); }
|
||||
warn(object) { return this.log(Logger.LEVEL_WARN, object); }
|
||||
info(object) { return this.log(Logger.LEVEL_INFO, object); }
|
||||
debug(object) { return this.log(Logger.LEVEL_DEBUG, object); }
|
||||
|
||||
static levelStringToId(s) {
|
||||
if (s == 'none') return Logger.LEVEL_NONE;
|
||||
if (s == 'error') return Logger.LEVEL_ERROR;
|
||||
if (s == 'warn') return Logger.LEVEL_WARN;
|
||||
if (s == 'info') return Logger.LEVEL_INFO;
|
||||
if (s == 'debug') return Logger.LEVEL_DEBUG;
|
||||
throw new Error(_('Unknown log level: %s', s));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Logger.LEVEL_NONE = 0;
|
||||
Logger.LEVEL_ERROR = 10;
|
||||
Logger.LEVEL_WARN = 20;
|
||||
Logger.LEVEL_INFO = 30;
|
||||
Logger.LEVEL_DEBUG = 40;
|
||||
|
||||
export { Logger };
|
||||
23
ReactNativeClient/lib/mime-utils.js
Normal file
23
ReactNativeClient/lib/mime-utils.js
Normal file
File diff suppressed because one or more lines are too long
244
ReactNativeClient/lib/models/base-item.js
Normal file
244
ReactNativeClient/lib/models/base-item.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import moment from 'moment';
|
||||
|
||||
class BaseItem extends BaseModel {
|
||||
|
||||
static useUuid() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Need to dynamically load the classes like this to avoid circular dependencies
|
||||
static getClass(name) {
|
||||
if (!this.classes_) this.classes_ = {};
|
||||
if (this.classes_[name]) return this.classes_[name];
|
||||
let filename = name.toLowerCase();
|
||||
if (name == 'NoteTag') filename = 'note-tag';
|
||||
this.classes_[name] = require('lib/models/' + filename + '.js')[name];
|
||||
return this.classes_[name];
|
||||
}
|
||||
|
||||
static systemPath(itemOrId) {
|
||||
if (typeof itemOrId === 'string') return itemOrId + '.md';
|
||||
return itemOrId.id + '.md';
|
||||
}
|
||||
|
||||
static itemClass(item) {
|
||||
if (!item) throw new Error('Item cannot be null');
|
||||
|
||||
if (typeof item === 'object') {
|
||||
if (!('type_' in item)) throw new Error('Item does not have a type_ property');
|
||||
return this.itemClass(item.type_);
|
||||
} else {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
let d = BaseItem.syncItemDefinitions_[i];
|
||||
if (Number(item) == d.type) return this.getClass(d.className);
|
||||
}
|
||||
throw new Error('Unknown type: ' + item);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the IDs of the items that have been synced at least once
|
||||
static async syncedItems() {
|
||||
let folders = await this.getClass('Folder').modelSelectAll('SELECT id FROM folders WHERE sync_time > 0');
|
||||
let notes = await this.getClass('Note').modelSelectAll('SELECT id FROM notes WHERE is_conflict = 0 AND sync_time > 0');
|
||||
let resources = await this.getClass('Resource').modelSelectAll('SELECT id FROM resources WHERE sync_time > 0');
|
||||
let tags = await this.getClass('Tag').modelSelectAll('SELECT id FROM tags WHERE sync_time > 0');
|
||||
let noteTags = await this.getClass('NoteTag').modelSelectAll('SELECT id FROM note_tags WHERE sync_time > 0');
|
||||
return folders.concat(notes).concat(resources).concat(tags).concat(noteTags);
|
||||
}
|
||||
|
||||
static pathToId(path) {
|
||||
let s = path.split('.');
|
||||
return s[0];
|
||||
}
|
||||
|
||||
static loadItemByPath(path) {
|
||||
return this.loadItemById(this.pathToId(path));
|
||||
}
|
||||
|
||||
static async loadItemById(id) {
|
||||
let classes = this.syncItemClassNames();
|
||||
for (let i = 0; i < classes.length; i++) {
|
||||
let item = await this.getClass(classes[i]).load(id);
|
||||
if (item) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static loadItemByField(itemType, field, value) {
|
||||
let ItemClass = this.itemClass(itemType);
|
||||
return ItemClass.loadByField(field, value);
|
||||
}
|
||||
|
||||
static loadItem(itemType, id) {
|
||||
let ItemClass = this.itemClass(itemType);
|
||||
return ItemClass.load(id);
|
||||
}
|
||||
|
||||
static deleteItem(itemType, id) {
|
||||
let ItemClass = this.itemClass(itemType);
|
||||
return ItemClass.delete(id);
|
||||
}
|
||||
|
||||
static async delete(id, options = null) {
|
||||
let trackDeleted = true;
|
||||
if (options && options.trackDeleted !== null && options.trackDeleted !== undefined) trackDeleted = options.trackDeleted;
|
||||
|
||||
await super.delete(id, options);
|
||||
|
||||
if (trackDeleted) {
|
||||
await this.db().exec('INSERT INTO deleted_items (item_type, item_id, deleted_time) VALUES (?, ?, ?)', [this.modelType(), id, time.unixMs()]);
|
||||
}
|
||||
}
|
||||
|
||||
static deletedItems() {
|
||||
return this.db().selectAll('SELECT * FROM deleted_items');
|
||||
}
|
||||
|
||||
static remoteDeletedItem(itemId) {
|
||||
return this.db().exec('DELETE FROM deleted_items WHERE item_id = ?', [itemId]);
|
||||
}
|
||||
|
||||
static serialize_format(propName, propValue) {
|
||||
if (['created_time', 'updated_time'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return '';
|
||||
propValue = moment.unix(propValue / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
|
||||
} else if (propValue === null || propValue === undefined) {
|
||||
propValue = '';
|
||||
}
|
||||
|
||||
return propValue;
|
||||
}
|
||||
|
||||
static unserialize_format(type, propName, propValue) {
|
||||
if (propName[propName.length - 1] == '_') return propValue; // Private property
|
||||
|
||||
let ItemClass = this.itemClass(type);
|
||||
|
||||
if (['created_time', 'updated_time'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return 0;
|
||||
propValue = moment(propValue, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x');
|
||||
} else {
|
||||
propValue = Database.formatValue(ItemClass.fieldType(propName), propValue);
|
||||
}
|
||||
|
||||
return propValue;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
item = this.filter(item);
|
||||
|
||||
let output = [];
|
||||
|
||||
if ('title' in item) {
|
||||
output.push(item.title);
|
||||
output.push('');
|
||||
}
|
||||
|
||||
if ('body' in item) {
|
||||
output.push(item.body);
|
||||
if (shownKeys.length) output.push('');
|
||||
}
|
||||
|
||||
for (let i = 0; i < shownKeys.length; i++) {
|
||||
let key = shownKeys[i];
|
||||
let value = null;
|
||||
if (typeof key === 'function') {
|
||||
let r = await key();
|
||||
key = r.key;
|
||||
value = r.value;
|
||||
} else {
|
||||
value = this.serialize_format(key, item[key]);
|
||||
}
|
||||
|
||||
output.push(key + ': ' + value);
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
static async unserialize(content) {
|
||||
let lines = content.split("\n");
|
||||
let output = {};
|
||||
let state = 'readingProps';
|
||||
let body = [];
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
let line = lines[i];
|
||||
|
||||
if (state == 'readingProps') {
|
||||
line = line.trim();
|
||||
|
||||
if (line == '') {
|
||||
state = 'readingBody';
|
||||
continue;
|
||||
}
|
||||
|
||||
let p = line.indexOf(':');
|
||||
if (p < 0) throw new Error('Invalid property format: ' + line + ": " + content);
|
||||
let key = line.substr(0, p).trim();
|
||||
let value = line.substr(p + 1).trim();
|
||||
output[key] = value;
|
||||
} else if (state == 'readingBody') {
|
||||
body.splice(0, 0, line);
|
||||
}
|
||||
}
|
||||
|
||||
if (!output.type_) throw new Error('Missing required property: type_: ' + content);
|
||||
output.type_ = Number(output.type_);
|
||||
|
||||
if (body.length) {
|
||||
let title = body.splice(0, 2);
|
||||
output.title = title[0];
|
||||
}
|
||||
|
||||
if (body.length) output.body = body.join("\n");
|
||||
|
||||
for (let n in output) {
|
||||
if (!output.hasOwnProperty(n)) continue;
|
||||
output[n] = await this.unserialize_format(output.type_, n, output[n]);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async itemsThatNeedSync(limit = 100) {
|
||||
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() {
|
||||
return BaseItem.syncItemDefinitions_.map((def) => {
|
||||
return def.className;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Also update:
|
||||
// - itemsThatNeedSync()
|
||||
// - syncedItems()
|
||||
|
||||
BaseItem.syncItemDefinitions_ = [
|
||||
{ type: BaseModel.TYPE_NOTE, className: 'Note' },
|
||||
{ type: BaseModel.TYPE_FOLDER, className: 'Folder' },
|
||||
{ type: BaseModel.TYPE_RESOURCE, className: 'Resource' },
|
||||
{ type: BaseModel.TYPE_TAG, className: 'Tag' },
|
||||
{ type: BaseModel.TYPE_NOTE_TAG, className: 'NoteTag' },
|
||||
];
|
||||
|
||||
export { BaseItem };
|
||||
96
ReactNativeClient/lib/models/folder.js
Normal file
96
ReactNativeClient/lib/models/folder.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { promiseChain } from 'lib/promise-utils.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import moment from 'moment';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import lodash from 'lodash';
|
||||
|
||||
class Folder extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'folders';
|
||||
}
|
||||
|
||||
static async serialize(folder) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
lodash.pull(fieldNames, 'parent_id', 'sync_time');
|
||||
return super.serialize(folder, 'folder', fieldNames);
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_FOLDER;
|
||||
}
|
||||
|
||||
static newFolder() {
|
||||
return {
|
||||
id: null,
|
||||
title: '',
|
||||
}
|
||||
}
|
||||
|
||||
static noteIds(parentId) {
|
||||
return this.db().selectAll('SELECT id FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]).then((rows) => {
|
||||
let output = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let row = rows[i];
|
||||
output.push(row.id);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
static async delete(folderId, options = null) {
|
||||
let folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error('Trying to delete non-existing notebook: ' + folderId);
|
||||
|
||||
let noteIds = await Folder.noteIds(folderId);
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
await Note.delete(noteIds[i]);
|
||||
}
|
||||
|
||||
await super.delete(folderId, options);
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDER_DELETE',
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
static async all(options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
let folders = await super.all(options);
|
||||
if (!options.includeNotes) return folders;
|
||||
|
||||
if (options.limit) options.limit -= folders.length;
|
||||
|
||||
let notes = await Note.all(options);
|
||||
return folders.concat(notes);
|
||||
}
|
||||
|
||||
static defaultFolder() {
|
||||
return this.modelSelectOne('SELECT * FROM folders ORDER BY created_time DESC LIMIT 1');
|
||||
}
|
||||
|
||||
static async save(o, options = null) {
|
||||
if (options && options.duplicateCheck === true && o.title) {
|
||||
let existingFolder = await Folder.loadByTitle(o.title);
|
||||
if (existingFolder) throw new Error(_('A notebook with this title already exists: "%s"', o.title));
|
||||
}
|
||||
|
||||
return super.save(o, options).then((folder) => {
|
||||
this.dispatch({
|
||||
type: 'FOLDERS_UPDATE_ONE',
|
||||
folder: folder,
|
||||
});
|
||||
return folder;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Folder };
|
||||
25
ReactNativeClient/lib/models/note-tag.js
Normal file
25
ReactNativeClient/lib/models/note-tag.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Database } from 'lib/database.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import lodash from 'lodash';
|
||||
|
||||
class NoteTag extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'note_tags';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_NOTE_TAG;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
lodash.pull(fieldNames, 'sync_time');
|
||||
return super.serialize(item, 'note_tag', fieldNames);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { NoteTag };
|
||||
147
ReactNativeClient/lib/models/note.js
Normal file
147
ReactNativeClient/lib/models/note.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { GeolocationReact } from 'lib/geolocation-react.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import moment from 'moment';
|
||||
import lodash from 'lodash';
|
||||
|
||||
class Note extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'notes';
|
||||
}
|
||||
|
||||
static async serialize(note, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
lodash.pull(fieldNames, 'is_conflict', 'sync_time', 'body'); // Exclude 'body' since it's going to be added separately at the top of the note
|
||||
return super.serialize(note, 'note', fieldNames);
|
||||
}
|
||||
|
||||
static async serializeForEdit(note) {
|
||||
return super.serialize(note, 'note', []);
|
||||
}
|
||||
|
||||
static async unserializeForEdit(content) {
|
||||
content += "\n\ntype_: " + BaseModel.TYPE_NOTE;
|
||||
return super.unserialize(content);
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_NOTE;
|
||||
}
|
||||
|
||||
static new(parentId = '') {
|
||||
let output = super.new();
|
||||
output.parent_id = parentId;
|
||||
return output;
|
||||
}
|
||||
|
||||
static newTodo(parentId = '') {
|
||||
let output = this.new(parentId);
|
||||
output.is_todo = true;
|
||||
return output;
|
||||
}
|
||||
|
||||
static previewFields() {
|
||||
return ['id', 'title', 'body', 'is_todo', 'todo_completed', 'parent_id', 'updated_time'];
|
||||
}
|
||||
|
||||
static previewFieldsSql() {
|
||||
return this.db().escapeFields(this.previewFields()).join(',');
|
||||
}
|
||||
|
||||
static loadFolderNoteByField(folderId, field, value) {
|
||||
return this.modelSelectOne('SELECT * FROM notes WHERE is_conflict = 0 AND `parent_id` = ? AND `' + field + '` = ?', [folderId, value]);
|
||||
}
|
||||
|
||||
static previews(parentId, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.orderBy) options.orderBy = 'updated_time';
|
||||
if (!options.orderByDir) options.orderByDir = 'DESC';
|
||||
if (!options.conditions) options.conditions = [];
|
||||
if (!options.conditionsParams) options.conditionsParams = [];
|
||||
if (!options.fields) options.fields = this.previewFields();
|
||||
|
||||
options.conditions.push('is_conflict = 0');
|
||||
|
||||
options.conditions.push('parent_id = ?');
|
||||
options.conditionsParams.push(parentId);
|
||||
|
||||
if (options.itemTypes && options.itemTypes.length) {
|
||||
if (options.itemTypes.indexOf('note') >= 0 && options.itemTypes.indexOf('todo') >= 0) {
|
||||
// Fetch everything
|
||||
} else if (options.itemTypes.indexOf('note') >= 0) {
|
||||
options.conditions.push('is_todo = 0');
|
||||
} else if (options.itemTypes.indexOf('todo') >= 0) {
|
||||
options.conditions.push('is_todo = 1');
|
||||
}
|
||||
}
|
||||
|
||||
return this.search(options);
|
||||
}
|
||||
|
||||
static preview(noteId) {
|
||||
return this.modelSelectOne('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE is_conflict = 0 AND id = ?', [noteId]);
|
||||
}
|
||||
|
||||
static conflictedNotes() {
|
||||
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 1');
|
||||
}
|
||||
|
||||
static unconflictedNotes() {
|
||||
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
|
||||
}
|
||||
|
||||
static updateGeolocation(noteId) {
|
||||
Log.info('Updating lat/long of note ' + noteId);
|
||||
|
||||
let geoData = null;
|
||||
return GeolocationReact.currentPosition().then((data) => {
|
||||
Log.info('Got lat/long');
|
||||
geoData = data;
|
||||
return Note.load(noteId);
|
||||
}).then((note) => {
|
||||
if (!note) return; // Race condition - note has been deleted in the meantime
|
||||
note.longitude = geoData.coords.longitude;
|
||||
note.latitude = geoData.coords.latitude;
|
||||
note.altitude = geoData.coords.altitude;
|
||||
return Note.save(note);
|
||||
}).catch((error) => {
|
||||
Log.info('Cannot get location:', error);
|
||||
});
|
||||
}
|
||||
|
||||
static filter(note) {
|
||||
if (!note) return note;
|
||||
|
||||
let output = super.filter(note);
|
||||
if ('longitude' in output) output.longitude = Number(!output.longitude ? 0 : output.longitude).toFixed(8);
|
||||
if ('latitude' in output) output.latitude = Number(!output.latitude ? 0 : output.latitude).toFixed(8);
|
||||
if ('altitude' in output) output.altitude = Number(!output.altitude ? 0 : output.altitude).toFixed(4);
|
||||
return output;
|
||||
}
|
||||
|
||||
static save(o, options = null) {
|
||||
let isNew = this.isNew(o, options);
|
||||
if (isNew && !o.source) o.source = Setting.value('appName');
|
||||
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
|
||||
|
||||
return super.save(o, options).then((result) => {
|
||||
// 'result' could be a partial one at this point (if, for example, only one property of it was saved)
|
||||
// so call this.preview() so that the right fields are populated.
|
||||
return this.load(result.id);
|
||||
}).then((note) => {
|
||||
this.dispatch({
|
||||
type: 'NOTES_UPDATE_ONE',
|
||||
note: note,
|
||||
});
|
||||
return note;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Note };
|
||||
50
ReactNativeClient/lib/models/resource.js
Normal file
50
ReactNativeClient/lib/models/resource.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { mime } from 'lib/mime-utils.js';
|
||||
import { filename } from 'lib/path-utils.js';
|
||||
import lodash from 'lodash';
|
||||
|
||||
class Resource extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'resources';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_RESOURCE;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
lodash.pull(fieldNames, 'sync_time');
|
||||
return super.serialize(item, 'resource', fieldNames);
|
||||
}
|
||||
|
||||
static fullPath(resource) {
|
||||
let extension = mime.toFileExtension(resource.mime);
|
||||
extension = extension ? '.' + extension : '';
|
||||
return Setting.value('resourceDir') + '/' + resource.id + extension;
|
||||
}
|
||||
|
||||
static pathToId(path) {
|
||||
return filename(path);
|
||||
}
|
||||
|
||||
static content(resource) {
|
||||
// TODO: node-only, and should probably be done with streams
|
||||
const fs = require('fs-extra');
|
||||
return fs.readFile(this.fullPath(resource));
|
||||
}
|
||||
|
||||
static setContent(resource, content) {
|
||||
// TODO: node-only, and should probably be done with streams
|
||||
const fs = require('fs-extra');
|
||||
let buffer = new Buffer(content);
|
||||
return fs.writeFile(this.fullPath(resource), buffer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Resource };
|
||||
11
ReactNativeClient/lib/models/session.js
Normal file
11
ReactNativeClient/lib/models/session.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
|
||||
class Session extends BaseModel {
|
||||
|
||||
static login(email, password) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Session };
|
||||
157
ReactNativeClient/lib/models/setting.js
Normal file
157
ReactNativeClient/lib/models/setting.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
|
||||
class Setting extends BaseModel {
|
||||
|
||||
static tableName() {
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_SETTING;
|
||||
}
|
||||
|
||||
static defaultSetting(key) {
|
||||
if (!(key in this.defaults_)) throw new Error('Unknown key: ' + key);
|
||||
let output = Object.assign({}, this.defaults_[key]);
|
||||
output.key = key;
|
||||
return output;
|
||||
}
|
||||
|
||||
static keys() {
|
||||
if (this.keys_) return this.keys_;
|
||||
this.keys_ = [];
|
||||
for (let n in this.defaults_) {
|
||||
if (!this.defaults_.hasOwnProperty(n)) continue;
|
||||
this.keys_.push(n);
|
||||
}
|
||||
return this.keys_;
|
||||
}
|
||||
|
||||
static publicKeys() {
|
||||
let output = [];
|
||||
for (let n in this.defaults_) {
|
||||
if (!this.defaults_.hasOwnProperty(n)) continue;
|
||||
if (this.defaults_[n].public) output.push(n);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static load() {
|
||||
this.cancelScheduleUpdate();
|
||||
this.cache_ = [];
|
||||
return this.modelSelectAll('SELECT * FROM settings').then((rows) => {
|
||||
this.cache_ = rows;
|
||||
});
|
||||
}
|
||||
|
||||
static setConstant(key, value) {
|
||||
this.constants_[key] = value;
|
||||
}
|
||||
|
||||
static setValue(key, value) {
|
||||
if (!this.cache_) throw new Error('Settings have not been initialized!');
|
||||
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
if (this.cache_[i].key == key) {
|
||||
if (this.cache_[i].value === value) return;
|
||||
this.cache_[i].value = value;
|
||||
this.scheduleUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let s = this.defaultSetting(key);
|
||||
s.value = value;
|
||||
this.cache_.push(s);
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
static value(key) {
|
||||
if (key in this.constants_) return this.constants_[key];
|
||||
|
||||
if (!this.cache_) throw new Error('Settings have not been initialized!');
|
||||
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
if (this.cache_[i].key == key) {
|
||||
return this.cache_[i].value;
|
||||
}
|
||||
}
|
||||
|
||||
let s = this.defaultSetting(key);
|
||||
return s.value;
|
||||
}
|
||||
|
||||
// Currently only supports objects with properties one level deep
|
||||
static object(key) {
|
||||
let output = {};
|
||||
let keys = this.keys();
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
let k = keys[i].split('.');
|
||||
if (k[0] == key) {
|
||||
output[k[1]] = this.value(keys[i]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Currently only supports objects with properties one level deep
|
||||
static setObject(key, object) {
|
||||
for (let n in object) {
|
||||
if (!object.hasOwnProperty(n)) continue;
|
||||
this.setValue(key + '.' + n, object[n]);
|
||||
}
|
||||
}
|
||||
|
||||
static saveAll() {
|
||||
if (!this.updateTimeoutId_) return Promise.resolve();
|
||||
|
||||
this.logger().info('Saving settings...');
|
||||
clearTimeout(this.updateTimeoutId_);
|
||||
this.updateTimeoutId_ = null;
|
||||
|
||||
let queries = [];
|
||||
queries.push('DELETE FROM settings');
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
let s = Object.assign({}, this.cache_[i]);
|
||||
delete s.public;
|
||||
queries.push(Database.insertQuery(this.tableName(), s));
|
||||
}
|
||||
|
||||
return BaseModel.db().transactionExecBatch(queries).then(() => {
|
||||
this.logger().info('Settings have been saved.');
|
||||
});
|
||||
}
|
||||
|
||||
static scheduleUpdate() {
|
||||
if (this.updateTimeoutId_) clearTimeout(this.updateTimeoutId_);
|
||||
|
||||
this.updateTimeoutId_ = setTimeout(() => {
|
||||
this.saveAll();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
static cancelScheduleUpdate() {
|
||||
if (this.updateTimeoutId_) clearTimeout(this.updateTimeoutId_);
|
||||
this.updateTimeoutId_ = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Setting.defaults_ = {
|
||||
'clientId': { value: '', type: 'string', public: false },
|
||||
'activeFolderId': { value: '', type: 'string', public: false },
|
||||
'sync.onedrive.auth': { value: '', type: 'string', public: false },
|
||||
'sync.local.path': { value: '', type: 'string', public: true },
|
||||
'sync.target': { value: 'onedrive', type: 'string', public: true },
|
||||
'editor': { value: '', type: 'string', public: true },
|
||||
};
|
||||
|
||||
// Contains constants that are set by the application and
|
||||
// cannot be modified by the user:
|
||||
Setting.constants_ = {
|
||||
'appName': 'joplin',
|
||||
'appId': 'SET_ME', // Each app should set this identifier
|
||||
}
|
||||
|
||||
export { Setting };
|
||||
68
ReactNativeClient/lib/models/tag.js
Normal file
68
ReactNativeClient/lib/models/tag.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { NoteTag } from 'lib/models/note-tag.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import lodash from 'lodash';
|
||||
|
||||
class Tag extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'tags';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_TAG;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
lodash.pull(fieldNames, 'sync_time');
|
||||
return super.serialize(item, 'tag', fieldNames);
|
||||
}
|
||||
|
||||
static async tagNoteIds(tagId) {
|
||||
let rows = await this.db().selectAll('SELECT note_id FROM note_tags WHERE tag_id = ?', [tagId]);
|
||||
let output = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
output.push(rows[i].note_id);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static async notes(tagId) {
|
||||
let noteIds = await this.tagNoteIds(tagId);
|
||||
if (!noteIds.length) return [];
|
||||
|
||||
return Note.search({
|
||||
conditions: ['id IN ("' + noteIds.join('","') + '")'],
|
||||
});
|
||||
}
|
||||
|
||||
static async addNote(tagId, noteId) {
|
||||
let hasIt = await this.hasNote(tagId, noteId);
|
||||
if (hasIt) return;
|
||||
|
||||
return NoteTag.save({
|
||||
tag_id: tagId,
|
||||
note_id: noteId,
|
||||
});
|
||||
}
|
||||
|
||||
static async removeNote(tagId, noteId) {
|
||||
let noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ? and note_id = ?', [tagId, noteId]);
|
||||
for (let i = 0; i < noteTags.length; i++) {
|
||||
await NoteTag.delete(noteTags[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
static async hasNote(tagId, noteId) {
|
||||
let r = await this.db().selectOne('SELECT note_id FROM note_tags WHERE tag_id = ? AND note_id = ? LIMIT 1', [tagId, noteId]);
|
||||
return !!r;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Tag };
|
||||
271
ReactNativeClient/lib/onedrive-api.js
Normal file
271
ReactNativeClient/lib/onedrive-api.js
Normal file
@@ -0,0 +1,271 @@
|
||||
const fetch = require('node-fetch');
|
||||
const tcpPortUsed = require('tcp-port-used');
|
||||
const http = require("http");
|
||||
const urlParser = require("url");
|
||||
const FormData = require('form-data');
|
||||
const enableServerDestroy = require('server-destroy');
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
class OneDriveApi {
|
||||
|
||||
constructor(clientId, clientSecret) {
|
||||
this.clientId_ = clientId;
|
||||
this.clientSecret_ = clientSecret;
|
||||
this.auth_ = null;
|
||||
this.listeners_ = {
|
||||
'authRefreshed': [],
|
||||
};
|
||||
}
|
||||
|
||||
dispatch(eventName, param) {
|
||||
let ls = this.listeners_[eventName];
|
||||
for (let i = 0; i < ls.length; i++) {
|
||||
ls[i](param);
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
this.listeners_[eventName].push(callback);
|
||||
}
|
||||
|
||||
tokenBaseUrl() {
|
||||
return 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
|
||||
}
|
||||
|
||||
setAuth(auth) {
|
||||
this.auth_ = auth;
|
||||
}
|
||||
|
||||
token() {
|
||||
return this.auth_ ? this.auth_.access_token : null;
|
||||
}
|
||||
|
||||
clientId() {
|
||||
return this.clientId_;
|
||||
}
|
||||
|
||||
clientSecret() {
|
||||
return this.clientSecret_;
|
||||
}
|
||||
|
||||
possibleOAuthDancePorts() {
|
||||
return [1917, 9917, 8917];
|
||||
}
|
||||
|
||||
async appDirectory() {
|
||||
let r = await this.execJson('GET', '/drive/special/approot');
|
||||
return r.parentReference.path + '/' + r.name;
|
||||
}
|
||||
|
||||
authCodeUrl(redirectUri) {
|
||||
let query = {
|
||||
client_id: this.clientId_,
|
||||
scope: 'files.readwrite offline_access',
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
};
|
||||
return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' + stringify(query);
|
||||
}
|
||||
|
||||
oneDriveErrorResponseToError(errorResponse) {
|
||||
if (!errorResponse) return new Error('Undefined error');
|
||||
|
||||
if (errorResponse.error) {
|
||||
let e = errorResponse.error;
|
||||
let output = new Error(e.message);
|
||||
if (e.code) output.code = e.code;
|
||||
if (e.innerError) output.innerError = e.innerError;
|
||||
return output;
|
||||
} else {
|
||||
return new Error(JSON.stringify(errorResponse));
|
||||
}
|
||||
}
|
||||
|
||||
async exec(method, path, query = null, data = null, options = null) {
|
||||
method = method.toUpperCase();
|
||||
|
||||
if (!options) options = {};
|
||||
if (!options.headers) options.headers = {};
|
||||
|
||||
if (method != 'GET') {
|
||||
options.method = method;
|
||||
}
|
||||
|
||||
if (method == 'PATCH' || method == 'POST') {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
if (data) data = JSON.stringify(data);
|
||||
}
|
||||
|
||||
let url = path;
|
||||
|
||||
// In general, `path` contains a path relative to the base URL, but in some
|
||||
// cases the full URL is provided (for example, when it's a URL that was
|
||||
// retrieved from the API).
|
||||
if (url.indexOf('https://') !== 0) url = 'https://graph.microsoft.com/v1.0' + path;
|
||||
|
||||
if (query) {
|
||||
url += url.indexOf('?') < 0 ? '?' : '&';
|
||||
url += stringify(query);
|
||||
}
|
||||
|
||||
if (data) options.body = data;
|
||||
|
||||
// Rare error (one Google hit) - maybe repeat the request when it happens?
|
||||
|
||||
// { error:
|
||||
// { code: 'generalException',
|
||||
// message: 'An error occurred in the data store.',
|
||||
// innerError:
|
||||
// { 'request-id': 'b4310552-c18a-45b1-bde1-68e2c2345eef',
|
||||
// date: '2017-06-29T00:15:50' } } }
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
options.headers['Authorization'] = 'bearer ' + this.token();
|
||||
|
||||
let response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
let errorResponse = await response.json();
|
||||
let error = this.oneDriveErrorResponseToError(errorResponse);
|
||||
|
||||
if (error.code == 'InvalidAuthenticationToken') {
|
||||
await this.refreshAccessToken();
|
||||
continue;
|
||||
} else {
|
||||
error.request = method + ' ' + url + ' ' + JSON.stringify(query) + ' ' + JSON.stringify(data) + ' ' + JSON.stringify(options);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw new Error('Could not execute request after multiple attempts: ' + method + ' ' + url);
|
||||
}
|
||||
|
||||
async execJson(method, path, query, data) {
|
||||
let response = await this.exec(method, path, query, data);
|
||||
let output = await response.json();
|
||||
return output;
|
||||
}
|
||||
|
||||
async execText(method, path, query, data) {
|
||||
let response = await this.exec(method, path, query, data);
|
||||
let output = await response.text();
|
||||
return output;
|
||||
}
|
||||
|
||||
async refreshAccessToken() {
|
||||
if (!this.auth_) throw new Error('Cannot refresh token: authentication data is missing');
|
||||
|
||||
let body = new FormData();
|
||||
body.append('client_id', this.clientId());
|
||||
body.append('client_secret', this.clientSecret());
|
||||
body.append('refresh_token', this.auth_.refresh_token);
|
||||
body.append('redirect_uri', 'http://localhost:1917');
|
||||
body.append('grant_type', 'refresh_token');
|
||||
|
||||
let options = {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
};
|
||||
|
||||
this.auth_ = null;
|
||||
|
||||
let response = await fetch(this.tokenBaseUrl(), options);
|
||||
if (!response.ok) {
|
||||
let msg = await response.text();
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
this.auth_ = await response.json();
|
||||
|
||||
this.dispatch('authRefreshed', this.auth_);
|
||||
}
|
||||
|
||||
async oauthDance(targetConsole = null) {
|
||||
if (targetConsole === null) targetConsole = console;
|
||||
|
||||
this.auth_ = null;
|
||||
|
||||
let ports = this.possibleOAuthDancePorts();
|
||||
let port = null;
|
||||
for (let i = 0; i < ports.length; i++) {
|
||||
let inUse = await tcpPortUsed.check(ports[i]);
|
||||
if (!inUse) {
|
||||
port = ports[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!port) throw new Error('All potential ports are in use - please report the issue at https://github.com/laurent22/joplin');
|
||||
|
||||
let authCodeUrl = this.authCodeUrl('http://localhost:' + port);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let server = http.createServer();
|
||||
let errorMessage = null;
|
||||
|
||||
server.on('request', (request, response) => {
|
||||
const query = urlParser.parse(request.url, true).query;
|
||||
|
||||
function writeResponse(code, message) {
|
||||
response.writeHead(code, {"Content-Type": "text/html"});
|
||||
response.write(message);
|
||||
response.end();
|
||||
}
|
||||
|
||||
if (!query.code) return writeResponse(400, '"code" query parameter is missing');
|
||||
|
||||
let body = new FormData();
|
||||
body.append('client_id', this.clientId());
|
||||
body.append('client_secret', this.clientSecret());
|
||||
body.append('code', query.code ? query.code : '');
|
||||
body.append('redirect_uri', 'http://localhost:' + port.toString());
|
||||
body.append('grant_type', 'authorization_code');
|
||||
|
||||
let options = {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
};
|
||||
|
||||
fetch(this.tokenBaseUrl(), options).then((r) => {
|
||||
if (!r.ok) {
|
||||
errorMessage = 'Could not retrieve auth code: ' + r.status + ': ' + r.statusText;
|
||||
writeResponse(400, errorMessage);
|
||||
targetConsole.log('');
|
||||
targetConsole.log(errorMessage);
|
||||
server.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
return r.json().then((json) => {
|
||||
this.auth_ = json;
|
||||
writeResponse(200, 'The application has been authorised - you may now close this browser tab.');
|
||||
targetConsole.log('');
|
||||
targetConsole.log('The application has been successfully authorised.');
|
||||
server.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.on('close', () => {
|
||||
if (errorMessage) {
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
resolve(this.auth_);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
|
||||
enableServerDestroy(server);
|
||||
|
||||
targetConsole.log('Please open this URL in your browser to authentify the application:');
|
||||
targetConsole.log('');
|
||||
targetConsole.log(authCodeUrl);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { OneDriveApi };
|
||||
1
ReactNativeClient/lib/package.json
Normal file
1
ReactNativeClient/lib/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "name": "lib" }
|
||||
30
ReactNativeClient/lib/path-utils.js
Normal file
30
ReactNativeClient/lib/path-utils.js
Normal file
@@ -0,0 +1,30 @@
|
||||
function dirname(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let s = path.split('/');
|
||||
s.pop();
|
||||
return s.join('/');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let s = path.split('/');
|
||||
return s[s.length - 1];
|
||||
}
|
||||
|
||||
function filename(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let output = basename(path);
|
||||
if (output.indexOf('.') < 0) return output;
|
||||
|
||||
output = output.split('.');
|
||||
output.pop();
|
||||
return output.join('.');
|
||||
}
|
||||
|
||||
function isHidden(path) {
|
||||
let b = basename(path);
|
||||
if (!b.length) throw new Error('Path empty or not a valid path: ' + path);
|
||||
return b[0] === '.';
|
||||
}
|
||||
|
||||
export { basename, dirname, filename, isHidden };
|
||||
37
ReactNativeClient/lib/promise-utils.js
Normal file
37
ReactNativeClient/lib/promise-utils.js
Normal file
@@ -0,0 +1,37 @@
|
||||
function promiseChain(chain, defaultValue = null) {
|
||||
let output = new Promise((resolve, reject) => { resolve(defaultValue); });
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
let f = chain[i];
|
||||
output = output.then(f);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function promiseWhile(callback) {
|
||||
let isDone = false;
|
||||
|
||||
function done() {
|
||||
isDone = true;
|
||||
}
|
||||
|
||||
let iterationDone = false;
|
||||
let p = callback(done).then(() => {
|
||||
iterationDone = true;
|
||||
});
|
||||
|
||||
let iid = setInterval(() => {
|
||||
if (iterationDone) {
|
||||
if (isDone) {
|
||||
clearInterval(iid);
|
||||
return;
|
||||
}
|
||||
|
||||
iterationDone = false;
|
||||
callback(done).then(() => {
|
||||
iterationDone = true;
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
export { promiseChain, promiseWhile }
|
||||
29
ReactNativeClient/lib/services/note-folder-service.js
Normal file
29
ReactNativeClient/lib/services/note-folder-service.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
|
||||
class NoteFolderService {
|
||||
|
||||
static openNoteList(folderId) {
|
||||
return Note.previews(folderId).then((notes) => {
|
||||
this.dispatch({
|
||||
type: 'NOTES_UPDATE_ALL',
|
||||
notes: notes,
|
||||
});
|
||||
|
||||
this.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Notes',
|
||||
folderId: folderId,
|
||||
});
|
||||
}).catch((error) => {
|
||||
Log.warn('Cannot load notes', error);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { NoteFolderService };
|
||||
116
ReactNativeClient/lib/string-utils.js
Normal file
116
ReactNativeClient/lib/string-utils.js
Normal file
@@ -0,0 +1,116 @@
|
||||
function removeDiacritics(str) {
|
||||
|
||||
var defaultDiacriticsRemovalMap = [
|
||||
{'base':'A', 'letters':/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g},
|
||||
{'base':'AA','letters':/[\uA732]/g},
|
||||
{'base':'AE','letters':/[\u00C6\u01FC\u01E2]/g},
|
||||
{'base':'AO','letters':/[\uA734]/g},
|
||||
{'base':'AU','letters':/[\uA736]/g},
|
||||
{'base':'AV','letters':/[\uA738\uA73A]/g},
|
||||
{'base':'AY','letters':/[\uA73C]/g},
|
||||
{'base':'B', 'letters':/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g},
|
||||
{'base':'C', 'letters':/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g},
|
||||
{'base':'D', 'letters':/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g},
|
||||
{'base':'DZ','letters':/[\u01F1\u01C4]/g},
|
||||
{'base':'Dz','letters':/[\u01F2\u01C5]/g},
|
||||
{'base':'E', 'letters':/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g},
|
||||
{'base':'F', 'letters':/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g},
|
||||
{'base':'G', 'letters':/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g},
|
||||
{'base':'H', 'letters':/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g},
|
||||
{'base':'I', 'letters':/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g},
|
||||
{'base':'J', 'letters':/[\u004A\u24BF\uFF2A\u0134\u0248]/g},
|
||||
{'base':'K', 'letters':/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g},
|
||||
{'base':'L', 'letters':/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g},
|
||||
{'base':'LJ','letters':/[\u01C7]/g},
|
||||
{'base':'Lj','letters':/[\u01C8]/g},
|
||||
{'base':'M', 'letters':/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g},
|
||||
{'base':'N', 'letters':/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g},
|
||||
{'base':'NJ','letters':/[\u01CA]/g},
|
||||
{'base':'Nj','letters':/[\u01CB]/g},
|
||||
{'base':'O', 'letters':/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g},
|
||||
{'base':'OI','letters':/[\u01A2]/g},
|
||||
{'base':'OO','letters':/[\uA74E]/g},
|
||||
{'base':'OU','letters':/[\u0222]/g},
|
||||
{'base':'P', 'letters':/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g},
|
||||
{'base':'Q', 'letters':/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g},
|
||||
{'base':'R', 'letters':/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g},
|
||||
{'base':'S', 'letters':/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g},
|
||||
{'base':'T', 'letters':/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g},
|
||||
{'base':'TZ','letters':/[\uA728]/g},
|
||||
{'base':'U', 'letters':/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g},
|
||||
{'base':'V', 'letters':/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g},
|
||||
{'base':'VY','letters':/[\uA760]/g},
|
||||
{'base':'W', 'letters':/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g},
|
||||
{'base':'X', 'letters':/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g},
|
||||
{'base':'Y', 'letters':/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g},
|
||||
{'base':'Z', 'letters':/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g},
|
||||
{'base':'a', 'letters':/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g},
|
||||
{'base':'aa','letters':/[\uA733]/g},
|
||||
{'base':'ae','letters':/[\u00E6\u01FD\u01E3]/g},
|
||||
{'base':'ao','letters':/[\uA735]/g},
|
||||
{'base':'au','letters':/[\uA737]/g},
|
||||
{'base':'av','letters':/[\uA739\uA73B]/g},
|
||||
{'base':'ay','letters':/[\uA73D]/g},
|
||||
{'base':'b', 'letters':/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g},
|
||||
{'base':'c', 'letters':/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g},
|
||||
{'base':'d', 'letters':/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g},
|
||||
{'base':'dz','letters':/[\u01F3\u01C6]/g},
|
||||
{'base':'e', 'letters':/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g},
|
||||
{'base':'f', 'letters':/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g},
|
||||
{'base':'g', 'letters':/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g},
|
||||
{'base':'h', 'letters':/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g},
|
||||
{'base':'hv','letters':/[\u0195]/g},
|
||||
{'base':'i', 'letters':/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g},
|
||||
{'base':'j', 'letters':/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g},
|
||||
{'base':'k', 'letters':/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g},
|
||||
{'base':'l', 'letters':/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g},
|
||||
{'base':'lj','letters':/[\u01C9]/g},
|
||||
{'base':'m', 'letters':/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g},
|
||||
{'base':'n', 'letters':/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g},
|
||||
{'base':'nj','letters':/[\u01CC]/g},
|
||||
{'base':'o', 'letters':/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g},
|
||||
{'base':'oi','letters':/[\u01A3]/g},
|
||||
{'base':'ou','letters':/[\u0223]/g},
|
||||
{'base':'oo','letters':/[\uA74F]/g},
|
||||
{'base':'p','letters':/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g},
|
||||
{'base':'q','letters':/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g},
|
||||
{'base':'r','letters':/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g},
|
||||
{'base':'s','letters':/[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g},
|
||||
{'base':'t','letters':/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g},
|
||||
{'base':'tz','letters':/[\uA729]/g},
|
||||
{'base':'u','letters':/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g},
|
||||
{'base':'v','letters':/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g},
|
||||
{'base':'vy','letters':/[\uA761]/g},
|
||||
{'base':'w','letters':/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g},
|
||||
{'base':'x','letters':/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g},
|
||||
{'base':'y','letters':/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g},
|
||||
{'base':'z','letters':/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g}
|
||||
];
|
||||
|
||||
for(var i=0; i<defaultDiacriticsRemovalMap.length; i++) {
|
||||
str = str.replace(defaultDiacriticsRemovalMap[i].letters, defaultDiacriticsRemovalMap[i].base);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function escapeFilename(s, maxLength = 32) {
|
||||
let output = removeDiacritics(s);
|
||||
output = output.replace("\n\r", " ");
|
||||
output = output.replace("\r\n", " ");
|
||||
output = output.replace("\r", " ");
|
||||
output = output.replace("\n", " ");
|
||||
output = output.replace("\t", " ");
|
||||
output = output.replace("\0", "");
|
||||
|
||||
const unsafe = "/\\:*\"'?<>|"; // In Windows
|
||||
for (let i = 0; i < unsafe.length; i++) {
|
||||
output = output.replace(unsafe[i], '_');
|
||||
}
|
||||
|
||||
if (output.toLowerCase() == 'nul') output = 'n_l'; // For Windows...
|
||||
|
||||
return output.substr(0, maxLength);
|
||||
}
|
||||
|
||||
export { removeDiacritics, escapeFilename };
|
||||
375
ReactNativeClient/lib/synchronizer.js
Normal file
375
ReactNativeClient/lib/synchronizer.js
Normal file
@@ -0,0 +1,375 @@
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Resource } from 'lib/models/resource.js';
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { sprintf } from 'sprintf-js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { Logger } from 'lib/logger.js'
|
||||
import moment from 'moment';
|
||||
|
||||
class Synchronizer {
|
||||
|
||||
constructor(db, api) {
|
||||
this.state_ = 'idle';
|
||||
this.db_ = db;
|
||||
this.api_ = api;
|
||||
this.syncDirName_ = '.sync';
|
||||
this.resourceDirName_ = '.resource';
|
||||
this.logger_ = new Logger();
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.state_;
|
||||
}
|
||||
|
||||
db() {
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
api() {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
logSyncOperation(action, local, remote, reason) {
|
||||
let line = ['Sync'];
|
||||
line.push(action);
|
||||
line.push(reason);
|
||||
|
||||
if (local) {
|
||||
let s = [];
|
||||
s.push(local.id);
|
||||
if ('title' in local) s.push('"' + local.title + '"');
|
||||
line.push('(Local ' + s.join(', ') + ')');
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
let s = [];
|
||||
s.push(remote.id);
|
||||
if ('title' in remote) s.push('"' + remote.title + '"');
|
||||
line.push('(Remote ' + s.join(', ') + ')');
|
||||
}
|
||||
|
||||
this.logger().debug(line.join(': '));
|
||||
}
|
||||
|
||||
async logSyncSummary(report) {
|
||||
for (let n in report) {
|
||||
if (!report.hasOwnProperty(n)) continue;
|
||||
this.logger().info(n + ': ' + (report[n] ? report[n] : '-'));
|
||||
}
|
||||
let folderCount = await Folder.count();
|
||||
let noteCount = await Note.count();
|
||||
let resourceCount = await Resource.count();
|
||||
this.logger().info('Total folders: ' + folderCount);
|
||||
this.logger().info('Total notes: ' + noteCount);
|
||||
this.logger().info('Total resources: ' + resourceCount);
|
||||
}
|
||||
|
||||
randomFailure(options, name) {
|
||||
if (!options.randomFailures) return false;
|
||||
|
||||
if (this.randomFailureChoice_ == name) {
|
||||
options.onMessage('Random failure: ' + name);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async start(options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.onProgress) options.onProgress = function(o) {};
|
||||
|
||||
if (this.state() != 'idle') {
|
||||
this.logger().warn('Synchronization is already in progress. State: ' + this.state());
|
||||
return;
|
||||
}
|
||||
|
||||
this.randomFailureChoice_ = Math.floor(Math.random() * 5);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// First, find all the items that have been changed since the
|
||||
// last sync and apply the changes to remote.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
let synchronizationId = time.unixMs().toString();
|
||||
this.logger().info('Starting synchronization... [' + synchronizationId + ']');
|
||||
|
||||
this.state_ = 'started';
|
||||
|
||||
let report = {
|
||||
remotesToUpdate: 0,
|
||||
remotesToDelete: 0,
|
||||
localsToUdpate: 0,
|
||||
localsToDelete: 0,
|
||||
|
||||
createLocal: 0,
|
||||
updateLocal: 0,
|
||||
deleteLocal: 0,
|
||||
createRemote: 0,
|
||||
updateRemote: 0,
|
||||
deleteRemote: 0,
|
||||
itemConflict: 0,
|
||||
noteConflict: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.api().mkdir(this.syncDirName_);
|
||||
await this.api().mkdir(this.resourceDirName_);
|
||||
|
||||
let donePaths = [];
|
||||
while (true) {
|
||||
let result = await BaseItem.itemsThatNeedSync();
|
||||
let locals = result.items;
|
||||
|
||||
report.remotesToUpdate += locals.length;
|
||||
options.onProgress(report);
|
||||
|
||||
for (let i = 0; i < locals.length; i++) {
|
||||
let local = locals[i];
|
||||
let ItemClass = BaseItem.itemClass(local);
|
||||
let path = BaseItem.systemPath(local);
|
||||
|
||||
// Safety check to avoid infinite loops:
|
||||
if (donePaths.indexOf(path) > 0) throw new Error(sprintf('Processing a path that has already been done: %s. sync_time was not updated?', path));
|
||||
|
||||
let remote = await this.api().stat(path);
|
||||
let content = await ItemClass.serialize(local);
|
||||
let action = null;
|
||||
let updateSyncTimeOnly = true;
|
||||
let reason = '';
|
||||
|
||||
if (!remote) {
|
||||
if (!local.sync_time) {
|
||||
action = 'createRemote';
|
||||
reason = 'remote does not exist, and local is new and has never been synced';
|
||||
} else {
|
||||
// Note or item was modified after having been deleted remotely
|
||||
action = local.type_ == BaseModel.TYPE_NOTE ? 'noteConflict' : 'itemConflict';
|
||||
reason = 'remote has been deleted, but local has changes';
|
||||
}
|
||||
} else {
|
||||
if (remote.updated_time > local.sync_time) {
|
||||
// Since, in this loop, we are only dealing with notes that require sync, if the
|
||||
// remote has been modified after the sync time, it means both notes have been
|
||||
// modified and so there's a conflict.
|
||||
action = local.type_ == BaseModel.TYPE_NOTE ? 'noteConflict' : 'itemConflict';
|
||||
reason = 'both remote and local have changes';
|
||||
} else {
|
||||
action = 'updateRemote';
|
||||
reason = 'local has changes';
|
||||
}
|
||||
}
|
||||
|
||||
this.logSyncOperation(action, local, remote, reason);
|
||||
|
||||
|
||||
if (local.type_ == BaseModel.TYPE_RESOURCE && (action == 'createRemote' || (action == 'itemConflict' && remote))) {
|
||||
let remoteContentPath = this.resourceDirName_ + '/' + local.id;
|
||||
let resourceContent = await Resource.content(local);
|
||||
await this.api().put(remoteContentPath, resourceContent);
|
||||
}
|
||||
|
||||
if (action == 'createRemote' || action == 'updateRemote') {
|
||||
|
||||
// Make the operation atomic by doing the work on a copy of the file
|
||||
// and then copying it back to the original location.
|
||||
let tempPath = this.syncDirName_ + '/' + path + '_' + time.unixMs();
|
||||
|
||||
await this.api().put(tempPath, content);
|
||||
await this.api().setTimestamp(tempPath, local.updated_time);
|
||||
await this.api().move(tempPath, path);
|
||||
|
||||
if (this.randomFailure(options, 0)) return;
|
||||
|
||||
await ItemClass.save({ id: local.id, sync_time: time.unixMs(), type_: local.type_ }, { autoTimestamp: false });
|
||||
|
||||
} else if (action == 'itemConflict') {
|
||||
|
||||
if (remote) {
|
||||
let remoteContent = await this.api().get(path);
|
||||
local = await BaseItem.unserialize(remoteContent);
|
||||
|
||||
local.sync_time = time.unixMs();
|
||||
await ItemClass.save(local, { autoTimestamp: false });
|
||||
} else {
|
||||
await ItemClass.delete(local.id);
|
||||
}
|
||||
|
||||
} else if (action == 'noteConflict') {
|
||||
|
||||
// - Create a duplicate of local note into Conflicts folder (to preserve the user's changes)
|
||||
// - Overwrite local note with remote note
|
||||
let conflictedNote = Object.assign({}, local);
|
||||
delete conflictedNote.id;
|
||||
conflictedNote.is_conflict = 1;
|
||||
await Note.save(conflictedNote, { autoTimestamp: false });
|
||||
|
||||
if (this.randomFailure(options, 1)) return;
|
||||
|
||||
if (remote) {
|
||||
let remoteContent = await this.api().get(path);
|
||||
local = await BaseItem.unserialize(remoteContent);
|
||||
|
||||
local.sync_time = time.unixMs();
|
||||
await ItemClass.save(local, { autoTimestamp: false });
|
||||
} else {
|
||||
await ItemClass.delete(local.id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
report[action]++;
|
||||
|
||||
donePaths.push(path);
|
||||
|
||||
options.onProgress(report);
|
||||
}
|
||||
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Delete the remote items that have been deleted locally.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
let deletedItems = await BaseItem.deletedItems();
|
||||
report.remotesToDelete = deletedItems.length;
|
||||
options.onProgress(report);
|
||||
for (let i = 0; i < deletedItems.length; i++) {
|
||||
let item = deletedItems[i];
|
||||
let path = BaseItem.systemPath(item.item_id)
|
||||
this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted');
|
||||
await this.api().delete(path);
|
||||
if (this.randomFailure(options, 2)) return;
|
||||
await BaseItem.remoteDeletedItem(item.item_id);
|
||||
|
||||
report['deleteRemote']++;
|
||||
options.onProgress(report);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Loop through all the remote items, find those that
|
||||
// have been updated, and apply the changes to local.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
// At this point all the local items that have changed have been pushed to remote
|
||||
// or handled as conflicts, so no conflict is possible after this.
|
||||
|
||||
let remoteIds = [];
|
||||
let context = null;
|
||||
|
||||
while (true) {
|
||||
let listResult = await this.api().list('', { context: context });
|
||||
let remotes = listResult.items;
|
||||
for (let i = 0; i < remotes.length; i++) {
|
||||
let remote = remotes[i];
|
||||
let path = remote.path;
|
||||
|
||||
remoteIds.push(BaseItem.pathToId(path));
|
||||
if (donePaths.indexOf(path) > 0) continue;
|
||||
|
||||
let action = null;
|
||||
let reason = '';
|
||||
let local = await BaseItem.loadItemByPath(path);
|
||||
if (!local) {
|
||||
action = 'createLocal';
|
||||
reason = 'remote exists but local does not';
|
||||
} else {
|
||||
if (remote.updated_time > local.updated_time) {
|
||||
action = 'updateLocal';
|
||||
reason = sprintf('remote is more recent than local');
|
||||
}
|
||||
}
|
||||
|
||||
if (!action) continue;
|
||||
|
||||
report.localsToUdpate++;
|
||||
options.onProgress(report);
|
||||
|
||||
if (action == 'createLocal' || action == 'updateLocal') {
|
||||
let content = await this.api().get(path);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
content = await BaseItem.unserialize(content);
|
||||
let ItemClass = BaseItem.itemClass(content);
|
||||
|
||||
let newContent = Object.assign({}, content);
|
||||
newContent.sync_time = time.unixMs();
|
||||
let options = {
|
||||
autoTimestamp: false,
|
||||
applyMetadataChanges: true,
|
||||
};
|
||||
if (action == 'createLocal') options.isNew = true;
|
||||
|
||||
if (newContent.type_ == BaseModel.TYPE_RESOURCE && action == 'createLocal') {
|
||||
let localResourceContentPath = Resource.fullPath(newContent);
|
||||
let remoteResourceContentPath = this.resourceDirName_ + '/' + newContent.id;
|
||||
let remoteResourceContent = await this.api().get(remoteResourceContentPath, { encoding: 'binary' });
|
||||
await Resource.setContent(newContent, remoteResourceContent);
|
||||
}
|
||||
|
||||
await ItemClass.save(newContent, options);
|
||||
|
||||
this.logSyncOperation(action, local, content, reason);
|
||||
} else {
|
||||
this.logSyncOperation(action, local, remote, reason);
|
||||
}
|
||||
|
||||
report[action]++;
|
||||
|
||||
options.onProgress(report);
|
||||
}
|
||||
|
||||
if (!listResult.hasMore) break;
|
||||
context = listResult.context;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Search, among the local IDs, those that don't exist remotely, which
|
||||
// means the item has been deleted.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
if (this.randomFailure(options, 4)) return;
|
||||
|
||||
let items = await BaseItem.syncedItems();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
if (remoteIds.indexOf(item.id) < 0) {
|
||||
report.localsToDelete++;
|
||||
options.onProgress(report);
|
||||
this.logSyncOperation('deleteLocal', { id: item.id }, null, 'remote has been deleted');
|
||||
|
||||
let ItemClass = BaseItem.itemClass(item);
|
||||
await ItemClass.delete(item.id, { trackDeleted: false });
|
||||
report['deleteLocal']++;
|
||||
options.onProgress(report);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger().error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
options.onProgress(report);
|
||||
|
||||
this.logger().info('Synchronization complete [' + synchronizationId + ']:');
|
||||
await this.logSyncSummary(report);
|
||||
|
||||
this.state_ = 'idle';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Synchronizer };
|
||||
35
ReactNativeClient/lib/time-utils.js
Normal file
35
ReactNativeClient/lib/time-utils.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import moment from 'moment';
|
||||
|
||||
let time = {
|
||||
|
||||
unix() {
|
||||
return Math.floor((new Date()).getTime() / 1000);
|
||||
},
|
||||
|
||||
unixMs() {
|
||||
return (new Date()).getTime();
|
||||
},
|
||||
|
||||
unixMsToS(ms) {
|
||||
return Math.floor(ms / 1000);
|
||||
},
|
||||
|
||||
unixMsToIso(ms) {
|
||||
return moment.unix(ms / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
|
||||
},
|
||||
|
||||
msleep(ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
},
|
||||
|
||||
sleep(seconds) {
|
||||
return this.msleep(seconds * 1000);
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export { time };
|
||||
11
ReactNativeClient/lib/uuid.js
Normal file
11
ReactNativeClient/lib/uuid.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import createUuidV4 from 'uuid/v4';
|
||||
|
||||
const uuid = {
|
||||
|
||||
create: function() {
|
||||
return createUuidV4().replace(/-/g, '');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { uuid };
|
||||
Reference in New Issue
Block a user