1
0
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:
Laurent Cozic 2017-06-05 23:00:02 +01:00
parent 5b5e160586
commit cb2ca28e09
40 changed files with 2853 additions and 0 deletions

3
CliClient/.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets": ["env", "react"]
}

2
CliClient/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
build/
node_modules/

View 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);
});

View 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 };

View File

@ -0,0 +1,13 @@
import { Registry } from 'src/registry.js';
class BaseService {
constructor() {}
api() {
return Registry.api();
}
}
export { BaseService };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 }}> &#8942; </Text>
</MenuTrigger>
<MenuOptions>
{ menuOptionComponents }
</MenuOptions>
</Menu>
</View>
);
}
}
ScreenHeaderComponent.defaultProps = {
menuOptions: [],
};
const ScreenHeader = connect(
(state) => {
return { user: state.user };
}
)(ScreenHeaderComponent)
export { ScreenHeader };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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
View 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
View 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 }

View 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 };

View 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 };

View 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 };

View File

@ -0,0 +1,11 @@
import { BaseModel } from 'src/base-model.js';
class Session extends BaseModel {
static login(email, password) {
}
}
export { Session };

View 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 };

View File

@ -0,0 +1 @@
{ "name": "src" }

View 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 }

View 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
View 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 };

View 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 };

View 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 };

View 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
View File

@ -0,0 +1,11 @@
import createUuidV4 from 'uuid/v4';
const uuid = {
create: function() {
return createUuidV4().replace(/-/g, '');
}
}
export { uuid };

View 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
View 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
View File

@ -0,0 +1,2 @@
#!/bin/bash
npm run build && NODE_PATH=/var/www/joplin/CliClient/build/ node build/import-enex.js