mirror of
https://github.com/laurent22/joplin.git
synced 2025-02-01 19:15:01 +02:00
Trying CLI client
This commit is contained in:
parent
5b5e160586
commit
cb2ca28e09
3
CliClient/.babelrc
Normal file
3
CliClient/.babelrc
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["env", "react"]
|
||||
}
|
2
CliClient/.gitignore
vendored
Normal file
2
CliClient/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
build/
|
||||
node_modules/
|
17
CliClient/app/import-enex.js
Normal file
17
CliClient/app/import-enex.js
Normal file
@ -0,0 +1,17 @@
|
||||
require('app-module-path').addPath(__dirname);
|
||||
|
||||
import { WebApi } from 'src/web-api.js'
|
||||
|
||||
// setTimeout(() => {
|
||||
// console.info('ici');
|
||||
// }, 1000);
|
||||
|
||||
|
||||
let api = new WebApi('http://joplin.local');
|
||||
|
||||
api.post('sessions', null, {
|
||||
email: 'laurent@cozic.net',
|
||||
password: '12345678',
|
||||
}).then((session) => {
|
||||
console.info(session);
|
||||
});
|
224
CliClient/app/src/base-model.js
Normal file
224
CliClient/app/src/base-model.js
Normal file
@ -0,0 +1,224 @@
|
||||
import { Log } from 'src/log.js';
|
||||
import { Database } from 'src/database.js';
|
||||
import { uuid } from 'src/uuid.js';
|
||||
|
||||
class BaseModel {
|
||||
|
||||
static tableName() {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
|
||||
static useUuid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static itemType() {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
|
||||
static trackChanges() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static byId(items, id) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].id == id) return items[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static hasField(name) {
|
||||
let fields = this.fieldNames();
|
||||
return fields.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
static fieldNames() {
|
||||
return this.db().tableFieldNames(this.tableName());
|
||||
}
|
||||
|
||||
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 fromApiResult(apiResult) {
|
||||
let fieldNames = this.fieldNames();
|
||||
let output = {};
|
||||
for (let i = 0; i < fieldNames.length; i++) {
|
||||
let f = fieldNames[i];
|
||||
output[f] = f in apiResult ? apiResult[f] : null;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static modOptions(options) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
} else {
|
||||
options = Object.assign({}, options);
|
||||
}
|
||||
if (!('trackChanges' in options)) options.trackChanges = true;
|
||||
if (!('isNew' in options)) options.isNew = 'auto';
|
||||
return options;
|
||||
}
|
||||
|
||||
static load(id) {
|
||||
return this.db().selectOne('SELECT * FROM ' + this.tableName() + ' WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
static applyPatch(model, patch) {
|
||||
model = Object.assign({}, model);
|
||||
for (let n in patch) {
|
||||
if (!patch.hasOwnProperty(n)) continue;
|
||||
model[n] = patch[n];
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
static diffObjects(oldModel, newModel) {
|
||||
let output = {};
|
||||
for (let n in newModel) {
|
||||
if (!newModel.hasOwnProperty(n)) continue;
|
||||
if (!(n in oldModel) || newModel[n] !== oldModel[n]) {
|
||||
output[n] = newModel[n];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static saveQuery(o, isNew = 'auto') {
|
||||
if (isNew == 'auto') isNew = !o.id;
|
||||
|
||||
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 itemId = o.id;
|
||||
|
||||
if (!o.updated_time && this.hasField('updated_time')) {
|
||||
o.updated_time = Math.round((new Date()).getTime() / 1000);
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
if (this.useUuid() && !o.id) {
|
||||
o = Object.assign({}, o);
|
||||
itemId = uuid.create();
|
||||
o.id = itemId;
|
||||
}
|
||||
|
||||
if (!o.created_time && this.hasField('created_time')) {
|
||||
o.created_time = Math.round((new Date()).getTime() / 1000);
|
||||
}
|
||||
|
||||
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 = itemId;
|
||||
|
||||
Log.info('Saving', o);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
static save(o, options = null) {
|
||||
options = this.modOptions(options);
|
||||
|
||||
let isNew = options.isNew == 'auto' ? !o.id : options.isNew;
|
||||
let query = this.saveQuery(o, isNew);
|
||||
|
||||
return this.db().transaction((tx) => {
|
||||
tx.executeSql(query.sql, query.params);
|
||||
|
||||
if (options.trackChanges && this.trackChanges()) {
|
||||
// Cannot import this class the normal way due to cyclical dependencies between Change and BaseModel
|
||||
// which are not handled by React Native.
|
||||
const { Change } = require('src/models/change.js');
|
||||
|
||||
if (isNew) {
|
||||
let change = Change.newChange();
|
||||
change.type = Change.TYPE_CREATE;
|
||||
change.item_id = query.id;
|
||||
change.item_type = this.itemType();
|
||||
|
||||
let changeQuery = Change.saveQuery(change);
|
||||
tx.executeSql(changeQuery.sql, changeQuery.params);
|
||||
} else {
|
||||
for (let n in o) {
|
||||
if (!o.hasOwnProperty(n)) continue;
|
||||
if (n == 'id') continue;
|
||||
|
||||
let change = Change.newChange();
|
||||
change.type = Change.TYPE_UPDATE;
|
||||
change.item_id = query.id;
|
||||
change.item_type = this.itemType();
|
||||
change.item_field = n;
|
||||
|
||||
let changeQuery = Change.saveQuery(change);
|
||||
tx.executeSql(changeQuery.sql, changeQuery.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).then((r) => {
|
||||
o = Object.assign({}, o);
|
||||
o.id = query.id;
|
||||
return o;
|
||||
}).catch((error) => {
|
||||
Log.error('Cannot save model', error);
|
||||
});
|
||||
}
|
||||
|
||||
static delete(id, options = null) {
|
||||
options = this.modOptions(options);
|
||||
|
||||
if (!id) {
|
||||
Log.warn('Cannot delete object without an ID');
|
||||
return;
|
||||
}
|
||||
|
||||
return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id = ?', [id]).then(() => {
|
||||
if (options.trackChanges && this.trackChanges()) {
|
||||
const { Change } = require('src/models/change.js');
|
||||
|
||||
let change = Change.newChange();
|
||||
change.type = Change.TYPE_DELETE;
|
||||
change.item_id = id;
|
||||
change.item_type = this.itemType();
|
||||
|
||||
return Change.save(change);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static db() {
|
||||
if (!this.db_) throw new Error('Accessing database before it has been initialised');
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BaseModel.ITEM_TYPE_NOTE = 1;
|
||||
BaseModel.ITEM_TYPE_FOLDER = 2;
|
||||
BaseModel.tableInfo_ = null;
|
||||
BaseModel.tableKeys_ = null;
|
||||
BaseModel.db_ = null;
|
||||
|
||||
export { BaseModel };
|
13
CliClient/app/src/base-service.js
Normal file
13
CliClient/app/src/base-service.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { Registry } from 'src/registry.js';
|
||||
|
||||
class BaseService {
|
||||
|
||||
constructor() {}
|
||||
|
||||
api() {
|
||||
return Registry.api();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { BaseService };
|
73
CliClient/app/src/components/action-button.js
Normal file
73
CliClient/app/src/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 'src/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
CliClient/app/src/components/checkbox.js
Normal file
44
CliClient/app/src/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
CliClient/app/src/components/folder-list.js
Normal file
27
CliClient/app/src/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 'src/log.js';
|
||||
import { ItemListComponent } from 'src/components/item-list.js';
|
||||
import { Note } from 'src/models/note.js';
|
||||
import { Folder } from 'src/models/folder.js';
|
||||
import { _ } from 'src/locale.js';
|
||||
import { NoteFolderService } from 'src/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
CliClient/app/src/components/item-list.js
Normal file
77
CliClient/app/src/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 'src/log.js';
|
||||
import { _ } from 'src/locale.js';
|
||||
import { Checkbox } from 'src/components/checkbox.js';
|
||||
import { NoteFolderService } from 'src/services/note-folder-service.js';
|
||||
import { Note } from 'src/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
CliClient/app/src/components/note-list.js
Normal file
26
CliClient/app/src/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 'src/log.js';
|
||||
import { ItemListComponent } from 'src/components/item-list.js';
|
||||
import { _ } from 'src/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
CliClient/app/src/components/screen-header.js
Normal file
120
CliClient/app/src/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 'src/log.js';
|
||||
import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu';
|
||||
import { _ } from 'src/locale.js';
|
||||
import { Setting } from 'src/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={this.menu_select}>
|
||||
<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 };
|
71
CliClient/app/src/components/screens/folder.js
Normal file
71
CliClient/app/src/components/screens/folder.js
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'src/log.js'
|
||||
import { Folder } from 'src/models/folder.js'
|
||||
import { ScreenHeader } from 'src/components/screen-header.js';
|
||||
import { NoteFolderService } from 'src/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() {
|
||||
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={this.title_changeText} />
|
||||
<Button title="Save folder" onPress={this.saveFolderButton_press} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const FolderScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folderId: state.selectedFolderId,
|
||||
};
|
||||
}
|
||||
)(FolderScreenComponent)
|
||||
|
||||
export { FolderScreen };
|
35
CliClient/app/src/components/screens/folders.js
Normal file
35
CliClient/app/src/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 'src/log.js'
|
||||
import { FolderList } from 'src/components/folder-list.js'
|
||||
import { ScreenHeader } from 'src/components/screen-header.js';
|
||||
import { _ } from 'src/locale.js';
|
||||
import { ActionButton } from 'src/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
CliClient/app/src/components/screens/loading.js
Normal file
31
CliClient/app/src/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 'src/log.js'
|
||||
import { Folder } from 'src/models/folder.js'
|
||||
import { ScreenHeader } from 'src/components/screen-header.js';
|
||||
import { NoteFolderService } from 'src/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 };
|
92
CliClient/app/src/components/screens/login.js
Normal file
92
CliClient/app/src/components/screens/login.js
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput, Text } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'src/log.js'
|
||||
import { Registry } from 'src/registry.js';
|
||||
import { Setting } from 'src/models/setting.js';
|
||||
import { ScreenHeader } from 'src/components/screen-header.js';
|
||||
import { _ } from 'src/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={this.email_changeText} keyboardType="email-address" />
|
||||
<TextInput value={this.state.password} onChangeText={this.password_changeText} 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 };
|
112
CliClient/app/src/components/screens/note.js
Normal file
112
CliClient/app/src/components/screens/note.js
Normal file
@ -0,0 +1,112 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'src/log.js'
|
||||
import { Note } from 'src/models/note.js'
|
||||
import { Registry } from 'src/registry.js'
|
||||
import { ScreenHeader } from 'src/components/screen-header.js';
|
||||
import { Checkbox } from 'src/components/checkbox.js'
|
||||
import { NoteFolderService } from 'src/services/note-folder-service.js';
|
||||
import { _ } from 'src/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() {
|
||||
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={this.title_changeText} />
|
||||
</View>
|
||||
<TextInput style={{flex: 1, textAlignVertical: 'top'}} multiline={true} value={note.body} onChangeText={this.body_changeText} />
|
||||
{ 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 };
|
82
CliClient/app/src/components/screens/notes.js
Normal file
82
CliClient/app/src/components/screens/notes.js
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, Picker } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'src/log.js'
|
||||
import { NoteList } from 'src/components/note-list.js'
|
||||
import { Folder } from 'src/models/folder.js'
|
||||
import { ScreenHeader } from 'src/components/screen-header.js';
|
||||
import { MenuOption, Text } from 'react-native-popup-menu';
|
||||
import { _ } from 'src/locale.js';
|
||||
import { ActionButton } from 'src/components/action-button.js';
|
||||
|
||||
class NotesScreenComponent extends React.Component {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
createNoteButton_press() {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Note',
|
||||
});
|
||||
}
|
||||
|
||||
createFolderButton_press() {
|
||||
this.props.dispatch({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Folder',
|
||||
});
|
||||
}
|
||||
|
||||
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
CliClient/app/src/components/side-menu-content.js
Normal file
78
CliClient/app/src/components/side-menu-content.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { connect } from 'react-redux'
|
||||
import { Button } from 'react-native';
|
||||
import { Log } from 'src/log.js';
|
||||
import { Note } from 'src/models/note.js';
|
||||
import { NoteFolderService } from 'src/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 + (f.is_default ? ' *' : '');
|
||||
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
CliClient/app/src/components/side-menu.js
Normal file
16
CliClient/app/src/components/side-menu.js
Normal file
@ -0,0 +1,16 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'src/log.js';
|
||||
import SideMenu_ from 'react-native-side-menu';
|
||||
|
||||
class SideMenuComponent extends SideMenu_ {};
|
||||
|
||||
const SideMenu = connect(
|
||||
(state) => {
|
||||
return {
|
||||
isOpen: state.showSideMenu,
|
||||
};
|
||||
}
|
||||
)(SideMenuComponent)
|
||||
|
||||
export { SideMenu };
|
390
CliClient/app/src/database.js
Normal file
390
CliClient/app/src/database.js
Normal file
@ -0,0 +1,390 @@
|
||||
import SQLite from 'react-native-sqlite-storage';
|
||||
import { Log } from 'src/log.js';
|
||||
import { uuid } from 'src/uuid.js';
|
||||
import { promiseChain } from 'src/promise-chain.js';
|
||||
import { _ } from 'src/locale.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 DEFAULT 0,
|
||||
updated_time INT NOT NULL DEFAULT 0,
|
||||
is_default BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
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 DEFAULT 0,
|
||||
updated_time INT NOT NULL DEFAULT 0,
|
||||
latitude NUMERIC NOT NULL DEFAULT 0,
|
||||
longitude NUMERIC NOT NULL DEFAULT 0,
|
||||
altitude NUMERIC NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT "",
|
||||
author TEXT NOT NULL DEFAULT "",
|
||||
source_url TEXT NOT NULL DEFAULT "",
|
||||
is_todo BOOLEAN NOT NULL DEFAULT 0,
|
||||
todo_due INT NOT NULL DEFAULT 0,
|
||||
todo_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||
source_application TEXT NOT NULL DEFAULT "",
|
||||
application_data TEXT NOT NULL DEFAULT "",
|
||||
\`order\` INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
created_time INT,
|
||||
updated_time INT
|
||||
);
|
||||
|
||||
CREATE TABLE note_tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
note_id TEXT,
|
||||
tag_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE resources (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
mime TEXT,
|
||||
filename TEXT,
|
||||
created_time INT,
|
||||
updated_time INT
|
||||
);
|
||||
|
||||
CREATE TABLE note_resources (
|
||||
id INTEGER PRIMARY KEY,
|
||||
note_id TEXT,
|
||||
resource_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE version (
|
||||
version INT
|
||||
);
|
||||
|
||||
CREATE TABLE changes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
\`type\` INT,
|
||||
item_id TEXT,
|
||||
item_type INT,
|
||||
item_field TEXT
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
INSERT INTO version (version) VALUES (1);
|
||||
`;
|
||||
|
||||
class Database {
|
||||
|
||||
constructor() {
|
||||
this.debugMode_ = false;
|
||||
this.initialized_ = false;
|
||||
this.tableFields_ = null;
|
||||
}
|
||||
|
||||
setDebugEnabled(v) {
|
||||
SQLite.DEBUG(v);
|
||||
this.debugMode_ = v;
|
||||
}
|
||||
|
||||
debugMode() {
|
||||
return this.debugMode_;
|
||||
}
|
||||
|
||||
initialized() {
|
||||
return this.initialized_;
|
||||
}
|
||||
|
||||
open() {
|
||||
this.db_ = SQLite.openDatabase({ name: '/storage/emulated/0/Download/joplin-32.sqlite' }, (db) => {
|
||||
Log.info('Database was open successfully');
|
||||
}, (error) => {
|
||||
Log.error('Cannot open database: ', error);
|
||||
});
|
||||
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
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_BOOLEAN) return !!Number(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) {
|
||||
if (!this.debugMode()) return;
|
||||
//Log.debug('DB: ' + sql, params);
|
||||
}
|
||||
|
||||
selectOne(sql, params = null) {
|
||||
this.logQuery(sql, params);
|
||||
|
||||
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) {
|
||||
this.logQuery(sql, params);
|
||||
|
||||
return this.exec(sql, params);
|
||||
}
|
||||
|
||||
exec(sql, params = null) {
|
||||
this.logQuery(sql, params);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.executeSql(sql, params, (r) => {
|
||||
resolve(r);
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
executeSql(sql, params = null) {
|
||||
return this.exec(sql, params);
|
||||
}
|
||||
|
||||
static insertQuery(tableName, data) {
|
||||
let keySql= '';
|
||||
let valueSql = '';
|
||||
let params = [];
|
||||
for (let key in data) {
|
||||
if (!data.hasOwnProperty(key)) 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) {
|
||||
let sql = '';
|
||||
let params = [];
|
||||
for (let key in data) {
|
||||
if (!data.hasOwnProperty(key)) 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,
|
||||
};
|
||||
}
|
||||
|
||||
transaction(readyCallack) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.transaction(
|
||||
readyCallack,
|
||||
(error) => { reject(error); },
|
||||
() => { resolve(); }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
refreshTableFields() {
|
||||
return this.exec('SELECT name FROM sqlite_master WHERE type="table"').then((tableResults) => {
|
||||
let chain = [];
|
||||
for (let i = 0; i < tableResults.rows.length; i++) {
|
||||
let row = tableResults.rows.item(i);
|
||||
let tableName = row.name;
|
||||
if (tableName == 'android_metadata') continue;
|
||||
if (tableName == 'table_fields') continue;
|
||||
|
||||
chain.push((queries) => {
|
||||
if (!queries) queries = [];
|
||||
return this.exec('PRAGMA table_info("' + tableName + '")').then((pragmaResult) => {
|
||||
for (let i = 0; i < pragmaResult.rows.length; i++) {
|
||||
let item = pragmaResult.rows.item(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 queries;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain);
|
||||
}).then((queries) => {
|
||||
return this.transaction((tx) => {
|
||||
tx.executeSql('DELETE FROM table_fields');
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
tx.executeSql(queries[i].sql, queries[i].params);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initialize() {
|
||||
Log.info('Checking for database schema update...');
|
||||
|
||||
return this.selectOne('SELECT * FROM version LIMIT 1').then((row) => {
|
||||
Log.info('Current database version', row);
|
||||
// TODO: version update logic
|
||||
|
||||
// TODO: only do this if db has been updated:
|
||||
return this.refreshTableFields();
|
||||
}).then(() => {
|
||||
return this.exec('SELECT * FROM table_fields').then((r) => {
|
||||
this.tableFields_ = {};
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
let row = r.rows.item(i);
|
||||
if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = [];
|
||||
this.tableFields_[row.table_name].push({
|
||||
name: row.field_name,
|
||||
type: row.field_type,
|
||||
default: Database.formatValue(row.field_type, row.field_default),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// }).then(() => {
|
||||
// let p = this.exec('DELETE FROM notes').then(() => {
|
||||
// return this.exec('DELETE FROM folders');
|
||||
// }).then(() => {
|
||||
// return this.exec('DELETE FROM changes');
|
||||
// }).then(() => {
|
||||
// return this.exec('DELETE FROM settings WHERE `key` = "sync.lastRevId"');
|
||||
// });
|
||||
|
||||
// return p.then(() => {
|
||||
// return this.exec('UPDATE settings SET `value` = "' + uuid.create() + '" WHERE `key` = "clientId"');
|
||||
// }).then(() => {
|
||||
// return this.exec('DELETE FROM settings WHERE `key` != "clientId"');
|
||||
// });
|
||||
|
||||
// return p;
|
||||
|
||||
|
||||
|
||||
|
||||
}).catch((error) => {
|
||||
if (error && error.code != 0) {
|
||||
Log.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Assume that error was:
|
||||
// { message: 'no such table: version (code 1): , while compiling: SELECT * FROM version', code: 0 }
|
||||
// which means the database is empty and the tables need to be created.
|
||||
// If it's any other error there's nothing we can do anyway.
|
||||
|
||||
Log.info('Database is new - creating the schema...');
|
||||
|
||||
let statements = this.sqlStringToLines(structureSql)
|
||||
return this.transaction((tx) => {
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
tx.executeSql(statements[i]);
|
||||
}
|
||||
tx.executeSql('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumId('settings', 'string') + '")');
|
||||
tx.executeSql('INSERT INTO folders (`id`, `title`, `is_default`, `created_time`) VALUES ("' + uuid.create() + '", "' + _('Default list') + '", 1, ' + Math.round((new Date()).getTime() / 1000) + ')');
|
||||
}).then(() => {
|
||||
Log.info('Database schema created successfully');
|
||||
// Calling initialize() now that the db has been created will make it go through
|
||||
// the normal db update process (applying any additional patch).
|
||||
return this.initialize();
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Database.TYPE_INT = 1;
|
||||
Database.TYPE_TEXT = 2;
|
||||
Database.TYPE_BOOLEAN = 3;
|
||||
Database.TYPE_NUMERIC = 4;
|
||||
|
||||
export { Database };
|
34
CliClient/app/src/geolocation.js
Normal file
34
CliClient/app/src/geolocation.js
Normal file
@ -0,0 +1,34 @@
|
||||
class Geolocation {
|
||||
|
||||
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 (!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 { Geolocation };
|
9
CliClient/app/src/locale.js
Normal file
9
CliClient/app/src/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
CliClient/app/src/log.js
Normal file
40
CliClient/app/src/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_ERROR : this.level_;
|
||||
}
|
||||
|
||||
static debug(...o) {
|
||||
if (Log.level() > Log.LEVEL_DEBUG) return;
|
||||
console.debug(...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 };
|
24
CliClient/app/src/main.js
Normal file
24
CliClient/app/src/main.js
Normal file
@ -0,0 +1,24 @@
|
||||
// Note about the application structure:
|
||||
// - The user interface and its state is managed by React/Redux.
|
||||
// - Persistent storage to SQLite and Web API is handled outside of React/Redux using regular JavaScript (no middleware, no thunk, etc.).
|
||||
// - Communication from React to SQLite is done by calling model methods (note.save, etc.)
|
||||
// - Communication from SQLite to Redux is done via dispatcher.
|
||||
|
||||
// So there's basically still a one way flux: React => SQLite => Redux => React
|
||||
|
||||
import { AppRegistry } from 'react-native';
|
||||
import { Log } from 'src/log.js'
|
||||
import { Root } from 'src/root.js';
|
||||
import { Registry } from 'src/registry.js';
|
||||
|
||||
function main() {
|
||||
Registry.setDebugMode(true);
|
||||
AppRegistry.registerComponent('AwesomeProject', () => Root);
|
||||
Log.setLevel(Registry.debugMode() ? Log.LEVEL_DEBUG : Log.LEVEL_WARN);
|
||||
console.ignoredYellowBox = ['Remote debugger'];
|
||||
Log.info('START ======================================================================================================');
|
||||
// Note: The final part of the initialization process is in
|
||||
// AppComponent.componentDidMount(), when the application is ready.
|
||||
}
|
||||
|
||||
export { main }
|
106
CliClient/app/src/models/change.js
Normal file
106
CliClient/app/src/models/change.js
Normal file
@ -0,0 +1,106 @@
|
||||
import { BaseModel } from 'src/base-model.js';
|
||||
import { Log } from 'src/log.js';
|
||||
|
||||
class Change extends BaseModel {
|
||||
|
||||
static tableName() {
|
||||
return 'changes';
|
||||
}
|
||||
|
||||
static newChange() {
|
||||
return {
|
||||
id: null,
|
||||
type: null,
|
||||
item_id: null,
|
||||
item_type: null,
|
||||
item_field: null,
|
||||
};
|
||||
}
|
||||
|
||||
static all() {
|
||||
return this.db().selectAll('SELECT * FROM changes').then((r) => {
|
||||
let output = [];
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
output.push(r.rows.item(i));
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
static deleteMultiple(ids) {
|
||||
if (ids.length == 0) return Promise.resolve();
|
||||
|
||||
return this.db().transaction((tx) => {
|
||||
let sql = '';
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
tx.executeSql('DELETE FROM changes WHERE id = ?', [ids[i]]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static mergeChanges(changes) {
|
||||
let createdItems = [];
|
||||
let deletedItems = [];
|
||||
let itemChanges = {};
|
||||
|
||||
for (let i = 0; i < changes.length; i++) {
|
||||
let change = changes[i];
|
||||
|
||||
if (itemChanges[change.item_id]) {
|
||||
mergedChange = itemChanges[change.item_id];
|
||||
} else {
|
||||
mergedChange = {
|
||||
item_id: change.item_id,
|
||||
item_type: change.item_type,
|
||||
fields: [],
|
||||
ids: [],
|
||||
type: change.type,
|
||||
}
|
||||
}
|
||||
|
||||
if (change.type == this.TYPE_CREATE) {
|
||||
createdItems.push(change.item_id);
|
||||
} else if (change.type == this.TYPE_DELETE) {
|
||||
deletedItems.push(change.item_id);
|
||||
} else if (change.type == this.TYPE_UPDATE) {
|
||||
if (mergedChange.fields.indexOf(change.item_field) < 0) {
|
||||
mergedChange.fields.push(change.item_field);
|
||||
}
|
||||
}
|
||||
|
||||
mergedChange.ids.push(change.id);
|
||||
|
||||
itemChanges[change.item_id] = mergedChange;
|
||||
}
|
||||
|
||||
let output = [];
|
||||
|
||||
for (let itemId in itemChanges) {
|
||||
if (!itemChanges.hasOwnProperty(itemId)) continue;
|
||||
let change = itemChanges[itemId];
|
||||
|
||||
if (createdItems.indexOf(itemId) >= 0 && deletedItems.indexOf(itemId) >= 0) {
|
||||
// Item both created then deleted - skip
|
||||
change.type = this.TYPE_NOOP;
|
||||
} else if (deletedItems.indexOf(itemId) >= 0) {
|
||||
// Item was deleted at some point - just return one 'delete' event
|
||||
change.type = this.TYPE_DELETE;
|
||||
} else if (createdItems.indexOf(itemId) >= 0) {
|
||||
// Item was created then updated - just return one 'create' event with the latest changes
|
||||
change.type = this.TYPE_CREATE;
|
||||
}
|
||||
|
||||
output.push(change);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Change.TYPE_NOOP = 0;
|
||||
Change.TYPE_CREATE = 1;
|
||||
Change.TYPE_UPDATE = 2;
|
||||
Change.TYPE_DELETE = 3;
|
||||
|
||||
export { Change };
|
91
CliClient/app/src/models/folder.js
Normal file
91
CliClient/app/src/models/folder.js
Normal file
@ -0,0 +1,91 @@
|
||||
import { BaseModel } from 'src/base-model.js';
|
||||
import { Log } from 'src/log.js';
|
||||
import { promiseChain } from 'src/promise-chain.js';
|
||||
import { Note } from 'src/models/note.js';
|
||||
import { _ } from 'src/locale.js';
|
||||
|
||||
class Folder extends BaseModel {
|
||||
|
||||
static tableName() {
|
||||
return 'folders';
|
||||
}
|
||||
|
||||
static useUuid() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static itemType() {
|
||||
return BaseModel.ITEM_TYPE_FOLDER;
|
||||
}
|
||||
|
||||
static trackChanges() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static newFolder() {
|
||||
return {
|
||||
id: null,
|
||||
title: '',
|
||||
}
|
||||
}
|
||||
|
||||
static noteIds(id) {
|
||||
return this.db().exec('SELECT id FROM notes WHERE parent_id = ?', [id]).then((r) => {
|
||||
let output = [];
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
let row = r.rows.item(i);
|
||||
output.push(row.id);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
static delete(folderId, options = null) {
|
||||
return this.load(folderId).then((folder) => {
|
||||
if (!!folder.is_default) {
|
||||
throw new Error(_('Cannot delete the default list'));
|
||||
}
|
||||
}).then(() => {
|
||||
return this.noteIds(folderId);
|
||||
}).then((ids) => {
|
||||
let chain = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
chain.push(() => {
|
||||
return Note.delete(ids[i]);
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain);
|
||||
}).then(() => {
|
||||
return super.delete(folderId, options);
|
||||
}).then(() => {
|
||||
this.dispatch({
|
||||
type: 'FOLDER_DELETE',
|
||||
folderId: folderId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static all() {
|
||||
return this.db().selectAll('SELECT * FROM folders').then((r) => {
|
||||
let output = [];
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
output.push(r.rows.item(i));
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
static save(o, options = null) {
|
||||
return super.save(o, options).then((folder) => {
|
||||
this.dispatch({
|
||||
type: 'FOLDERS_UPDATE_ONE',
|
||||
folder: folder,
|
||||
});
|
||||
return folder;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Folder };
|
88
CliClient/app/src/models/note.js
Normal file
88
CliClient/app/src/models/note.js
Normal file
@ -0,0 +1,88 @@
|
||||
import { BaseModel } from 'src/base-model.js';
|
||||
import { Log } from 'src/log.js';
|
||||
import { Geolocation } from 'src/geolocation.js';
|
||||
|
||||
class Note extends BaseModel {
|
||||
|
||||
static tableName() {
|
||||
return 'notes';
|
||||
}
|
||||
|
||||
static useUuid() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static itemType() {
|
||||
return BaseModel.ITEM_TYPE_NOTE;
|
||||
}
|
||||
|
||||
static trackChanges() {
|
||||
return true;
|
||||
}
|
||||
|
||||
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 previewFieldsSql() {
|
||||
return '`id`, `title`, `body`, `is_todo`, `todo_completed`, `parent_id`, `updated_time`'
|
||||
}
|
||||
|
||||
static previews(parentId) {
|
||||
return this.db().selectAll('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE parent_id = ?', [parentId]).then((r) => {
|
||||
let output = [];
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
output.push(r.rows.item(i));
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
static preview(noteId) {
|
||||
return this.db().selectOne('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE id = ?', [noteId]);
|
||||
}
|
||||
|
||||
static updateGeolocation(noteId) {
|
||||
Log.info('Updating lat/long of note ' + noteId);
|
||||
|
||||
let geoData = null;
|
||||
return Geolocation.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 save(o, options = null) {
|
||||
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.preview(result.id);
|
||||
}).then((note) => {
|
||||
this.dispatch({
|
||||
type: 'NOTES_UPDATE_ONE',
|
||||
note: note,
|
||||
});
|
||||
return note;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Note };
|
11
CliClient/app/src/models/session.js
Normal file
11
CliClient/app/src/models/session.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { BaseModel } from 'src/base-model.js';
|
||||
|
||||
class Session extends BaseModel {
|
||||
|
||||
static login(email, password) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Session };
|
124
CliClient/app/src/models/setting.js
Normal file
124
CliClient/app/src/models/setting.js
Normal file
@ -0,0 +1,124 @@
|
||||
import { BaseModel } from 'src/base-model.js';
|
||||
import { Log } from 'src/log.js';
|
||||
import { Database } from 'src/database.js';
|
||||
|
||||
class Setting extends BaseModel {
|
||||
|
||||
static tableName() {
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
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 load() {
|
||||
this.cache_ = [];
|
||||
return this.db().selectAll('SELECT * FROM settings').then((r) => {
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
this.cache_.push(r.rows.item(i));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static setValue(key, value) {
|
||||
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) {
|
||||
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();
|
||||
|
||||
Log.info('Saving settings...');
|
||||
clearTimeout(this.updateTimeoutId_);
|
||||
this.updateTimeoutId_ = null;
|
||||
|
||||
return BaseModel.db().transaction((tx) => {
|
||||
tx.executeSql('DELETE FROM settings');
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
let q = Database.insertQuery(this.tableName(), this.cache_[i]);
|
||||
tx.executeSql(q.sql, q.params);
|
||||
}
|
||||
}).then(() => {
|
||||
Log.info('Settings have been saved.');
|
||||
}).catch((error) => {
|
||||
Log.warn('Could not save settings', error);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
static scheduleUpdate() {
|
||||
if (this.updateTimeoutId_) clearTimeout(this.updateTimeoutId_);
|
||||
|
||||
this.updateTimeoutId_ = setTimeout(() => {
|
||||
this.saveAll();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Setting.defaults_ = {
|
||||
'clientId': { value: '', type: 'string' },
|
||||
'sessionId': { value: '', type: 'string' },
|
||||
'user.email': { value: '', type: 'string' },
|
||||
'user.session': { value: '', type: 'string' },
|
||||
'sync.lastRevId': { value: 0, type: 'int' },
|
||||
};
|
||||
|
||||
export { Setting };
|
1
CliClient/app/src/package.json
Normal file
1
CliClient/app/src/package.json
Normal file
@ -0,0 +1 @@
|
||||
{ "name": "src" }
|
10
CliClient/app/src/promise-chain.js
Normal file
10
CliClient/app/src/promise-chain.js
Normal file
@ -0,0 +1,10 @@
|
||||
function promiseChain(chain) {
|
||||
let output = new Promise((resolve, reject) => { resolve(); });
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
let f = chain[i];
|
||||
output = output.then(f);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export { promiseChain }
|
47
CliClient/app/src/registry.js
Normal file
47
CliClient/app/src/registry.js
Normal file
@ -0,0 +1,47 @@
|
||||
// Stores global dynamic objects that are not state but that are required
|
||||
// throughout the application. Dependency injection would be a better solution
|
||||
// but more complex and YAGNI at this point. However classes that make use of the
|
||||
// registry should be designed in such a way that they can be converted to use
|
||||
// dependency injection later on (eg. `BaseModel.db()`, `Synchroniser.api()`)
|
||||
|
||||
import { Database } from 'src/database.js'
|
||||
import { WebApi } from 'src/web-api.js'
|
||||
|
||||
class Registry {
|
||||
|
||||
static setDebugMode(v) {
|
||||
this.debugMode_ = v;
|
||||
}
|
||||
|
||||
static debugMode() {
|
||||
if (this.debugMode_ === undefined) return false;
|
||||
return this.debugMode_;
|
||||
}
|
||||
|
||||
static api() {
|
||||
if (this.api_) return this.api_;
|
||||
this.api_ = new WebApi('http://192.168.1.3');
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
static setDb(v) {
|
||||
this.db_ = v;
|
||||
}
|
||||
|
||||
static db() {
|
||||
if (!this.db_) throw new Error('Accessing database before it has been initialised');
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
static setSynchronizer(s) {
|
||||
this.synchronizer_ = s;
|
||||
}
|
||||
|
||||
static synchronizer() {
|
||||
if (!this.synchronizer_) throw new Error('Accessing synchronizer before it has been initialised');
|
||||
return this.synchronizer_;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Registry };
|
300
CliClient/app/src/root.js
Normal file
300
CliClient/app/src/root.js
Normal file
@ -0,0 +1,300 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createStore } from 'redux';
|
||||
import { combineReducers } from 'redux';
|
||||
import { StackNavigator } from 'react-navigation';
|
||||
import { addNavigationHelpers } from 'react-navigation';
|
||||
import { Log } from 'src/log.js'
|
||||
import { Note } from 'src/models/note.js'
|
||||
import { Folder } from 'src/models/folder.js'
|
||||
import { BaseModel } from 'src/base-model.js'
|
||||
import { Database } from 'src/database.js'
|
||||
import { Registry } from 'src/registry.js'
|
||||
import { ItemList } from 'src/components/item-list.js'
|
||||
import { NotesScreen } from 'src/components/screens/notes.js'
|
||||
import { NoteScreen } from 'src/components/screens/note.js'
|
||||
import { FolderScreen } from 'src/components/screens/folder.js'
|
||||
import { FoldersScreen } from 'src/components/screens/folders.js'
|
||||
import { LoginScreen } from 'src/components/screens/login.js'
|
||||
import { LoadingScreen } from 'src/components/screens/loading.js'
|
||||
import { Setting } from 'src/models/setting.js'
|
||||
import { Synchronizer } from 'src/synchronizer.js'
|
||||
import { MenuContext } from 'react-native-popup-menu';
|
||||
import { SideMenu } from 'src/components/side-menu.js';
|
||||
import { SideMenuContent } from 'src/components/side-menu-content.js';
|
||||
import { NoteFolderService } from 'src/services/note-folder-service.js';
|
||||
|
||||
let defaultState = {
|
||||
notes: [],
|
||||
folders: [],
|
||||
selectedNoteId: null,
|
||||
selectedItemType: 'note',
|
||||
selectedFolderId: null,
|
||||
user: { email: 'laurent@cozic.net', session: null },
|
||||
showSideMenu: false,
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
Log.info('Reducer action', action.type);
|
||||
|
||||
let newState = state;
|
||||
|
||||
switch (action.type) {
|
||||
|
||||
case 'Navigation/NAVIGATE':
|
||||
case 'Navigation/BACK':
|
||||
|
||||
const r = state.nav.routes;
|
||||
const currentRoute = r.length ? r[r.length - 1] : null;
|
||||
const currentRouteName = currentRoute ? currentRoute.routeName : '';
|
||||
|
||||
Log.info('Current route name', currentRouteName);
|
||||
Log.info('New route name', action.routeName);
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
|
||||
if ('noteId' in action) {
|
||||
newState.selectedNoteId = action.noteId;
|
||||
}
|
||||
|
||||
if ('folderId' in action) {
|
||||
newState.selectedFolderId = action.folderId;
|
||||
}
|
||||
|
||||
if ('itemType' in action) {
|
||||
newState.selectedItemType = action.itemType;
|
||||
}
|
||||
|
||||
if (currentRouteName == action.routeName) {
|
||||
// If the current screen is already the requested screen, don't do anything
|
||||
} else {
|
||||
const nextStateNav = AppNavigator.router.getStateForAction(action, currentRouteName != 'Loading' ? state.nav : null);
|
||||
if (nextStateNav) {
|
||||
newState.nav = nextStateNav;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
// Replace all the notes with the provided array
|
||||
case 'NOTES_UPDATE_ALL':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.notes = action.notes;
|
||||
break;
|
||||
|
||||
// Insert the note into the note list if it's new, or
|
||||
// update it within the note array if it already exists.
|
||||
case 'NOTES_UPDATE_ONE':
|
||||
|
||||
Log.info('NOITTEOJTNEONTOE', action.note);
|
||||
|
||||
let newNotes = state.notes.splice(0);
|
||||
var found = false;
|
||||
for (let i = 0; i < newNotes.length; i++) {
|
||||
let n = newNotes[i];
|
||||
if (n.id == action.note.id) {
|
||||
newNotes[i] = action.note;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) newNotes.push(action.note);
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.notes = newNotes;
|
||||
break;
|
||||
|
||||
case 'FOLDERS_UPDATE_ALL':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.folders = action.folders;
|
||||
break;
|
||||
|
||||
case 'FOLDERS_UPDATE_ONE':
|
||||
|
||||
var newFolders = state.folders.splice(0);
|
||||
var found = false;
|
||||
for (let i = 0; i < newFolders.length; i++) {
|
||||
let n = newFolders[i];
|
||||
if (n.id == action.folder.id) {
|
||||
newFolders[i] = action.folder;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) newFolders.push(action.folder);
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.folders = newFolders;
|
||||
break;
|
||||
|
||||
case 'FOLDER_DELETE':
|
||||
|
||||
var newFolders = [];
|
||||
for (let i = 0; i < state.folders.length; i++) {
|
||||
let f = state.folders[i];
|
||||
if (f.id == action.folderId) continue;
|
||||
newFolders.push(f);
|
||||
}
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.folders = newFolders;
|
||||
break;
|
||||
|
||||
case 'USER_SET':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.user = action.user;
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_TOGGLE':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.showSideMenu = !newState.showSideMenu
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_OPEN':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.showSideMenu = true
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_CLOSE':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.showSideMenu = false
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
// Log.info('newState.selectedFolderId', newState.selectedFolderId);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
let store = createStore(reducer);
|
||||
|
||||
const AppNavigator = StackNavigator({
|
||||
Notes: { screen: NotesScreen },
|
||||
Note: { screen: NoteScreen },
|
||||
Folder: { screen: FolderScreen },
|
||||
Folders: { screen: FoldersScreen },
|
||||
Login: { screen: LoginScreen },
|
||||
Loading: { screen: LoadingScreen },
|
||||
});
|
||||
|
||||
class AppComponent extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
let db = new Database();
|
||||
//db.setDebugEnabled(Registry.debugMode());
|
||||
db.setDebugEnabled(false);
|
||||
|
||||
BaseModel.dispatch = this.props.dispatch;
|
||||
BaseModel.db_ = db;
|
||||
NoteFolderService.dispatch = this.props.dispatch;
|
||||
|
||||
db.open().then(() => {
|
||||
Log.info('Database is ready.');
|
||||
Registry.setDb(db);
|
||||
}).then(() => {
|
||||
Log.info('Loading settings...');
|
||||
return Setting.load();
|
||||
}).then(() => {
|
||||
let user = Setting.object('user');
|
||||
Log.info('Client ID', Setting.value('clientId'));
|
||||
Log.info('User', user);
|
||||
|
||||
Registry.api().setSession(user.session);
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'USER_SET',
|
||||
user: user,
|
||||
});
|
||||
|
||||
Log.info('Loading folders...');
|
||||
|
||||
return Folder.all().then((folders) => {
|
||||
this.props.dispatch({
|
||||
type: 'FOLDERS_UPDATE_ALL',
|
||||
folders: folders,
|
||||
});
|
||||
return folders;
|
||||
}).catch((error) => {
|
||||
Log.warn('Cannot load folders', error);
|
||||
});
|
||||
}).then((folders) => {
|
||||
let folder = folders[0];
|
||||
|
||||
if (!folder) throw new Error('No default folder is defined');
|
||||
|
||||
return NoteFolderService.openNoteList(folder.id);
|
||||
|
||||
// this.props.dispatch({
|
||||
// type: 'Navigation/NAVIGATE',
|
||||
// routeName: 'Notes',
|
||||
// folderId: folder.id,
|
||||
// });
|
||||
}).then(() => {
|
||||
let synchronizer = new Synchronizer(db, Registry.api());
|
||||
Registry.setSynchronizer(synchronizer);
|
||||
synchronizer.start();
|
||||
}).catch((error) => {
|
||||
Log.error('Initialization error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
sideMenu_change(isOpen) {
|
||||
// Make sure showSideMenu property of state is updated
|
||||
// when the menu is open/closed.
|
||||
this.props.dispatch({
|
||||
type: isOpen ? 'SIDE_MENU_OPEN' : 'SIDE_MENU_CLOSE',
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const sideMenuContent = <SideMenuContent/>;
|
||||
|
||||
return (
|
||||
<SideMenu menu={sideMenuContent} onChange={this.sideMenu_change}>
|
||||
<MenuContext style={{ flex: 1 }}>
|
||||
<AppNavigator navigation={addNavigationHelpers({
|
||||
dispatch: this.props.dispatch,
|
||||
state: this.props.nav,
|
||||
})} />
|
||||
</MenuContext>
|
||||
</SideMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
defaultState.nav = AppNavigator.router.getStateForAction({
|
||||
type: 'Navigation/NAVIGATE',
|
||||
routeName: 'Loading',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
nav: state.nav
|
||||
};
|
||||
};
|
||||
|
||||
const App = connect(mapStateToProps)(AppComponent);
|
||||
|
||||
class Root extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { Root };
|
69
CliClient/app/src/services/note-folder-service.js
Normal file
69
CliClient/app/src/services/note-folder-service.js
Normal file
@ -0,0 +1,69 @@
|
||||
// A service that handle notes and folders in a uniform way
|
||||
|
||||
import { BaseService } from 'src/base-service.js';
|
||||
import { BaseModel } from 'src/base-model.js';
|
||||
import { Note } from 'src/models/note.js';
|
||||
import { Folder } from 'src/models/folder.js';
|
||||
import { Log } from 'src/log.js';
|
||||
import { Registry } from 'src/registry.js';
|
||||
|
||||
class NoteFolderService extends BaseService {
|
||||
|
||||
static save(type, item, oldItem) {
|
||||
if (oldItem) {
|
||||
let diff = BaseModel.diffObjects(oldItem, item);
|
||||
if (!Object.getOwnPropertyNames(diff).length) {
|
||||
Log.info('Item not changed - not saved');
|
||||
return Promise.resolve(item);
|
||||
}
|
||||
}
|
||||
|
||||
let ItemClass = null;
|
||||
if (type == 'note') {
|
||||
ItemClass = Note;
|
||||
} else if (type == 'folder') {
|
||||
ItemClass = Folder;
|
||||
}
|
||||
|
||||
let isNew = !item.id;
|
||||
let output = null;
|
||||
return ItemClass.save(item).then((item) => {
|
||||
output = item;
|
||||
if (isNew && type == 'note') return Note.updateGeolocation(item.id);
|
||||
}).then(() => {
|
||||
Registry.synchronizer().start();
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
static setField(type, itemId, fieldName, fieldValue, oldValue = undefined) {
|
||||
// TODO: not really consistent as the promise will return 'null' while
|
||||
// this.save will return the note or folder. Currently not used, and maybe not needed.
|
||||
if (oldValue !== undefined && fieldValue === oldValue) return Promise.resolve();
|
||||
|
||||
let item = { id: itemId };
|
||||
item[fieldName] = fieldValue;
|
||||
let oldItem = { id: itemId };
|
||||
return this.save(type, item, oldItem);
|
||||
}
|
||||
|
||||
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 };
|
15
CliClient/app/src/services/session-service.js
Normal file
15
CliClient/app/src/services/session-service.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { BaseService } from 'src/base-service.js';
|
||||
|
||||
class SessionService extends BaseService {
|
||||
|
||||
login(email, password, clientId) {
|
||||
return this.api_.post('sessions', null, {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'client_id': clientId,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { SessionService };
|
181
CliClient/app/src/synchronizer.js
Normal file
181
CliClient/app/src/synchronizer.js
Normal file
@ -0,0 +1,181 @@
|
||||
import { Log } from 'src/log.js';
|
||||
import { Setting } from 'src/models/setting.js';
|
||||
import { Change } from 'src/models/change.js';
|
||||
import { Folder } from 'src/models/folder.js';
|
||||
import { Note } from 'src/models/note.js';
|
||||
import { BaseModel } from 'src/base-model.js';
|
||||
import { promiseChain } from 'src/promise-chain.js';
|
||||
|
||||
class Synchronizer {
|
||||
|
||||
constructor(db, api) {
|
||||
this.state_ = 'idle';
|
||||
this.db_ = db;
|
||||
this.api_ = api;
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.state_;
|
||||
}
|
||||
|
||||
db() {
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
api() {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
processState_uploadChanges() {
|
||||
Change.all().then((changes) => {
|
||||
let mergedChanges = Change.mergeChanges(changes);
|
||||
let chain = [];
|
||||
let processedChangeIds = [];
|
||||
for (let i = 0; i < mergedChanges.length; i++) {
|
||||
let c = mergedChanges[i];
|
||||
chain.push(() => {
|
||||
let p = null;
|
||||
|
||||
let ItemClass = null;
|
||||
let path = null;
|
||||
if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) {
|
||||
ItemClass = Folder;
|
||||
path = 'folders';
|
||||
} else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) {
|
||||
ItemClass = Note;
|
||||
path = 'notes';
|
||||
}
|
||||
|
||||
if (c.type == Change.TYPE_NOOP) {
|
||||
p = Promise.resolve();
|
||||
} else if (c.type == Change.TYPE_CREATE) {
|
||||
p = ItemClass.load(c.item_id).then((item) => {
|
||||
return this.api().put(path + '/' + item.id, null, item);
|
||||
});
|
||||
} else if (c.type == Change.TYPE_UPDATE) {
|
||||
p = ItemClass.load(c.item_id).then((item) => {
|
||||
return this.api().patch(path + '/' + item.id, null, item);
|
||||
});
|
||||
} else if (c.type == Change.TYPE_DELETE) {
|
||||
p = this.api().delete(path + '/' + c.item_id);
|
||||
}
|
||||
|
||||
return p.then(() => {
|
||||
processedChangeIds = processedChangeIds.concat(c.ids);
|
||||
}).catch((error) => {
|
||||
Log.warn('Failed applying changes', c.ids, error.message, error.type);
|
||||
// This is fine - trying to apply changes to an object that has been deleted
|
||||
if (error.type == 'NotFoundException') {
|
||||
processedChangeIds = processedChangeIds.concat(c.ids);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain).catch((error) => {
|
||||
Log.warn('Synchronization was interrupted due to an error:', error);
|
||||
}).then(() => {
|
||||
Log.info('IDs to delete: ', processedChangeIds);
|
||||
Change.deleteMultiple(processedChangeIds);
|
||||
});
|
||||
}).then(() => {
|
||||
this.processState('downloadChanges');
|
||||
});
|
||||
}
|
||||
|
||||
processState_downloadChanges() {
|
||||
let maxRevId = null;
|
||||
let hasMore = false;
|
||||
this.api().get('synchronizer', { rev_id: Setting.value('sync.lastRevId') }).then((syncOperations) => {
|
||||
hasMore = syncOperations.has_more;
|
||||
let chain = [];
|
||||
for (let i = 0; i < syncOperations.items.length; i++) {
|
||||
let syncOp = syncOperations.items[i];
|
||||
if (syncOp.id > maxRevId) maxRevId = syncOp.id;
|
||||
|
||||
let ItemClass = null;
|
||||
if (syncOp.item_type == 'folder') {
|
||||
ItemClass = Folder;
|
||||
} else if (syncOp.item_type == 'note') {
|
||||
ItemClass = Note;
|
||||
}
|
||||
|
||||
if (syncOp.type == 'create') {
|
||||
chain.push(() => {
|
||||
let item = ItemClass.fromApiResult(syncOp.item);
|
||||
// TODO: automatically handle NULL fields by checking type and default value of field
|
||||
if ('parent_id' in item && !item.parent_id) item.parent_id = '';
|
||||
return ItemClass.save(item, { isNew: true, trackChanges: false });
|
||||
});
|
||||
}
|
||||
|
||||
if (syncOp.type == 'update') {
|
||||
chain.push(() => {
|
||||
return ItemClass.load(syncOp.item_id).then((item) => {
|
||||
if (!item) return;
|
||||
item = ItemClass.applyPatch(item, syncOp.item);
|
||||
return ItemClass.save(item, { trackChanges: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (syncOp.type == 'delete') {
|
||||
chain.push(() => {
|
||||
return ItemClass.delete(syncOp.item_id, { trackChanges: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
return promiseChain(chain);
|
||||
}).then(() => {
|
||||
Log.info('All items synced. has_more = ', hasMore);
|
||||
if (maxRevId) {
|
||||
Setting.setValue('sync.lastRevId', maxRevId);
|
||||
return Setting.saveAll();
|
||||
}
|
||||
}).then(() => {
|
||||
if (hasMore) {
|
||||
this.processState('downloadChanges');
|
||||
} else {
|
||||
this.processState('idle');
|
||||
}
|
||||
}).catch((error) => {
|
||||
Log.warn('Sync error', error);
|
||||
});
|
||||
}
|
||||
|
||||
processState(state) {
|
||||
Log.info('Sync: processing: ' + state);
|
||||
this.state_ = state;
|
||||
|
||||
if (state == 'uploadChanges') {
|
||||
processState_uploadChanges();
|
||||
} else if (state == 'downloadChanges') {
|
||||
processState_downloadChanges();
|
||||
} else if (state == 'idle') {
|
||||
// Nothing
|
||||
} else {
|
||||
throw new Error('Invalid state: ' . state);
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
Log.info('Sync: start');
|
||||
|
||||
if (this.state() != 'idle') {
|
||||
Log.info("Sync: cannot start synchronizer because synchronization already in progress. State: " + this.state());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.api().session()) {
|
||||
Log.info("Sync: cannot start synchronizer because user is not logged in.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.processState('uploadChanges');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Synchronizer };
|
11
CliClient/app/src/uuid.js
Normal file
11
CliClient/app/src/uuid.js
Normal file
@ -0,0 +1,11 @@
|
||||
import createUuidV4 from 'uuid/v4';
|
||||
|
||||
const uuid = {
|
||||
|
||||
create: function() {
|
||||
return createUuidV4().replace(/-/g, '');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { uuid };
|
133
CliClient/app/src/web-api.js
Normal file
133
CliClient/app/src/web-api.js
Normal file
@ -0,0 +1,133 @@
|
||||
import { Log } from 'src/log.js';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
const FormData = require('form-data');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
class WebApiError extends Error {
|
||||
|
||||
constructor(msg) {
|
||||
let type = 'WebApiError';
|
||||
// Create a regular JS Error object from a web api error response { error: "something", type: "NotFoundException" }
|
||||
if (typeof msg === 'object' && msg !== null) {
|
||||
if (msg.type) type = msg.type;
|
||||
msg = msg.error ? msg.error : 'error';
|
||||
}
|
||||
super(msg);
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class WebApi {
|
||||
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl_ = baseUrl;
|
||||
this.session_ = null;
|
||||
}
|
||||
|
||||
setSession(v) {
|
||||
this.session_ = v;
|
||||
}
|
||||
|
||||
session() {
|
||||
return this.session_;
|
||||
}
|
||||
|
||||
makeRequest(method, path, query, data) {
|
||||
let url = this.baseUrl_;
|
||||
if (path) url += '/' + path;
|
||||
if (query) url += '?' + stringify(query);
|
||||
let options = {};
|
||||
options.method = method.toUpperCase();
|
||||
if (data) {
|
||||
let formData = null;
|
||||
if (method == 'POST') {
|
||||
formData = new FormData();
|
||||
for (var key in data) {
|
||||
if (!data.hasOwnProperty(key)) continue;
|
||||
formData.append(key, data[key]);
|
||||
}
|
||||
} else {
|
||||
options.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
formData = stringify(data);
|
||||
}
|
||||
|
||||
options.body = formData;
|
||||
}
|
||||
|
||||
return {
|
||||
url: url,
|
||||
options: options
|
||||
};
|
||||
}
|
||||
|
||||
static toCurl(r, data) {
|
||||
let o = r.options;
|
||||
let cmd = [];
|
||||
cmd.push('curl');
|
||||
if (o.method == 'PUT') cmd.push('-X PUT');
|
||||
if (o.method == 'PATCH') cmd.push('-X PATCH');
|
||||
if (o.method == 'DELETE') cmd.push('-X DELETE');
|
||||
if (o.method != 'GET' && o.method != 'DELETE') {
|
||||
cmd.push("--data '" + stringify(data) + "'");
|
||||
}
|
||||
cmd.push("'" + r.url + "'");
|
||||
return cmd.join(' ');
|
||||
}
|
||||
|
||||
exec(method, path, query, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.session_) {
|
||||
query = query ? Object.assign({}, query) : {};
|
||||
if (!query.session) query.session = this.session_;
|
||||
}
|
||||
|
||||
let r = this.makeRequest(method, path, query, data);
|
||||
|
||||
Log.debug(WebApi.toCurl(r, data));
|
||||
|
||||
fetch(r.url, r.options).then(function(response) {
|
||||
let responseClone = response.clone();
|
||||
return response.json().then(function(data) {
|
||||
if (data && data.error) {
|
||||
reject(new WebApiError(data));
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
}).catch(function(error) {
|
||||
responseClone.text().then(function(text) {
|
||||
reject(new Error('Cannot parse JSON: ' + text));
|
||||
});
|
||||
});
|
||||
}).then(function(data) {
|
||||
resolve(data);
|
||||
}).catch(function(error) {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get(path, query) {
|
||||
return this.exec('GET', path, query);
|
||||
}
|
||||
|
||||
post(path, query, data) {
|
||||
return this.exec('POST', path, query, data);
|
||||
}
|
||||
|
||||
put(path, query, data) {
|
||||
return this.exec('PUT', path, query, data);
|
||||
}
|
||||
|
||||
patch(path, query, data) {
|
||||
return this.exec('PATCH', path, query, data);
|
||||
}
|
||||
|
||||
delete(path, query) {
|
||||
return this.exec('DELETE', path, query);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { WebApi };
|
24
CliClient/package.json
Normal file
24
CliClient/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "CliClient",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"app-module-path": "^2.2.0",
|
||||
"form-data": "^2.1.4",
|
||||
"node-fetch": "^1.7.1",
|
||||
"react": "16.0.0-alpha.6",
|
||||
"uuid": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-changed": "^7.0.0",
|
||||
"babel-cli": "^6.24.1",
|
||||
"babel-preset-env": "^1.5.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"query-string": "4.3.4",
|
||||
"react-native-sqlite-storage": "3.3.*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel-changed app -d build",
|
||||
"clean": "babel-changed --reset"
|
||||
}
|
||||
}
|
2
CliClient/run.sh
Normal file
2
CliClient/run.sh
Normal file
@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
npm run build && NODE_PATH=/var/www/joplin/CliClient/build/ node build/import-enex.js
|
Loading…
x
Reference in New Issue
Block a user