1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-29 22:48:10 +02:00

Started fixing ReactNative app

This commit is contained in:
Laurent Cozic
2017-07-05 21:34:25 +01:00
parent 9d630ab0ca
commit f0a8cbe95d
50 changed files with 12 additions and 153 deletions

View File

@@ -1 +0,0 @@
../lib

View File

@@ -0,0 +1,292 @@
import { Log } from 'lib/log.js';
import { Database } from 'lib/database.js';
import { uuid } from 'lib/uuid.js';
import { time } from 'lib/time-utils.js';
class BaseModel {
static modelType() {
throw new Error('Must be overriden');
}
static tableName() {
throw new Error('Must be overriden');
}
static addModelMd(model) {
if (!model) return model;
if (Array.isArray(model)) {
let output = [];
for (let i = 0; i < model.length; i++) {
output.push(this.addModelMd(model[i]));
}
return output;
} else {
model = Object.assign({}, model);
model.type_ = this.modelType();
return model;
}
}
static logger() {
return this.db().logger();
}
static useUuid() {
return false;
}
static byId(items, id) {
for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return items[i];
}
return null;
}
static hasField(name) {
let fields = this.fieldNames();
return fields.indexOf(name) >= 0;
}
static fieldNames() {
return this.db().tableFieldNames(this.tableName());
}
static fieldType(name) {
let fields = this.fields();
for (let i = 0; i < fields.length; i++) {
if (fields[i].name == name) return fields[i].type;
}
throw new Error('Unknown field: ' + name);
}
static fields() {
return this.db().tableFields(this.tableName());
}
static new() {
let fields = this.fields();
let output = {};
for (let i = 0; i < fields.length; i++) {
let f = fields[i];
output[f.name] = f.default;
}
return output;
}
static modOptions(options) {
if (!options) {
options = {};
} else {
options = Object.assign({}, options);
}
if (!('isNew' in options)) options.isNew = 'auto';
if (!('autoTimestamp' in options)) options.autoTimestamp = true;
return options;
}
static count() {
return this.db().selectOne('SELECT count(*) as total FROM `' + this.tableName() + '`').then((r) => {
return r ? r['total'] : 0;
});
}
static load(id) {
return this.loadByField('id', id);
}
static applySqlOptions(options, sql, params = null) {
if (!options) options = {};
if (options.orderBy) {
sql += ' ORDER BY ' + options.orderBy;
if (options.caseInsensitive === true) sql += ' COLLATE NOCASE';
if (options.orderByDir) sql += ' ' + options.orderByDir;
}
if (options.limit) sql += ' LIMIT ' + options.limit;
return { sql: sql, params: params };
}
static async all(options = null) {
let q = this.applySqlOptions(options, 'SELECT * FROM `' + this.tableName() + '`');
return this.modelSelectAll(q.sql);
}
static async search(options = null) {
if (!options) options = {};
if (!options.fields) options.fields = '*';
let conditions = options.conditions ? options.conditions.slice(0) : [];
let params = options.conditionsParams ? options.conditionsParams.slice(0) : [];
if (options.titlePattern) {
let pattern = options.titlePattern.replace(/\*/g, '%');
conditions.push('title LIKE ?');
params.push(pattern);
}
let sql = 'SELECT ' + this.db().escapeFields(options.fields) + ' FROM `' + this.tableName() + '`';
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
let query = this.applySqlOptions(options, sql, params);
return this.modelSelectAll(query.sql, query.params);
}
static modelSelectOne(sql, params = null) {
if (params === null) params = [];
return this.db().selectOne(sql, params).then((model) => {
return this.filter(this.addModelMd(model));
});
}
static modelSelectAll(sql, params = null) {
if (params === null) params = [];
return this.db().selectAll(sql, params).then((models) => {
return this.filterArray(this.addModelMd(models));
});
}
static loadByField(fieldName, fieldValue) {
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `' + fieldName + '` = ?', [fieldValue]);
}
static loadByTitle(fieldValue) {
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `title` = ?', [fieldValue]);
}
static diffObjects(oldModel, newModel) {
let output = {};
let type = null;
for (let n in newModel) {
if (n == 'type_') {
type = n;
continue;
}
if (!newModel.hasOwnProperty(n)) continue;
if (!(n in oldModel) || newModel[n] !== oldModel[n]) {
output[n] = newModel[n];
}
}
if (type !== null) output.type_ = type;
return output;
}
static saveQuery(o, options) {
let temp = {}
let fieldNames = this.fieldNames();
for (let i = 0; i < fieldNames.length; i++) {
let n = fieldNames[i];
if (n in o) temp[n] = o[n];
}
o = temp;
let query = {};
let modelId = o.id;
if (options.autoTimestamp && this.hasField('updated_time')) {
o.updated_time = time.unixMs();
}
if (options.isNew) {
if (this.useUuid() && !o.id) {
modelId = uuid.create();
o.id = modelId;
}
if (!o.created_time && this.hasField('created_time')) {
o.created_time = time.unixMs();
}
query = Database.insertQuery(this.tableName(), o);
} else {
let where = { id: o.id };
let temp = Object.assign({}, o);
delete temp.id;
query = Database.updateQuery(this.tableName(), temp, where);
}
query.id = modelId;
return query;
}
static save(o, options = null) {
options = this.modOptions(options);
options.isNew = this.isNew(o, options);
o = this.filter(o);
let queries = [];
let saveQuery = this.saveQuery(o, options);
let modelId = saveQuery.id;
queries.push(saveQuery);
return this.db().transactionExecBatch(queries).then(() => {
o = Object.assign({}, o);
o.id = modelId;
o = this.addModelMd(o);
return this.filter(o);
}).catch((error) => {
Log.error('Cannot save model', error);
});
}
static isNew(object, options) {
if (options && ('isNew' in options)) {
// options.isNew can be "auto" too
if (options.isNew === true) return true;
if (options.isNew === false) return false;
}
return !object.id;
}
static filterArray(models) {
let output = [];
for (let i = 0; i < models.length; i++) {
output.push(this.filter(models[i]));
}
return output;
}
static filter(model) {
if (!model) return model;
let output = Object.assign({}, model);
for (let n in output) {
if (!output.hasOwnProperty(n)) continue;
// The SQLite database doesn't have booleans so cast everything to int
if (output[n] === true) output[n] = 1;
if (output[n] === false) output[n] = 0;
}
return output;
}
static delete(id, options = null) {
options = this.modOptions(options);
if (!id) throw new Error('Cannot delete object without an ID');
return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id = ?', [id]);
}
static db() {
if (!this.db_) throw new Error('Accessing database before it has been initialised');
return this.db_;
}
}
BaseModel.TYPE_NOTE = 1;
BaseModel.TYPE_FOLDER = 2;
BaseModel.TYPE_SETTING = 3;
BaseModel.TYPE_RESOURCE = 4;
BaseModel.TYPE_TAG = 5;
BaseModel.TYPE_NOTE_TAG = 6;
BaseModel.db_ = null;
BaseModel.dispatch = function(o) {};
export { BaseModel };

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 'lib/log.js'
const styles = StyleSheet.create({
actionButtonIcon: {
fontSize: 20,
height: 22,
color: 'white',
},
});
class ActionButtonComponent extends React.Component {
newTodo_press() {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Note',
noteId: null,
folderId: this.props.parentFolderId,
itemType: 'todo',
});
}
newNote_press() {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Note',
noteId: null,
folderId: this.props.parentFolderId,
itemType: 'note',
});
}
newFolder_press() {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Folder',
folderId: null,
});
}
render() {
return (
<ReactNativeActionButton buttonColor="rgba(231,76,60,1)">
<ReactNativeActionButton.Item buttonColor='#9b59b6' title="New todo" onPress={() => { this.newTodo_press() }}>
<Icon name="md-checkbox-outline" style={styles.actionButtonIcon} />
</ReactNativeActionButton.Item>
<ReactNativeActionButton.Item buttonColor='#9b59b6' title="New note" onPress={() => { this.newNote_press() }}>
<Icon name="md-document" style={styles.actionButtonIcon} />
</ReactNativeActionButton.Item>
<ReactNativeActionButton.Item buttonColor='#3498db' title="New folder" onPress={() => { this.newFolder_press() }}>
<Icon name="md-folder" style={styles.actionButtonIcon} />
</ReactNativeActionButton.Item>
</ReactNativeActionButton>
);
}
}
const ActionButton = connect(
(state) => {
return {};
}
)(ActionButtonComponent)
export { ActionButton };

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 'lib/log.js';
import { ItemListComponent } from 'lib/components/item-list.js';
import { Note } from 'lib/models/note.js';
import { Folder } from 'lib/models/folder.js';
import { _ } from 'lib/locale.js';
import { NoteFolderService } from 'lib/services/note-folder-service.js';
class FolderListComponent extends ItemListComponent {
listView_itemPress(folderId) {
NoteFolderService.openNoteList(folderId);
}
}
const FolderList = connect(
(state) => {
return {
items: state.folders,
};
}
)(FolderListComponent)
export { FolderList };

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 'lib/log.js';
import { _ } from 'lib/locale.js';
import { Checkbox } from 'lib/components/checkbox.js';
import { NoteFolderService } from 'lib/services/note-folder-service.js';
import { Note } from 'lib/models/note.js';
class ItemListComponent extends Component {
constructor() {
super();
const ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => { return r1 !== r2; }
});
this.state = {
dataSource: ds,
items: [],
selectedItemIds: [],
};
}
componentWillMount() {
const newDataSource = this.state.dataSource.cloneWithRows(this.props.items);
this.state = { dataSource: newDataSource };
}
componentWillReceiveProps(newProps) {
// https://stackoverflow.com/questions/38186114/react-native-redux-and-listview
this.setState({
dataSource: this.state.dataSource.cloneWithRows(newProps.items),
});
}
todoCheckbox_change(itemId, checked) {
NoteFolderService.setField('note', itemId, 'todo_completed', checked);
// Note.load(itemId).then((oldNote) => {
// let newNote = Object.assign({}, oldNote);
// newNote.todo_completed = checked;
// return NoteFolderService.save('note', newNote, oldNote);
// });
}
listView_itemPress(itemId) {}
render() {
let renderRow = (item) => {
let onPress = () => {
this.listView_itemPress(item.id);
}
let onLongPress = () => {
this.listView_itemLongPress(item.id);
}
return (
<TouchableHighlight onPress={onPress} onLongPress={onLongPress}>
<View style={{flexDirection: 'row'}}>
{ !!Number(item.is_todo) && <Checkbox checked={!!Number(item.todo_completed)} onChange={(checked) => { this.todoCheckbox_change(item.id, checked) }}/> }<Text>{item.title} [{item.id}]</Text>
</View>
</TouchableHighlight>
);
}
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
return (
<ListView
dataSource={this.state.dataSource}
renderRow={renderRow}
enableEmptySections={true}
/>
);
}
}
export { ItemListComponent };

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 'lib/log.js';
import { ItemListComponent } from 'lib/components/item-list.js';
import { _ } from 'lib/locale.js';
class NoteListComponent extends ItemListComponent {
listView_itemPress(noteId) {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Note',
noteId: noteId,
});
}
}
const NoteList = connect(
(state) => {
return { items: state.notes };
}
)(NoteListComponent)
export { NoteList };

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 'lib/log.js';
import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu';
import { _ } from 'lib/locale.js';
import { Setting } from 'lib/models/setting.js';
const styles = StyleSheet.create({
divider: {
marginVertical: 5,
marginHorizontal: 2,
borderBottomWidth: 1,
borderColor: '#ccc'
},
});
class ScreenHeaderComponent extends Component {
showBackButton() {
// Note: this is hardcoded for now because navigation.state doesn't tell whether
// it's possible to go back or not. Maybe it's possible to get this information
// from somewhere else.
return this.props.navState.routeName != 'Notes';
}
sideMenuButton_press() {
this.props.dispatch({ type: 'SIDE_MENU_TOGGLE' });
}
backButton_press() {
this.props.dispatch({ type: 'Navigation/BACK' });
}
menu_select(value) {
if (typeof(value) == 'function') {
value();
}
}
menu_login() {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Login',
});
}
menu_logout() {
let user = { email: null, session: null };
Setting.setObject('user', user);
this.props.dispatch({
type: 'USER_SET',
user: user,
});
}
render() {
let key = 0;
let menuOptionComponents = [];
for (let i = 0; i < this.props.menuOptions.length; i++) {
let o = this.props.menuOptions[i];
menuOptionComponents.push(
<MenuOption value={o.onPress} key={'menuOption_' + key++}>
<Text>{o.title}</Text>
</MenuOption>);
}
if (menuOptionComponents.length) {
menuOptionComponents.push(<View key={'menuOption_' + key++} style={styles.divider}/>);
}
if (this.props.user && this.props.user.session) {
menuOptionComponents.push(
<MenuOption value={() => this.menu_logout()} key={'menuOption_' + key++}>
<Text>{_('Logout')}</Text>
</MenuOption>);
} else {
menuOptionComponents.push(
<MenuOption value={() => this.menu_login()} key={'menuOption_' + key++}>
<Text>{_('Login')}</Text>
</MenuOption>);
}
menuOptionComponents.push(
<MenuOption value={1} key={'menuOption_' + key++}>
<Text>{_('Configuration')}</Text>
</MenuOption>);
let title = 'title' in this.props && this.props.title !== null ? this.props.title : _(this.props.navState.routeName);
return (
<View style={{ flexDirection: 'row', padding: 10, backgroundColor: '#ffffff', alignItems: 'center' }} >
<Button title="☰" onPress={() => this.sideMenuButton_press()} />
<Button disabled={!this.showBackButton()} title="<" onPress={() => this.backButton_press()}></Button>
<Text style={{ flex:1, marginLeft: 10 }} >{title}</Text>
<Menu onSelect={(value) => this.menu_select(value)}>
<MenuTrigger>
<Text style={{ fontSize: 20 }}> &#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,78 @@
import React, { Component } from 'react';
import { View, Button, TextInput } from 'react-native';
import { connect } from 'react-redux'
import { Log } from 'lib/log.js'
import { Folder } from 'lib/models/folder.js'
import { ScreenHeader } from 'lib/components/screen-header.js';
import { NoteFolderService } from 'lib/services/note-folder-service.js';
class FolderScreenComponent extends React.Component {
static navigationOptions(options) {
return { header: null };
}
constructor() {
super();
this.state = { folder: Folder.new() };
this.originalFolder = null;
}
componentWillMount() {
if (!this.props.folderId) {
this.setState({ folder: Folder.new() });
} else {
Folder.load(this.props.folderId).then((folder) => {
this.originalFolder = Object.assign({}, folder);
this.setState({ folder: folder });
});
}
}
folderComponent_change(propName, propValue) {
this.setState((prevState, props) => {
let folder = Object.assign({}, prevState.folder);
folder[propName] = propValue;
return { folder: folder }
});
}
title_changeText(text) {
this.folderComponent_change('title', text);
}
saveFolderButton_press() {
console.warn('CHANGE NOT TESTED');
let toSave = BaseModel.diffObjects(this.originalFolder, this.state.folder);
toSave.id = this.state.folder.id;
Folder.save(toSave).then((folder) => {
this.originalFolder = Object.assign({}, folder);
this.setState({ folder: folder });
});
// NoteFolderService.save('folder', this.state.folder, this.originalFolder).then((folder) => {
// this.originalFolder = Object.assign({}, folder);
// this.setState({ folder: folder });
// });
}
render() {
return (
<View style={{flex: 1}}>
<ScreenHeader navState={this.props.navigation.state} />
<TextInput value={this.state.folder.title} onChangeText={(text) => this.title_changeText(text)} />
<Button title="Save folder" onPress={() => this.saveFolderButton_press()} />
</View>
);
}
}
const FolderScreen = connect(
(state) => {
return {
folderId: state.selectedFolderId,
};
}
)(FolderScreenComponent)
export { FolderScreen };

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 'lib/log.js'
import { FolderList } from 'lib/components/folder-list.js'
import { ScreenHeader } from 'lib/components/screen-header.js';
import { _ } from 'lib/locale.js';
import { ActionButton } from 'lib/components/action-button.js';
class FoldersScreenComponent extends React.Component {
static navigationOptions(options) {
return { header: null };
}
render() {
return (
<View style={{flex: 1}}>
<ScreenHeader navState={this.props.navigation.state} />
<FolderList style={{flex: 1}}/>
<ActionButton></ActionButton>
</View>
);
}
}
const FoldersScreen = connect(
(state) => {
return {
folders: state.folders,
};
}
)(FoldersScreenComponent)
export { FoldersScreen };

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 'lib/log.js'
import { Folder } from 'lib/models/folder.js'
import { ScreenHeader } from 'lib/components/screen-header.js';
import { NoteFolderService } from 'lib/services/note-folder-service.js';
class LoadingScreenComponent extends React.Component {
static navigationOptions(options) {
return { header: null };
}
render() {
return (
<View style={{flex: 1}}>
<Text>Loading...</Text>
</View>
);
}
}
const LoadingScreen = connect(
(state) => {
return {};
}
)(LoadingScreenComponent)
export { LoadingScreen };

View File

@@ -0,0 +1,91 @@
import React, { Component } from 'react';
import { View, Button, TextInput, Text } from 'react-native';
import { connect } from 'react-redux'
import { Log } from 'lib/log.js'
import { Setting } from 'lib/models/setting.js';
import { ScreenHeader } from 'lib/components/screen-header.js';
import { _ } from 'lib/locale.js';
class LoginScreenComponent extends React.Component {
static navigationOptions(options) {
return { header: null };
}
constructor() {
super();
this.state = {
email: '',
password: '',
errorMessage: null,
};
}
componentWillMount() {
this.setState({ email: this.props.user.email });
}
email_changeText(text) {
this.setState({ email: text });
}
password_changeText(text) {
this.setState({ password: text });
}
loginButton_press() {
this.setState({ errorMessage: null });
// return Registry.api().post('sessions', null, {
// 'email': this.state.email,
// 'password': this.state.password,
// 'client_id': Setting.value('clientId'),
// }).then((session) => {
// Log.info('Got session', session);
// let user = {
// email: this.state.email,
// session: session.id,
// };
// Setting.setObject('user', user);
// this.props.dispatch({
// type: 'USER_SET',
// user: user,
// });
// this.props.dispatch({
// type: 'Navigation/BACK',
// });
// Registry.api().setSession(session.id);
// //Registry.synchronizer().start();
// }).catch((error) => {
// this.setState({ errorMessage: _('Could not login: %s)', error.message) });
// });
}
render() {
return (
<View style={{flex: 1}}>
<ScreenHeader navState={this.props.navigation.state} />
<TextInput value={this.state.email} onChangeText={(text) => this.email_changeText(text)} keyboardType="email-address" />
<TextInput value={this.state.password} onChangeText={(text) => this.password_changeText(text)} secureTextEntry={true} />
{ this.state.errorMessage && <Text style={{color:'#ff0000'}}>{this.state.errorMessage}</Text> }
<Button title="Login" onPress={() => this.loginButton_press()} />
</View>
);
}
}
const LoginScreen = connect(
(state) => {
return {
user: state.user,
};
}
)(LoginScreenComponent)
export { LoginScreen };

View File

@@ -0,0 +1,123 @@
import React, { Component } from 'react';
import { View, Button, TextInput } from 'react-native';
import { connect } from 'react-redux'
import { Log } from 'lib/log.js'
import { Note } from 'lib/models/note.js'
import { ScreenHeader } from 'lib/components/screen-header.js';
import { Checkbox } from 'lib/components/checkbox.js'
import { NoteFolderService } from 'lib/services/note-folder-service.js';
import { _ } from 'lib/locale.js';
class NoteScreenComponent extends React.Component {
static navigationOptions(options) {
return { header: null };
}
constructor() {
super();
this.state = { note: Note.new() }
this.originalNote = null;
}
componentWillMount() {
if (!this.props.noteId) {
let note = this.props.itemType == 'todo' ? Note.newTodo(this.props.folderId) : Note.new(this.props.folderId);
Log.info(note);
this.setState({ note: note });
} else {
Note.load(this.props.noteId).then((note) => {
this.originalNote = Object.assign({}, note);
this.setState({ note: note });
});
}
}
noteComponent_change(propName, propValue) {
this.setState((prevState, props) => {
let note = Object.assign({}, prevState.note);
note[propName] = propValue;
return { note: note }
});
}
title_changeText(text) {
this.noteComponent_change('title', text);
}
body_changeText(text) {
this.noteComponent_change('body', text);
}
saveNoteButton_press() {
console.warn('CHANGE NOT TESTED');
let isNew = !this.state.note.id;
let toSave = BaseModel.diffObjects(this.originalNote, this.state.note);
toSave.id = this.state.note.id;
Note.save(toSave).then((note) => {
this.originalNote = Object.assign({}, note);
this.setState({ note: note });
if (isNew) return Note.updateGeolocation(note.id);
});
// NoteFolderService.save('note', this.state.note, this.originalNote).then((note) => {
// this.originalNote = Object.assign({}, note);
// this.setState({ note: note });
// });
}
deleteNote_onPress(noteId) {
Log.info('DELETE', noteId);
}
attachFile_onPress(noteId) {
}
menuOptions() {
return [
{ title: _('Attach file'), onPress: () => { this.attachFile_onPress(this.state.note.id); } },
{ title: _('Delete note'), onPress: () => { this.deleteNote_onPress(this.state.note.id); } },
];
}
render() {
const note = this.state.note;
const isTodo = !!Number(note.is_todo);
let todoComponents = null;
if (note.is_todo) {
todoComponents = (
<View>
<Button title="test" onPress={this.saveNoteButton_press} />
</View>
);
}
return (
<View style={{flex: 1}}>
<ScreenHeader navState={this.props.navigation.state} menuOptions={this.menuOptions()} />
<View style={{ flexDirection: 'row' }}>
{ isTodo && <Checkbox checked={!!Number(note.todo_completed)} /> }<TextInput style={{flex:1}} value={note.title} onChangeText={(text) => this.title_changeText(text)} />
</View>
<TextInput style={{flex: 1, textAlignVertical: 'top'}} multiline={true} value={note.body} onChangeText={(text) => this.body_changeText(text)} />
{ todoComponents }
<Button title="Save note" onPress={() => this.saveNoteButton_press()} />
</View>
);
}
}
const NoteScreen = connect(
(state) => {
return {
noteId: state.selectedNoteId,
folderId: state.selectedFolderId,
itemType: state.selectedItemType,
};
}
)(NoteScreenComponent)
export { NoteScreen };

View File

@@ -0,0 +1,68 @@
import React, { Component } from 'react';
import { View, Button, Picker } from 'react-native';
import { connect } from 'react-redux'
import { Log } from 'lib/log.js'
import { NoteList } from 'lib/components/note-list.js'
import { Folder } from 'lib/models/folder.js'
import { ScreenHeader } from 'lib/components/screen-header.js';
import { MenuOption, Text } from 'react-native-popup-menu';
import { _ } from 'lib/locale.js';
import { ActionButton } from 'lib/components/action-button.js';
class NotesScreenComponent extends React.Component {
static navigationOptions(options) {
return { header: null };
}
deleteFolder_onPress(folderId) {
Folder.delete(folderId).then(() => {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Folders',
});
}).catch((error) => {
alert(error.message);
});
}
editFolder_onPress(folderId) {
this.props.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Folder',
folderId: folderId,
});
}
menuOptions() {
return [
{ title: _('Delete folder'), onPress: () => { this.deleteFolder_onPress(this.props.selectedFolderId); } },
{ title: _('Edit folder'), onPress: () => { this.editFolder_onPress(this.props.selectedFolderId); } },
];
}
render() {
let folder = Folder.byId(this.props.folders, this.props.selectedFolderId);
let title = folder ? folder.title : null;
const { navigate } = this.props.navigation;
return (
<View style={{flex: 1}}>
<ScreenHeader title={title} navState={this.props.navigation.state} menuOptions={this.menuOptions()} />
<NoteList style={{flex: 1}}/>
<ActionButton parentFolderId={this.props.selectedFolderId}></ActionButton>
</View>
);
}
}
const NotesScreen = connect(
(state) => {
return {
folders: state.folders,
selectedFolderId: state.selectedFolderId,
};
}
)(NotesScreenComponent)
export { NotesScreen };

View File

@@ -0,0 +1,78 @@
import { connect } from 'react-redux'
import { Button } from 'react-native';
import { Log } from 'lib/log.js';
import { Note } from 'lib/models/note.js';
import { NoteFolderService } from 'lib/services/note-folder-service.js';
const React = require('react');
const {
Dimensions,
StyleSheet,
ScrollView,
View,
Image,
Text,
} = require('react-native');
const { Component } = React;
const window = Dimensions.get('window');
const styles = StyleSheet.create({
menu: {
flex: 1,
backgroundColor: 'white',
padding: 20,
},
name: {
position: 'absolute',
left: 70,
top: 20,
},
item: {
fontSize: 14,
fontWeight: '300',
paddingTop: 5,
},
button: {
flex: 1,
textAlign: 'left',
}
});
class SideMenuContentComponent extends Component {
folder_press(folder) {
this.props.dispatch({
type: 'SIDE_MENU_CLOSE',
});
NoteFolderService.openNoteList(folder.id);
}
render() {
let buttons = [];
for (let i = 0; i < this.props.folders.length; i++) {
let f = this.props.folders[i];
let title = f.title;
buttons.push(
<Button style={styles.button} title={title} onPress={() => { this.folder_press(f) }} key={f.id} />
);
}
return (
<ScrollView scrollsToTop={false} style={styles.menu}>
{ buttons }
</ScrollView>
);
}
};
const SideMenuContent = connect(
(state) => {
return {
folders: state.folders,
};
}
)(SideMenuContentComponent)
export { SideMenuContent };

View File

@@ -0,0 +1,16 @@
import React, { Component } from 'react';
import { connect } from 'react-redux'
import { Log } from 'lib/log.js';
import SideMenu_ from 'react-native-side-menu';
class SideMenuComponent extends SideMenu_ {};
const SideMenu = connect(
(state) => {
return {
isOpen: state.showSideMenu,
};
}
)(SideMenuComponent)
export { SideMenu };

View File

@@ -0,0 +1,63 @@
const sqlite3 = require('sqlite3').verbose();
const Promise = require('promise');
class DatabaseDriverNode {
open(options) {
return new Promise((resolve, reject) => {
this.db_ = new sqlite3.Database(options.name, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
setDebugMode(v) {
// ??
}
selectOne(sql, params = null) {
if (!params) params = {};
return new Promise((resolve, reject) => {
this.db_.get(sql, params, (error, row) => {
if (error) {
reject(error);
return;
}
resolve(row);
});
});
}
selectAll(sql, params = null) {
if (!params) params = {};
return new Promise((resolve, reject) => {
this.db_.all(sql, params, (error, row) => {
if (error) {
reject(error);
return;
}
resolve(row);
});
});
}
exec(sql, params = null) {
if (!params) params = {};
return new Promise((resolve, reject) => {
this.db_.run(sql, params, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
export { DatabaseDriverNode };

View File

@@ -0,0 +1,52 @@
import SQLite from 'react-native-sqlite-storage';
class DatabaseDriverReactNative {
open(options) {
return new Promise((resolve, reject) => {
SQLite.openDatabase({ name: options.name }, (db) => {
this.db_ = db;
resolve();
}, (error) => {
reject(error);
});
});
}
setDebugMode(v) {
//SQLite.DEBUG(v);
}
selectOne(sql, params = null) {
return new Promise((resolve, reject) => {
this.db_.executeSql(sql, params, (r) => {
resolve(r.rows.length ? r.rows.item(0) : null);
}, (error) => {
reject(error);
});
});
}
selectAll(sql, params = null) {
return this.exec(sql, params).then((r) => {
let output = []
for (let i = 0; i < r.rows.length; i++) {
output.push(r.rows.item(i));
}
return output;
});
}
exec(sql, params = null) {
return new Promise((resolve, reject) => {
this.db_.executeSql(sql, params, (r) => {
resolve(r);
}, (error) => {
reject(error);
});
});
}
}
export { DatabaseDriverReactNative }

View File

@@ -0,0 +1,475 @@
import { uuid } from 'lib/uuid.js';
import { promiseChain } from 'lib/promise-utils.js';
import { Logger } from 'lib/logger.js'
import { time } from 'lib/time-utils.js'
import { _ } from 'lib/locale.js'
import { sprintf } from 'sprintf-js';
const structureSql = `
CREATE TABLE folders (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL DEFAULT "",
title TEXT NOT NULL DEFAULT "",
created_time INT NOT NULL,
updated_time INT NOT NULL,
sync_time INT NOT NULL DEFAULT 0
);
CREATE INDEX folders_title ON folders (title);
CREATE INDEX folders_updated_time ON folders (updated_time);
CREATE INDEX folders_sync_time ON folders (sync_time);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL DEFAULT "",
title TEXT NOT NULL DEFAULT "",
body TEXT NOT NULL DEFAULT "",
created_time INT NOT NULL,
updated_time INT NOT NULL,
sync_time INT NOT NULL DEFAULT 0,
is_conflict INT NOT NULL DEFAULT 0,
latitude NUMERIC NOT NULL DEFAULT 0,
longitude NUMERIC NOT NULL DEFAULT 0,
altitude NUMERIC NOT NULL DEFAULT 0,
author TEXT NOT NULL DEFAULT "",
source_url TEXT NOT NULL DEFAULT "",
is_todo INT NOT NULL DEFAULT 0,
todo_due INT NOT NULL DEFAULT 0,
todo_completed INT NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT "",
source_application TEXT NOT NULL DEFAULT "",
application_data TEXT NOT NULL DEFAULT "",
\`order\` INT NOT NULL DEFAULT 0
);
CREATE INDEX notes_title ON notes (title);
CREATE INDEX notes_updated_time ON notes (updated_time);
CREATE INDEX notes_sync_time ON notes (sync_time);
CREATE INDEX notes_is_conflict ON notes (is_conflict);
CREATE INDEX notes_is_todo ON notes (is_todo);
CREATE INDEX notes_order ON notes (\`order\`);
CREATE TABLE deleted_items (
id INTEGER PRIMARY KEY,
item_type INT NOT NULL,
item_id TEXT NOT NULL,
deleted_time INT NOT NULL
);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT "",
created_time INT NOT NULL,
updated_time INT NOT NULL,
sync_time INT NOT NULL DEFAULT 0
);
CREATE TABLE note_tags (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
created_time INT NOT NULL,
updated_time INT NOT NULL,
sync_time INT NOT NULL DEFAULT 0
);
CREATE TABLE resources (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT "",
mime TEXT NOT NULL,
filename TEXT NOT NULL,
created_time INT NOT NULL,
updated_time INT NOT NULL,
sync_time INT NOT NULL DEFAULT 0
);
CREATE TABLE settings (
\`key\` TEXT PRIMARY KEY,
\`value\` TEXT,
\`type\` INT
);
CREATE TABLE table_fields (
id INTEGER PRIMARY KEY,
table_name TEXT,
field_name TEXT,
field_type INT,
field_default TEXT
);
CREATE TABLE version (
version INT
);
INSERT INTO version (version) VALUES (1);
`;
class Database {
constructor(driver) {
this.debugMode_ = false;
this.initialized_ = false;
this.tableFields_ = null;
this.driver_ = driver;
this.inTransaction_ = false;
this.logger_ = new Logger();
this.logger_.addTarget('console');
this.logger_.setLevel(Logger.LEVEL_DEBUG);
}
// Converts the SQLite error to a regular JS error
// so that it prints a stacktrace when passed to
// console.error()
sqliteErrorToJsError(error, sql = null, params = null) {
let msg = [error.toString()];
if (sql) msg.push(sql);
if (params) msg.push(params);
let output = new Error(msg.join(': '));
if (error.code) output.code = error.code;
return output;
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
}
initialized() {
return this.initialized_;
}
driver() {
return this.driver_;
}
async open(options) {
await this.driver().open(options);
this.logger().info('Database was open successfully');
return this.initialize();
}
escapeField(field) {
if (field == '*') return '*';
return '`' + field + '`';
}
escapeFields(fields) {
if (fields == '*') return '*';
let output = [];
for (let i = 0; i < fields.length; i++) {
output.push(this.escapeField(fields[i]));
}
return output;
}
async tryCall(callName, sql, params) {
if (typeof sql === 'object') {
params = sql.params;
sql = sql.sql;
}
let waitTime = 50;
let totalWaitTime = 0;
while (true) {
try {
this.logQuery(sql, params);
let result = await this.driver()[callName](sql, params);
return result; // No exception was thrown
} catch (error) {
if (error && (error.code == 'SQLITE_IOERR' || error.code == 'SQLITE_BUSY')) {
if (totalWaitTime >= 20000) throw this.sqliteErrorToJsError(error, sql, params);
this.logger().warn(sprintf('Error %s: will retry in %s milliseconds', error.code, waitTime));
this.logger().warn('Error was: ' + error.toString());
await time.msleep(waitTime);
totalWaitTime += waitTime;
waitTime *= 1.5;
} else {
throw this.sqliteErrorToJsError(error, sql, params);
}
}
}
}
async selectOne(sql, params = null) {
return this.tryCall('selectOne', sql, params);
}
async selectAll(sql, params = null) {
return this.tryCall('selectAll', sql, params);
}
async exec(sql, params = null) {
return this.tryCall('exec', sql, params);
}
transactionExecBatch(queries) {
if (queries.length <= 0) return Promise.resolve();
if (queries.length == 1) {
let q = this.wrapQuery(queries[0]);
return this.exec(q.sql, q.params);
}
// There can be only one transaction running at a time so queue
// any new transaction here.
if (this.inTransaction_) {
return new Promise((resolve, reject) => {
let iid = setInterval(() => {
if (!this.inTransaction_) {
clearInterval(iid);
this.transactionExecBatch(queries).then(() => {
resolve();
}).catch((error) => {
reject(error);
});
}
}, 100);
});
}
this.inTransaction_ = true;
queries.splice(0, 0, 'BEGIN TRANSACTION');
queries.push('COMMIT'); // Note: ROLLBACK is currently not supported
let chain = [];
for (let i = 0; i < queries.length; i++) {
let query = this.wrapQuery(queries[i]);
chain.push(() => {
return this.exec(query.sql, query.params);
});
}
return promiseChain(chain).then(() => {
this.inTransaction_ = false;
});
}
static enumId(type, s) {
if (type == 'settings') {
if (s == 'int') return 1;
if (s == 'string') return 2;
}
if (type == 'fieldType') {
return this['TYPE_' + s];
}
throw new Error('Unknown enum type or value: ' + type + ', ' + s);
}
tableFieldNames(tableName) {
let tf = this.tableFields(tableName);
let output = [];
for (let i = 0; i < tf.length; i++) {
output.push(tf[i].name);
}
return output;
}
tableFields(tableName) {
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
if (!this.tableFields_[tableName]) throw new Error('Unknown table: ' + tableName);
return this.tableFields_[tableName];
}
static formatValue(type, value) {
if (value === null || value === undefined) return null;
if (type == this.TYPE_INT) return Number(value);
if (type == this.TYPE_TEXT) return value;
if (type == this.TYPE_NUMERIC) return Number(value);
throw new Error('Unknown type: ' + type);
}
sqlStringToLines(sql) {
let output = [];
let lines = sql.split("\n");
let statement = '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == '') continue;
if (line.substr(0, 2) == "--") continue;
statement += line;
if (line[line.length - 1] == ';') {
output.push(statement);
statement = '';
}
}
return output;
}
logQuery(sql, params = null) {
this.logger().debug(sql);
if (params !== null && params.length) this.logger().debug(JSON.stringify(params));
}
static insertQuery(tableName, data) {
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
let keySql= '';
let valueSql = '';
let params = [];
for (let key in data) {
if (!data.hasOwnProperty(key)) continue;
if (key[key.length - 1] == '_') continue;
if (keySql != '') keySql += ', ';
if (valueSql != '') valueSql += ', ';
keySql += '`' + key + '`';
valueSql += '?';
params.push(data[key]);
}
return {
sql: 'INSERT INTO `' + tableName + '` (' + keySql + ') VALUES (' + valueSql + ')',
params: params,
};
}
static updateQuery(tableName, data, where) {
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
let sql = '';
let params = [];
for (let key in data) {
if (!data.hasOwnProperty(key)) continue;
if (key[key.length - 1] == '_') continue;
if (sql != '') sql += ', ';
sql += '`' + key + '`=?';
params.push(data[key]);
}
if (typeof where != 'string') {
params.push(where.id);
where = 'id=?';
}
return {
sql: 'UPDATE `' + tableName + '` SET ' + sql + ' WHERE ' + where,
params: params,
};
}
wrapQueries(queries) {
let output = [];
for (let i = 0; i < queries.length; i++) {
output.push(this.wrapQuery(queries[i]));
}
return output;
}
wrapQuery(sql, params = null) {
if (!sql) throw new Error('Cannot wrap empty string: ' + sql);
if (sql.constructor === Array) {
let output = {};
output.sql = sql[0];
output.params = sql.length >= 2 ? sql[1] : null;
return output;
} else if (typeof sql === 'string') {
return { sql: sql, params: params };
} else {
return sql; // Already wrapped
}
}
refreshTableFields() {
this.logger().info('Initializing tables...');
let queries = [];
queries.push(this.wrapQuery('DELETE FROM table_fields'));
return this.selectAll('SELECT name FROM sqlite_master WHERE type="table"').then((tableRows) => {
let chain = [];
for (let i = 0; i < tableRows.length; i++) {
let tableName = tableRows[i].name;
if (tableName == 'android_metadata') continue;
if (tableName == 'table_fields') continue;
chain.push(() => {
return this.selectAll('PRAGMA table_info("' + tableName + '")').then((pragmas) => {
for (let i = 0; i < pragmas.length; i++) {
let item = pragmas[i];
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
let defaultValue = item.dflt_value;
if (typeof defaultValue == 'string' && defaultValue.length >= 2 && defaultValue[0] == '"' && defaultValue[defaultValue.length - 1] == '"') {
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
}
let q = Database.insertQuery('table_fields', {
table_name: tableName,
field_name: item.name,
field_type: Database.enumId('fieldType', item.type),
field_default: defaultValue,
});
queries.push(q);
}
});
});
}
return promiseChain(chain);
}).then(() => {
return this.transactionExecBatch(queries);
});
}
async initialize() {
this.logger().info('Checking for database schema update...');
for (let initLoopCount = 1; initLoopCount <= 2; initLoopCount++) {
try {
let row = await this.selectOne('SELECT * FROM version LIMIT 1');
this.logger().info('Current database version', row);
// TODO: version update logic
// TODO: only do this if db has been updated:
// return this.refreshTableFields();
} catch (error) {
if (error && error.code != 0 && error.code != 'SQLITE_ERROR') throw this.sqliteErrorToJsError(error);
// Assume that error was:
// { message: 'no such table: version (code 1): , while compiling: SELECT * FROM version', code: 0 }
// which means the database is empty and the tables need to be created.
// If it's any other error there's nothing we can do anyway.
this.logger().info('Database is new - creating the schema...');
let queries = this.wrapQueries(this.sqlStringToLines(structureSql));
queries.push(this.wrapQuery('INSERT INTO settings (`key`, `value`, `type`) VALUES ("clientId", "' + uuid.create() + '", "' + Database.enumId('settings', 'string') + '")'));
try {
await this.transactionExecBatch(queries);
this.logger().info('Database schema created successfully');
await this.refreshTableFields();
} catch (error) {
throw this.sqliteErrorToJsError(error);
}
// Now that the database has been created, go through the normal initialisation process
continue;
}
this.tableFields_ = {};
let rows = await this.selectAll('SELECT * FROM table_fields');
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = [];
this.tableFields_[row.table_name].push({
name: row.field_name,
type: row.field_type,
default: Database.formatValue(row.field_type, row.field_default),
});
}
break;
}
}
}
Database.TYPE_INT = 1;
Database.TYPE_TEXT = 2;
Database.TYPE_NUMERIC = 3;
export { Database };

View File

@@ -0,0 +1,179 @@
import fs from 'fs-extra';
import { promiseChain } from 'lib/promise-utils.js';
import moment from 'moment';
import { time } from 'lib/time-utils.js';
class FileApiDriverLocal {
fsErrorToJsError_(error) {
let msg = error.toString();
let output = new Error(msg);
if (error.code) output.code = error.code;
return output;
}
stat(path) {
return new Promise((resolve, reject) => {
fs.stat(path, (error, s) => {
if (error) {
if (error.code == 'ENOENT') {
resolve(null);
} else {
reject(this.fsErrorToJsError_(error));
}
return;
}
resolve(this.metadataFromStats_(path, s));
});
});
}
statTimeToTimestampMs_(time) {
let m = moment(time, 'YYYY-MM-DDTHH:mm:ss.SSSZ');
if (!m.isValid()) {
throw new Error('Invalid date: ' + time);
}
return m.toDate().getTime();
}
metadataFromStats_(path, stats) {
return {
path: path,
created_time: this.statTimeToTimestampMs_(stats.birthtime),
updated_time: this.statTimeToTimestampMs_(stats.mtime),
created_time_orig: stats.birthtime,
updated_time_orig: stats.mtime,
isDir: stats.isDirectory(),
};
}
setTimestamp(path, timestampMs) {
return new Promise((resolve, reject) => {
let t = Math.floor(timestampMs / 1000);
fs.utimes(path, t, t, (error) => {
if (error) {
reject(this.fsErrorToJsError_(error));
return;
}
resolve();
});
});
}
async list(path, options) {
try {
let items = await fs.readdir(path);
let output = [];
for (let i = 0; i < items.length; i++) {
let stat = await this.stat(path + '/' + items[i]);
if (!stat) continue; // Has been deleted between the readdir() call and now
stat.path = items[i];
output.push(stat);
}
return {
items: output,
hasMore: false,
context: null,
};
} catch(error) {
throw this.fsErrorToJsError_(error);
}
}
async get(path, options) {
let output = null;
try {
if (options.encoding == 'binary') {
output = fs.readFile(path);
} else {
output = fs.readFile(path, options.encoding);
}
} catch (error) {
if (error.code == 'ENOENT') return null;
throw this.fsErrorToJsError_(error);
}
return output;
}
mkdir(path) {
return new Promise((resolve, reject) => {
fs.exists(path, (exists) => {
if (exists) {
resolve();
return;
}
const mkdirp = require('mkdirp');
mkdirp(path, (error) => {
if (error) {
reject(this.fsErrorToJsError_(error));
} else {
resolve();
}
});
});
});
}
put(path, content) {
return new Promise((resolve, reject) => {
fs.writeFile(path, content, function(error) {
if (error) {
reject(this.fsErrorToJsError_(error));
} else {
resolve();
}
});
});
}
delete(path) {
return new Promise((resolve, reject) => {
fs.unlink(path, function(error) {
if (error) {
if (error && error.code == 'ENOENT') {
// File doesn't exist - it's fine
resolve();
} else {
reject(this.fsErrorToJsError_(error));
}
} else {
resolve();
}
});
});
}
async move(oldPath, newPath) {
let lastError = null;
for (let i = 0; i < 5; i++) {
try {
let output = await fs.move(oldPath, newPath, { overwrite: true });
return output;
} catch (error) {
lastError = error;
// Normally cannot happen with the `overwrite` flag but sometime it still does.
// In this case, retry.
if (error.code == 'EEXIST') {
await time.sleep(1);
continue;
}
throw this.fsErrorToJsError_(error);
}
}
throw lastError;
}
format() {
throw new Error('Not supported');
}
}
export { FileApiDriverLocal };

View File

@@ -0,0 +1,117 @@
import { time } from 'lib/time-utils.js';
class FileApiDriverMemory {
constructor() {
this.items_ = [];
}
itemIndexByPath(path) {
for (let i = 0; i < this.items_.length; i++) {
if (this.items_[i].path == path) return i;
}
return -1;
}
itemByPath(path) {
let index = this.itemIndexByPath(path);
return index < 0 ? null : this.items_[index];
}
newItem(path, isDir = false) {
let now = time.unixMs();
return {
path: path,
isDir: isDir,
updated_time: now, // In milliseconds!!
created_time: now, // In milliseconds!!
content: '',
};
}
stat(path) {
let item = this.itemByPath(path);
return Promise.resolve(item ? Object.assign({}, item) : null);
}
setTimestamp(path, timestampMs) {
let item = this.itemByPath(path);
if (!item) return Promise.reject(new Error('File not found: ' + path));
item.updated_time = timestampMs;
return Promise.resolve();
}
list(path, options) {
let output = [];
for (let i = 0; i < this.items_.length; i++) {
let item = this.items_[i];
if (item.path == path) continue;
if (item.path.indexOf(path + '/') === 0) {
let s = item.path.substr(path.length + 1);
if (s.split('/').length === 1) {
let it = Object.assign({}, item);
it.path = it.path.substr(path.length + 1);
output.push(it);
}
}
}
return Promise.resolve({
items: output,
hasMore: false,
context: null,
});
}
get(path) {
let item = this.itemByPath(path);
if (!item) return Promise.resolve(null);
if (item.isDir) return Promise.reject(new Error(path + ' is a directory, not a file'));
return Promise.resolve(item.content);
}
mkdir(path) {
let index = this.itemIndexByPath(path);
if (index >= 0) return Promise.resolve();
this.items_.push(this.newItem(path, true));
return Promise.resolve();
}
put(path, content) {
let index = this.itemIndexByPath(path);
if (index < 0) {
let item = this.newItem(path, false);
item.content = content;
this.items_.push(item);
} else {
this.items_[index].content = content;
this.items_[index].updated_time = time.unix();
}
return Promise.resolve();
}
delete(path) {
let index = this.itemIndexByPath(path);
if (index >= 0) {
this.items_.splice(index, 1);
}
return Promise.resolve();
}
move(oldPath, newPath) {
let sourceItem = this.itemByPath(oldPath);
if (!sourceItem) return Promise.reject(new Error('Path not found: ' + oldPath));
this.delete(newPath); // Overwrite if newPath already exists
sourceItem.path = newPath;
return Promise.resolve();
}
format() {
this.items_ = [];
return Promise.resolve();
}
}
export { FileApiDriverMemory };

View File

@@ -0,0 +1,149 @@
import moment from 'moment';
import { time } from 'lib/time-utils.js';
import { dirname, basename } from 'lib/path-utils.js';
import { OneDriveApi } from 'lib/onedrive-api.js';
class FileApiDriverOneDrive {
constructor(clientId, clientSecret) {
this.api_ = new OneDriveApi(clientId, clientSecret);
}
api() {
return this.api_;
}
itemFilter_() {
return {
select: 'name,file,folder,fileSystemInfo',
}
}
makePath_(path) {
return path;
}
makeItems_(odItems) {
let output = [];
for (let i = 0; i < odItems.length; i++) {
output.push(this.makeItem_(odItems[i]));
}
return output;
}
makeItem_(odItem) {
return {
path: odItem.name,
isDir: ('folder' in odItem),
created_time: moment(odItem.fileSystemInfo.createdDateTime, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x'),
updated_time: moment(odItem.fileSystemInfo.lastModifiedDateTime, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x'),
};
}
async statRaw_(path) {
let item = null;
try {
item = await this.api_.execJson('GET', this.makePath_(path), this.itemFilter_());
} catch (error) {
if (error.code == 'itemNotFound') return null;
throw error;
}
return item;
}
async stat(path) {
let item = await this.statRaw_(path);
if (!item) return null;
return this.makeItem_(item);
}
async setTimestamp(path, timestamp) {
let body = {
fileSystemInfo: {
lastModifiedDateTime: moment.unix(timestamp / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z',
}
};
let item = await this.api_.execJson('PATCH', this.makePath_(path), null, body);
return this.makeItem_(item);
}
async list(path, options = null) {
let query = this.itemFilter_();
let url = this.makePath_(path) + ':/children';
if (options.context) {
query = null;
url = options.context;
}
let r = await this.api_.execJson('GET', url, query);
return {
hasMore: !!r['@odata.nextLink'],
items: this.makeItems_(r.value),
context: r["@odata.nextLink"],
}
}
async get(path) {
let content = null;
try {
content = await this.api_.execText('GET', this.makePath_(path) + ':/content');
} catch (error) {
if (error.code == 'itemNotFound') return null;
throw error;
}
return content;
}
async mkdir(path) {
let item = await this.stat(path);
if (item) return item;
let parentPath = dirname(path);
item = await this.api_.execJson('POST', this.makePath_(parentPath) + ':/children', this.itemFilter_(), {
name: basename(path),
folder: {},
});
return this.makeItem_(item);
}
put(path, content) {
let options = {
headers: { 'Content-Type': 'text/plain' },
};
return this.api_.exec('PUT', this.makePath_(path) + ':/content', null, content, options);
}
delete(path) {
return this.api_.exec('DELETE', this.makePath_(path));
}
async move(oldPath, newPath) {
let previousItem = await this.statRaw_(oldPath);
let newDir = dirname(newPath);
let newName = basename(newPath);
// We don't want the modification date to change when we move the file so retrieve it
// now set it in the PATCH operation.
let item = await this.api_.execJson('PATCH', this.makePath_(oldPath), this.itemFilter_(), {
name: newName,
parentReference: { path: newDir },
fileSystemInfo: {
lastModifiedDateTime: previousItem.fileSystemInfo.lastModifiedDateTime,
},
});
return this.makeItem_(item);
}
format() {
throw new Error('Not implemented');
}
}
export { FileApiDriverOneDrive };

View File

@@ -0,0 +1,92 @@
import { isHidden } from 'lib/path-utils.js';
import { Logger } from 'lib/logger.js';
class FileApi {
constructor(baseDir, driver) {
this.baseDir_ = baseDir;
this.driver_ = driver;
this.logger_ = new Logger();
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
}
fullPath_(path) {
let output = this.baseDir_;
if (path != '') output += '/' + path;
return output;
}
// DRIVER MUST RETURN PATHS RELATIVE TO `path`
list(path = '', options = null) {
if (!options) options = {};
if (!('includeHidden' in options)) options.includeHidden = false;
if (!('context' in options)) options.context = null;
this.logger().debug('list ' + this.baseDir_);
return this.driver_.list(this.baseDir_, options).then((result) => {
if (!options.includeHidden) {
let temp = [];
for (let i = 0; i < result.items.length; i++) {
if (!isHidden(result.items[i].path)) temp.push(result.items[i]);
}
result.items = temp;
}
return result;
});
}
setTimestamp(path, timestampMs) {
this.logger().debug('setTimestamp ' + this.fullPath_(path));
return this.driver_.setTimestamp(this.fullPath_(path), timestampMs);
}
mkdir(path) {
this.logger().debug('mkdir ' + this.fullPath_(path));
return this.driver_.mkdir(this.fullPath_(path));
}
stat(path) {
this.logger().debug('stat ' + this.fullPath_(path));
return this.driver_.stat(this.fullPath_(path)).then((output) => {
if (!output) return output;
output.path = path;
return output;
});
}
get(path, options = {}) {
if (!options.encoding) options.encoding = 'utf8';
this.logger().debug('get ' + this.fullPath_(path));
return this.driver_.get(this.fullPath_(path), options);
}
put(path, content) {
this.logger().debug('put ' + this.fullPath_(path));
return this.driver_.put(this.fullPath_(path), content);
}
delete(path) {
this.logger().debug('delete ' + this.fullPath_(path));
return this.driver_.delete(this.fullPath_(path));
}
move(oldPath, newPath) {
this.logger().debug('move ' + this.fullPath_(oldPath) + ' => ' + this.fullPath_(newPath));
return this.driver_.move(this.fullPath_(oldPath), this.fullPath_(newPath));
}
format() {
return this.driver_.format();
}
}
export { FileApi };

View File

@@ -0,0 +1,39 @@
class GeolocationReact {
static currentPosition_testResponse() {
return {
mocked: false,
timestamp: (new Date()).getTime(),
coords: {
speed: 0,
heading: 0,
accuracy: 20,
longitude: -3.4596633911132812,
altitude: 0,
latitude: 48.73219093634444
}
}
}
static currentPosition(options = null) {
if (typeof navigator === 'undefined') {
// TODO
return Promise.resolve(this.currentPosition_testResponse());
}
if (!options) options = {};
if (!('enableHighAccuracy' in options)) options.enableHighAccuracy = true;
if (!('timeout' in options)) options.timeout = 10000;
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition((data) => {
resolve(data);
}, (error) => {
rejec(error);
}, options);
});
}
}
export { GeolocationReact };

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 { _ };

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_DEBUG : this.level_;
}
static debug(...o) {
if (Log.level() > Log.LEVEL_DEBUG) return;
console.info(...o);
}
static info(...o) {
if (Log.level() > Log.LEVEL_INFO) return;
console.info(...o);
}
static warn(...o) {
if (Log.level() > Log.LEVEL_WARN) return;
console.info(...o);
}
static error(...o) {
if (Log.level() > Log.LEVEL_ERROR) return;
console.info(...o);
}
}
Log.LEVEL_DEBUG = 0;
Log.LEVEL_INFO = 10;
Log.LEVEL_WARN = 20;
Log.LEVEL_ERROR = 30;
export { Log };

View File

@@ -0,0 +1,121 @@
import moment from 'moment';
import fs from 'fs-extra';
import { _ } from 'lib/locale.js';
class Logger {
constructor() {
this.targets_ = [];
this.level_ = Logger.LEVEL_ERROR;
this.fileAppendQueue_ = []
}
setLevel(level) {
this.level_ = level;
}
level() {
return this.level_;
}
clearTargets() {
this.targets_.clear();
}
addTarget(type, options = null) {
let target = { type: type };
for (let n in options) {
if (!options.hasOwnProperty(n)) continue;
target[n] = options[n];
}
this.targets_.push(target);
}
log(level, object) {
if (this.level() < level || !this.targets_.length) return;
let levelString = '';
if (this.level() == Logger.LEVEL_INFO) levelString = '[info] ';
if (this.level() == Logger.LEVEL_WARN) levelString = '[warn] ';
if (this.level() == Logger.LEVEL_ERROR) levelString = '[error] ';
let line = moment().format('YYYY-MM-DD HH:mm:ss') + ': ' + levelString;
for (let i = 0; i < this.targets_.length; i++) {
let t = this.targets_[i];
if (t.type == 'console') {
let fn = 'debug';
if (level = Logger.LEVEL_ERROR) fn = 'error';
if (level = Logger.LEVEL_WARN) fn = 'warn';
if (level = Logger.LEVEL_INFO) fn = 'info';
if (typeof object === 'object') {
console[fn](line, object);
} else {
console[fn](line + object);
}
} else if (t.type == 'file') {
let serializedObject = '';
if (typeof object === 'object') {
if (object instanceof Error) {
serializedObject = object.toString();
if (object.stack) serializedObject += "\n" + object.stack;
} else {
serializedObject = JSON.stringify(object);
}
} else {
serializedObject = object;
}
fs.appendFileSync(t.path, line + serializedObject + "\n");
// this.fileAppendQueue_.push({
// path: t.path,
// line: line + serializedObject + "\n",
// });
// this.scheduleFileAppendQueueProcessing_();
} else if (t.type == 'vorpal') {
t.vorpal.log(object);
}
}
}
// scheduleFileAppendQueueProcessing_() {
// if (this.fileAppendQueueTID_) return;
// this.fileAppendQueueTID_ = setTimeout(async () => {
// this.fileAppendQueueTID_ = null;
// let queue = this.fileAppendQueue_.slice(0);
// for (let i = 0; i < queue.length; i++) {
// let t = queue[i];
// await fs.appendFile(t.path, t.line);
// }
// this.fileAppendQueue_.splice(0, queue.length);
// }, 1);
// }
error(object) { return this.log(Logger.LEVEL_ERROR, object); }
warn(object) { return this.log(Logger.LEVEL_WARN, object); }
info(object) { return this.log(Logger.LEVEL_INFO, object); }
debug(object) { return this.log(Logger.LEVEL_DEBUG, object); }
static levelStringToId(s) {
if (s == 'none') return Logger.LEVEL_NONE;
if (s == 'error') return Logger.LEVEL_ERROR;
if (s == 'warn') return Logger.LEVEL_WARN;
if (s == 'info') return Logger.LEVEL_INFO;
if (s == 'debug') return Logger.LEVEL_DEBUG;
throw new Error(_('Unknown log level: %s', s));
}
}
Logger.LEVEL_NONE = 0;
Logger.LEVEL_ERROR = 10;
Logger.LEVEL_WARN = 20;
Logger.LEVEL_INFO = 30;
Logger.LEVEL_DEBUG = 40;
export { Logger };

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,244 @@
import { BaseModel } from 'lib/base-model.js';
import { Database } from 'lib/database.js';
import { time } from 'lib/time-utils.js';
import moment from 'moment';
class BaseItem extends BaseModel {
static useUuid() {
return true;
}
// Need to dynamically load the classes like this to avoid circular dependencies
static getClass(name) {
if (!this.classes_) this.classes_ = {};
if (this.classes_[name]) return this.classes_[name];
let filename = name.toLowerCase();
if (name == 'NoteTag') filename = 'note-tag';
this.classes_[name] = require('lib/models/' + filename + '.js')[name];
return this.classes_[name];
}
static systemPath(itemOrId) {
if (typeof itemOrId === 'string') return itemOrId + '.md';
return itemOrId.id + '.md';
}
static itemClass(item) {
if (!item) throw new Error('Item cannot be null');
if (typeof item === 'object') {
if (!('type_' in item)) throw new Error('Item does not have a type_ property');
return this.itemClass(item.type_);
} else {
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
let d = BaseItem.syncItemDefinitions_[i];
if (Number(item) == d.type) return this.getClass(d.className);
}
throw new Error('Unknown type: ' + item);
}
}
// Returns the IDs of the items that have been synced at least once
static async syncedItems() {
let folders = await this.getClass('Folder').modelSelectAll('SELECT id FROM folders WHERE sync_time > 0');
let notes = await this.getClass('Note').modelSelectAll('SELECT id FROM notes WHERE is_conflict = 0 AND sync_time > 0');
let resources = await this.getClass('Resource').modelSelectAll('SELECT id FROM resources WHERE sync_time > 0');
let tags = await this.getClass('Tag').modelSelectAll('SELECT id FROM tags WHERE sync_time > 0');
let noteTags = await this.getClass('NoteTag').modelSelectAll('SELECT id FROM note_tags WHERE sync_time > 0');
return folders.concat(notes).concat(resources).concat(tags).concat(noteTags);
}
static pathToId(path) {
let s = path.split('.');
return s[0];
}
static loadItemByPath(path) {
return this.loadItemById(this.pathToId(path));
}
static async loadItemById(id) {
let classes = this.syncItemClassNames();
for (let i = 0; i < classes.length; i++) {
let item = await this.getClass(classes[i]).load(id);
if (item) return item;
}
return null;
}
static loadItemByField(itemType, field, value) {
let ItemClass = this.itemClass(itemType);
return ItemClass.loadByField(field, value);
}
static loadItem(itemType, id) {
let ItemClass = this.itemClass(itemType);
return ItemClass.load(id);
}
static deleteItem(itemType, id) {
let ItemClass = this.itemClass(itemType);
return ItemClass.delete(id);
}
static async delete(id, options = null) {
let trackDeleted = true;
if (options && options.trackDeleted !== null && options.trackDeleted !== undefined) trackDeleted = options.trackDeleted;
await super.delete(id, options);
if (trackDeleted) {
await this.db().exec('INSERT INTO deleted_items (item_type, item_id, deleted_time) VALUES (?, ?, ?)', [this.modelType(), id, time.unixMs()]);
}
}
static deletedItems() {
return this.db().selectAll('SELECT * FROM deleted_items');
}
static remoteDeletedItem(itemId) {
return this.db().exec('DELETE FROM deleted_items WHERE item_id = ?', [itemId]);
}
static serialize_format(propName, propValue) {
if (['created_time', 'updated_time'].indexOf(propName) >= 0) {
if (!propValue) return '';
propValue = moment.unix(propValue / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
} else if (propValue === null || propValue === undefined) {
propValue = '';
}
return propValue;
}
static unserialize_format(type, propName, propValue) {
if (propName[propName.length - 1] == '_') return propValue; // Private property
let ItemClass = this.itemClass(type);
if (['created_time', 'updated_time'].indexOf(propName) >= 0) {
if (!propValue) return 0;
propValue = moment(propValue, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x');
} else {
propValue = Database.formatValue(ItemClass.fieldType(propName), propValue);
}
return propValue;
}
static async serialize(item, type = null, shownKeys = null) {
item = this.filter(item);
let output = [];
if ('title' in item) {
output.push(item.title);
output.push('');
}
if ('body' in item) {
output.push(item.body);
if (shownKeys.length) output.push('');
}
for (let i = 0; i < shownKeys.length; i++) {
let key = shownKeys[i];
let value = null;
if (typeof key === 'function') {
let r = await key();
key = r.key;
value = r.value;
} else {
value = this.serialize_format(key, item[key]);
}
output.push(key + ': ' + value);
}
return output.join("\n");
}
static async unserialize(content) {
let lines = content.split("\n");
let output = {};
let state = 'readingProps';
let body = [];
for (let i = lines.length - 1; i >= 0; i--) {
let line = lines[i];
if (state == 'readingProps') {
line = line.trim();
if (line == '') {
state = 'readingBody';
continue;
}
let p = line.indexOf(':');
if (p < 0) throw new Error('Invalid property format: ' + line + ": " + content);
let key = line.substr(0, p).trim();
let value = line.substr(p + 1).trim();
output[key] = value;
} else if (state == 'readingBody') {
body.splice(0, 0, line);
}
}
if (!output.type_) throw new Error('Missing required property: type_: ' + content);
output.type_ = Number(output.type_);
if (body.length) {
let title = body.splice(0, 2);
output.title = title[0];
}
if (body.length) output.body = body.join("\n");
for (let n in output) {
if (!output.hasOwnProperty(n)) continue;
output[n] = await this.unserialize_format(output.type_, n, output[n]);
}
return output;
}
static async itemsThatNeedSync(limit = 100) {
let items = await this.getClass('Folder').modelSelectAll('SELECT * FROM folders WHERE sync_time < updated_time LIMIT ' + limit);
if (items.length) return { hasMore: true, items: items };
items = await this.getClass('Resource').modelSelectAll('SELECT * FROM resources WHERE sync_time < updated_time LIMIT ' + limit);
if (items.length) return { hasMore: true, items: items };
items = await this.getClass('Note').modelSelectAll('SELECT * FROM notes WHERE sync_time < updated_time AND is_conflict = 0 LIMIT ' + limit);
if (items.length) return { hasMore: true, items: items };
items = await this.getClass('Tag').modelSelectAll('SELECT * FROM tags WHERE sync_time < updated_time LIMIT ' + limit);
if (items.length) return { hasMore: true, items: items };
items = await this.getClass('NoteTag').modelSelectAll('SELECT * FROM note_tags WHERE sync_time < updated_time LIMIT ' + limit);
return { hasMore: items.length >= limit, items: items };
}
static syncItemClassNames() {
return BaseItem.syncItemDefinitions_.map((def) => {
return def.className;
});
}
}
// Also update:
// - itemsThatNeedSync()
// - syncedItems()
BaseItem.syncItemDefinitions_ = [
{ type: BaseModel.TYPE_NOTE, className: 'Note' },
{ type: BaseModel.TYPE_FOLDER, className: 'Folder' },
{ type: BaseModel.TYPE_RESOURCE, className: 'Resource' },
{ type: BaseModel.TYPE_TAG, className: 'Tag' },
{ type: BaseModel.TYPE_NOTE_TAG, className: 'NoteTag' },
];
export { BaseItem };

View File

@@ -0,0 +1,96 @@
import { BaseModel } from 'lib/base-model.js';
import { Log } from 'lib/log.js';
import { promiseChain } from 'lib/promise-utils.js';
import { Note } from 'lib/models/note.js';
import { Setting } from 'lib/models/setting.js';
import { _ } from 'lib/locale.js';
import moment from 'moment';
import { BaseItem } from 'lib/models/base-item.js';
import lodash from 'lodash';
class Folder extends BaseItem {
static tableName() {
return 'folders';
}
static async serialize(folder) {
let fieldNames = this.fieldNames();
fieldNames.push('type_');
lodash.pull(fieldNames, 'parent_id', 'sync_time');
return super.serialize(folder, 'folder', fieldNames);
}
static modelType() {
return BaseModel.TYPE_FOLDER;
}
static newFolder() {
return {
id: null,
title: '',
}
}
static noteIds(parentId) {
return this.db().selectAll('SELECT id FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]).then((rows) => {
let output = [];
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
output.push(row.id);
}
return output;
});
}
static async delete(folderId, options = null) {
let folder = await Folder.load(folderId);
if (!folder) throw new Error('Trying to delete non-existing notebook: ' + folderId);
let noteIds = await Folder.noteIds(folderId);
for (let i = 0; i < noteIds.length; i++) {
await Note.delete(noteIds[i]);
}
await super.delete(folderId, options);
this.dispatch({
type: 'FOLDER_DELETE',
folderId: folderId,
});
}
static async all(options = null) {
if (!options) options = {};
let folders = await super.all(options);
if (!options.includeNotes) return folders;
if (options.limit) options.limit -= folders.length;
let notes = await Note.all(options);
return folders.concat(notes);
}
static defaultFolder() {
return this.modelSelectOne('SELECT * FROM folders ORDER BY created_time DESC LIMIT 1');
}
static async save(o, options = null) {
if (options && options.duplicateCheck === true && o.title) {
let existingFolder = await Folder.loadByTitle(o.title);
if (existingFolder) throw new Error(_('A notebook with this title already exists: "%s"', o.title));
}
return super.save(o, options).then((folder) => {
this.dispatch({
type: 'FOLDERS_UPDATE_ONE',
folder: folder,
});
return folder;
});
}
}
export { Folder };

View File

@@ -0,0 +1,25 @@
import { Database } from 'lib/database.js';
import { BaseItem } from 'lib/models/base-item.js';
import { BaseModel } from 'lib/base-model.js';
import lodash from 'lodash';
class NoteTag extends BaseItem {
static tableName() {
return 'note_tags';
}
static modelType() {
return BaseModel.TYPE_NOTE_TAG;
}
static async serialize(item, type = null, shownKeys = null) {
let fieldNames = this.fieldNames();
fieldNames.push('type_');
lodash.pull(fieldNames, 'sync_time');
return super.serialize(item, 'note_tag', fieldNames);
}
}
export { NoteTag };

View File

@@ -0,0 +1,147 @@
import { BaseModel } from 'lib/base-model.js';
import { Log } from 'lib/log.js';
import { Folder } from 'lib/models/folder.js';
import { GeolocationReact } from 'lib/geolocation-react.js';
import { BaseItem } from 'lib/models/base-item.js';
import { Setting } from 'lib/models/setting.js';
import moment from 'moment';
import lodash from 'lodash';
class Note extends BaseItem {
static tableName() {
return 'notes';
}
static async serialize(note, type = null, shownKeys = null) {
let fieldNames = this.fieldNames();
fieldNames.push('type_');
lodash.pull(fieldNames, 'is_conflict', 'sync_time', 'body'); // Exclude 'body' since it's going to be added separately at the top of the note
return super.serialize(note, 'note', fieldNames);
}
static async serializeForEdit(note) {
return super.serialize(note, 'note', []);
}
static async unserializeForEdit(content) {
content += "\n\ntype_: " + BaseModel.TYPE_NOTE;
return super.unserialize(content);
}
static modelType() {
return BaseModel.TYPE_NOTE;
}
static new(parentId = '') {
let output = super.new();
output.parent_id = parentId;
return output;
}
static newTodo(parentId = '') {
let output = this.new(parentId);
output.is_todo = true;
return output;
}
static previewFields() {
return ['id', 'title', 'body', 'is_todo', 'todo_completed', 'parent_id', 'updated_time'];
}
static previewFieldsSql() {
return this.db().escapeFields(this.previewFields()).join(',');
}
static loadFolderNoteByField(folderId, field, value) {
return this.modelSelectOne('SELECT * FROM notes WHERE is_conflict = 0 AND `parent_id` = ? AND `' + field + '` = ?', [folderId, value]);
}
static previews(parentId, options = null) {
if (!options) options = {};
if (!options.orderBy) options.orderBy = 'updated_time';
if (!options.orderByDir) options.orderByDir = 'DESC';
if (!options.conditions) options.conditions = [];
if (!options.conditionsParams) options.conditionsParams = [];
if (!options.fields) options.fields = this.previewFields();
options.conditions.push('is_conflict = 0');
options.conditions.push('parent_id = ?');
options.conditionsParams.push(parentId);
if (options.itemTypes && options.itemTypes.length) {
if (options.itemTypes.indexOf('note') >= 0 && options.itemTypes.indexOf('todo') >= 0) {
// Fetch everything
} else if (options.itemTypes.indexOf('note') >= 0) {
options.conditions.push('is_todo = 0');
} else if (options.itemTypes.indexOf('todo') >= 0) {
options.conditions.push('is_todo = 1');
}
}
return this.search(options);
}
static preview(noteId) {
return this.modelSelectOne('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE is_conflict = 0 AND id = ?', [noteId]);
}
static conflictedNotes() {
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 1');
}
static unconflictedNotes() {
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
}
static updateGeolocation(noteId) {
Log.info('Updating lat/long of note ' + noteId);
let geoData = null;
return GeolocationReact.currentPosition().then((data) => {
Log.info('Got lat/long');
geoData = data;
return Note.load(noteId);
}).then((note) => {
if (!note) return; // Race condition - note has been deleted in the meantime
note.longitude = geoData.coords.longitude;
note.latitude = geoData.coords.latitude;
note.altitude = geoData.coords.altitude;
return Note.save(note);
}).catch((error) => {
Log.info('Cannot get location:', error);
});
}
static filter(note) {
if (!note) return note;
let output = super.filter(note);
if ('longitude' in output) output.longitude = Number(!output.longitude ? 0 : output.longitude).toFixed(8);
if ('latitude' in output) output.latitude = Number(!output.latitude ? 0 : output.latitude).toFixed(8);
if ('altitude' in output) output.altitude = Number(!output.altitude ? 0 : output.altitude).toFixed(4);
return output;
}
static save(o, options = null) {
let isNew = this.isNew(o, options);
if (isNew && !o.source) o.source = Setting.value('appName');
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
return super.save(o, options).then((result) => {
// 'result' could be a partial one at this point (if, for example, only one property of it was saved)
// so call this.preview() so that the right fields are populated.
return this.load(result.id);
}).then((note) => {
this.dispatch({
type: 'NOTES_UPDATE_ONE',
note: note,
});
return note;
});
}
}
export { Note };

View File

@@ -0,0 +1,50 @@
import { BaseModel } from 'lib/base-model.js';
import { BaseItem } from 'lib/models/base-item.js';
import { Setting } from 'lib/models/setting.js';
import { mime } from 'lib/mime-utils.js';
import { filename } from 'lib/path-utils.js';
import lodash from 'lodash';
class Resource extends BaseItem {
static tableName() {
return 'resources';
}
static modelType() {
return BaseModel.TYPE_RESOURCE;
}
static async serialize(item, type = null, shownKeys = null) {
let fieldNames = this.fieldNames();
fieldNames.push('type_');
lodash.pull(fieldNames, 'sync_time');
return super.serialize(item, 'resource', fieldNames);
}
static fullPath(resource) {
let extension = mime.toFileExtension(resource.mime);
extension = extension ? '.' + extension : '';
return Setting.value('resourceDir') + '/' + resource.id + extension;
}
static pathToId(path) {
return filename(path);
}
static content(resource) {
// TODO: node-only, and should probably be done with streams
const fs = require('fs-extra');
return fs.readFile(this.fullPath(resource));
}
static setContent(resource, content) {
// TODO: node-only, and should probably be done with streams
const fs = require('fs-extra');
let buffer = new Buffer(content);
return fs.writeFile(this.fullPath(resource), buffer);
}
}
export { Resource };

View File

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

View File

@@ -0,0 +1,157 @@
import { BaseModel } from 'lib/base-model.js';
import { Database } from 'lib/database.js';
class Setting extends BaseModel {
static tableName() {
return 'settings';
}
static modelType() {
return BaseModel.TYPE_SETTING;
}
static defaultSetting(key) {
if (!(key in this.defaults_)) throw new Error('Unknown key: ' + key);
let output = Object.assign({}, this.defaults_[key]);
output.key = key;
return output;
}
static keys() {
if (this.keys_) return this.keys_;
this.keys_ = [];
for (let n in this.defaults_) {
if (!this.defaults_.hasOwnProperty(n)) continue;
this.keys_.push(n);
}
return this.keys_;
}
static publicKeys() {
let output = [];
for (let n in this.defaults_) {
if (!this.defaults_.hasOwnProperty(n)) continue;
if (this.defaults_[n].public) output.push(n);
}
return output;
}
static load() {
this.cancelScheduleUpdate();
this.cache_ = [];
return this.modelSelectAll('SELECT * FROM settings').then((rows) => {
this.cache_ = rows;
});
}
static setConstant(key, value) {
this.constants_[key] = value;
}
static setValue(key, value) {
if (!this.cache_) throw new Error('Settings have not been initialized!');
for (let i = 0; i < this.cache_.length; i++) {
if (this.cache_[i].key == key) {
if (this.cache_[i].value === value) return;
this.cache_[i].value = value;
this.scheduleUpdate();
return;
}
}
let s = this.defaultSetting(key);
s.value = value;
this.cache_.push(s);
this.scheduleUpdate();
}
static value(key) {
if (key in this.constants_) return this.constants_[key];
if (!this.cache_) throw new Error('Settings have not been initialized!');
for (let i = 0; i < this.cache_.length; i++) {
if (this.cache_[i].key == key) {
return this.cache_[i].value;
}
}
let s = this.defaultSetting(key);
return s.value;
}
// Currently only supports objects with properties one level deep
static object(key) {
let output = {};
let keys = this.keys();
for (let i = 0; i < keys.length; i++) {
let k = keys[i].split('.');
if (k[0] == key) {
output[k[1]] = this.value(keys[i]);
}
}
return output;
}
// Currently only supports objects with properties one level deep
static setObject(key, object) {
for (let n in object) {
if (!object.hasOwnProperty(n)) continue;
this.setValue(key + '.' + n, object[n]);
}
}
static saveAll() {
if (!this.updateTimeoutId_) return Promise.resolve();
this.logger().info('Saving settings...');
clearTimeout(this.updateTimeoutId_);
this.updateTimeoutId_ = null;
let queries = [];
queries.push('DELETE FROM settings');
for (let i = 0; i < this.cache_.length; i++) {
let s = Object.assign({}, this.cache_[i]);
delete s.public;
queries.push(Database.insertQuery(this.tableName(), s));
}
return BaseModel.db().transactionExecBatch(queries).then(() => {
this.logger().info('Settings have been saved.');
});
}
static scheduleUpdate() {
if (this.updateTimeoutId_) clearTimeout(this.updateTimeoutId_);
this.updateTimeoutId_ = setTimeout(() => {
this.saveAll();
}, 500);
}
static cancelScheduleUpdate() {
if (this.updateTimeoutId_) clearTimeout(this.updateTimeoutId_);
this.updateTimeoutId_ = null;
}
}
Setting.defaults_ = {
'clientId': { value: '', type: 'string', public: false },
'activeFolderId': { value: '', type: 'string', public: false },
'sync.onedrive.auth': { value: '', type: 'string', public: false },
'sync.local.path': { value: '', type: 'string', public: true },
'sync.target': { value: 'onedrive', type: 'string', public: true },
'editor': { value: '', type: 'string', public: true },
};
// Contains constants that are set by the application and
// cannot be modified by the user:
Setting.constants_ = {
'appName': 'joplin',
'appId': 'SET_ME', // Each app should set this identifier
}
export { Setting };

View File

@@ -0,0 +1,68 @@
import { BaseModel } from 'lib/base-model.js';
import { Database } from 'lib/database.js';
import { BaseItem } from 'lib/models/base-item.js';
import { NoteTag } from 'lib/models/note-tag.js';
import { Note } from 'lib/models/note.js';
import { time } from 'lib/time-utils.js';
import lodash from 'lodash';
class Tag extends BaseItem {
static tableName() {
return 'tags';
}
static modelType() {
return BaseModel.TYPE_TAG;
}
static async serialize(item, type = null, shownKeys = null) {
let fieldNames = this.fieldNames();
fieldNames.push('type_');
lodash.pull(fieldNames, 'sync_time');
return super.serialize(item, 'tag', fieldNames);
}
static async tagNoteIds(tagId) {
let rows = await this.db().selectAll('SELECT note_id FROM note_tags WHERE tag_id = ?', [tagId]);
let output = [];
for (let i = 0; i < rows.length; i++) {
output.push(rows[i].note_id);
}
return output;
}
static async notes(tagId) {
let noteIds = await this.tagNoteIds(tagId);
if (!noteIds.length) return [];
return Note.search({
conditions: ['id IN ("' + noteIds.join('","') + '")'],
});
}
static async addNote(tagId, noteId) {
let hasIt = await this.hasNote(tagId, noteId);
if (hasIt) return;
return NoteTag.save({
tag_id: tagId,
note_id: noteId,
});
}
static async removeNote(tagId, noteId) {
let noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ? and note_id = ?', [tagId, noteId]);
for (let i = 0; i < noteTags.length; i++) {
await NoteTag.delete(noteTags[i].id);
}
}
static async hasNote(tagId, noteId) {
let r = await this.db().selectOne('SELECT note_id FROM note_tags WHERE tag_id = ? AND note_id = ? LIMIT 1', [tagId, noteId]);
return !!r;
}
}
export { Tag };

View File

@@ -0,0 +1,271 @@
const fetch = require('node-fetch');
const tcpPortUsed = require('tcp-port-used');
const http = require("http");
const urlParser = require("url");
const FormData = require('form-data');
const enableServerDestroy = require('server-destroy');
import { stringify } from 'query-string';
class OneDriveApi {
constructor(clientId, clientSecret) {
this.clientId_ = clientId;
this.clientSecret_ = clientSecret;
this.auth_ = null;
this.listeners_ = {
'authRefreshed': [],
};
}
dispatch(eventName, param) {
let ls = this.listeners_[eventName];
for (let i = 0; i < ls.length; i++) {
ls[i](param);
}
}
on(eventName, callback) {
this.listeners_[eventName].push(callback);
}
tokenBaseUrl() {
return 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
}
setAuth(auth) {
this.auth_ = auth;
}
token() {
return this.auth_ ? this.auth_.access_token : null;
}
clientId() {
return this.clientId_;
}
clientSecret() {
return this.clientSecret_;
}
possibleOAuthDancePorts() {
return [1917, 9917, 8917];
}
async appDirectory() {
let r = await this.execJson('GET', '/drive/special/approot');
return r.parentReference.path + '/' + r.name;
}
authCodeUrl(redirectUri) {
let query = {
client_id: this.clientId_,
scope: 'files.readwrite offline_access',
response_type: 'code',
redirect_uri: redirectUri,
};
return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' + stringify(query);
}
oneDriveErrorResponseToError(errorResponse) {
if (!errorResponse) return new Error('Undefined error');
if (errorResponse.error) {
let e = errorResponse.error;
let output = new Error(e.message);
if (e.code) output.code = e.code;
if (e.innerError) output.innerError = e.innerError;
return output;
} else {
return new Error(JSON.stringify(errorResponse));
}
}
async exec(method, path, query = null, data = null, options = null) {
method = method.toUpperCase();
if (!options) options = {};
if (!options.headers) options.headers = {};
if (method != 'GET') {
options.method = method;
}
if (method == 'PATCH' || method == 'POST') {
options.headers['Content-Type'] = 'application/json';
if (data) data = JSON.stringify(data);
}
let url = path;
// In general, `path` contains a path relative to the base URL, but in some
// cases the full URL is provided (for example, when it's a URL that was
// retrieved from the API).
if (url.indexOf('https://') !== 0) url = 'https://graph.microsoft.com/v1.0' + path;
if (query) {
url += url.indexOf('?') < 0 ? '?' : '&';
url += stringify(query);
}
if (data) options.body = data;
// Rare error (one Google hit) - maybe repeat the request when it happens?
// { error:
// { code: 'generalException',
// message: 'An error occurred in the data store.',
// innerError:
// { 'request-id': 'b4310552-c18a-45b1-bde1-68e2c2345eef',
// date: '2017-06-29T00:15:50' } } }
for (let i = 0; i < 5; i++) {
options.headers['Authorization'] = 'bearer ' + this.token();
let response = await fetch(url, options);
if (!response.ok) {
let errorResponse = await response.json();
let error = this.oneDriveErrorResponseToError(errorResponse);
if (error.code == 'InvalidAuthenticationToken') {
await this.refreshAccessToken();
continue;
} else {
error.request = method + ' ' + url + ' ' + JSON.stringify(query) + ' ' + JSON.stringify(data) + ' ' + JSON.stringify(options);
throw error;
}
}
return response;
}
throw new Error('Could not execute request after multiple attempts: ' + method + ' ' + url);
}
async execJson(method, path, query, data) {
let response = await this.exec(method, path, query, data);
let output = await response.json();
return output;
}
async execText(method, path, query, data) {
let response = await this.exec(method, path, query, data);
let output = await response.text();
return output;
}
async refreshAccessToken() {
if (!this.auth_) throw new Error('Cannot refresh token: authentication data is missing');
let body = new FormData();
body.append('client_id', this.clientId());
body.append('client_secret', this.clientSecret());
body.append('refresh_token', this.auth_.refresh_token);
body.append('redirect_uri', 'http://localhost:1917');
body.append('grant_type', 'refresh_token');
let options = {
method: 'POST',
body: body,
};
this.auth_ = null;
let response = await fetch(this.tokenBaseUrl(), options);
if (!response.ok) {
let msg = await response.text();
throw new Error(msg);
}
this.auth_ = await response.json();
this.dispatch('authRefreshed', this.auth_);
}
async oauthDance(targetConsole = null) {
if (targetConsole === null) targetConsole = console;
this.auth_ = null;
let ports = this.possibleOAuthDancePorts();
let port = null;
for (let i = 0; i < ports.length; i++) {
let inUse = await tcpPortUsed.check(ports[i]);
if (!inUse) {
port = ports[i];
break;
}
}
if (!port) throw new Error('All potential ports are in use - please report the issue at https://github.com/laurent22/joplin');
let authCodeUrl = this.authCodeUrl('http://localhost:' + port);
return new Promise((resolve, reject) => {
let server = http.createServer();
let errorMessage = null;
server.on('request', (request, response) => {
const query = urlParser.parse(request.url, true).query;
function writeResponse(code, message) {
response.writeHead(code, {"Content-Type": "text/html"});
response.write(message);
response.end();
}
if (!query.code) return writeResponse(400, '"code" query parameter is missing');
let body = new FormData();
body.append('client_id', this.clientId());
body.append('client_secret', this.clientSecret());
body.append('code', query.code ? query.code : '');
body.append('redirect_uri', 'http://localhost:' + port.toString());
body.append('grant_type', 'authorization_code');
let options = {
method: 'POST',
body: body,
};
fetch(this.tokenBaseUrl(), options).then((r) => {
if (!r.ok) {
errorMessage = 'Could not retrieve auth code: ' + r.status + ': ' + r.statusText;
writeResponse(400, errorMessage);
targetConsole.log('');
targetConsole.log(errorMessage);
server.destroy();
return;
}
return r.json().then((json) => {
this.auth_ = json;
writeResponse(200, 'The application has been authorised - you may now close this browser tab.');
targetConsole.log('');
targetConsole.log('The application has been successfully authorised.');
server.destroy();
});
});
});
server.on('close', () => {
if (errorMessage) {
reject(new Error(errorMessage));
} else {
resolve(this.auth_);
}
});
server.listen(port);
enableServerDestroy(server);
targetConsole.log('Please open this URL in your browser to authentify the application:');
targetConsole.log('');
targetConsole.log(authCodeUrl);
});
}
}
export { OneDriveApi };

View File

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

View File

@@ -0,0 +1,30 @@
function dirname(path) {
if (!path) throw new Error('Path is empty');
let s = path.split('/');
s.pop();
return s.join('/');
}
function basename(path) {
if (!path) throw new Error('Path is empty');
let s = path.split('/');
return s[s.length - 1];
}
function filename(path) {
if (!path) throw new Error('Path is empty');
let output = basename(path);
if (output.indexOf('.') < 0) return output;
output = output.split('.');
output.pop();
return output.join('.');
}
function isHidden(path) {
let b = basename(path);
if (!b.length) throw new Error('Path empty or not a valid path: ' + path);
return b[0] === '.';
}
export { basename, dirname, filename, isHidden };

View File

@@ -0,0 +1,37 @@
function promiseChain(chain, defaultValue = null) {
let output = new Promise((resolve, reject) => { resolve(defaultValue); });
for (let i = 0; i < chain.length; i++) {
let f = chain[i];
output = output.then(f);
}
return output;
}
function promiseWhile(callback) {
let isDone = false;
function done() {
isDone = true;
}
let iterationDone = false;
let p = callback(done).then(() => {
iterationDone = true;
});
let iid = setInterval(() => {
if (iterationDone) {
if (isDone) {
clearInterval(iid);
return;
}
iterationDone = false;
callback(done).then(() => {
iterationDone = true;
});
}
}, 100);
}
export { promiseChain, promiseWhile }

View File

@@ -0,0 +1,29 @@
import { BaseModel } from 'lib/base-model.js';
import { BaseItem } from 'lib/models/base-item.js';
import { Note } from 'lib/models/note.js';
import { Folder } from 'lib/models/folder.js';
import { Log } from 'lib/log.js';
import { time } from 'lib/time-utils.js';
class NoteFolderService {
static openNoteList(folderId) {
return Note.previews(folderId).then((notes) => {
this.dispatch({
type: 'NOTES_UPDATE_ALL',
notes: notes,
});
this.dispatch({
type: 'Navigation/NAVIGATE',
routeName: 'Notes',
folderId: folderId,
});
}).catch((error) => {
Log.warn('Cannot load notes', error);
});
}
}
export { NoteFolderService };

View File

@@ -0,0 +1,116 @@
function removeDiacritics(str) {
var defaultDiacriticsRemovalMap = [
{'base':'A', 'letters':/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g},
{'base':'AA','letters':/[\uA732]/g},
{'base':'AE','letters':/[\u00C6\u01FC\u01E2]/g},
{'base':'AO','letters':/[\uA734]/g},
{'base':'AU','letters':/[\uA736]/g},
{'base':'AV','letters':/[\uA738\uA73A]/g},
{'base':'AY','letters':/[\uA73C]/g},
{'base':'B', 'letters':/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g},
{'base':'C', 'letters':/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g},
{'base':'D', 'letters':/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g},
{'base':'DZ','letters':/[\u01F1\u01C4]/g},
{'base':'Dz','letters':/[\u01F2\u01C5]/g},
{'base':'E', 'letters':/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g},
{'base':'F', 'letters':/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g},
{'base':'G', 'letters':/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g},
{'base':'H', 'letters':/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g},
{'base':'I', 'letters':/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g},
{'base':'J', 'letters':/[\u004A\u24BF\uFF2A\u0134\u0248]/g},
{'base':'K', 'letters':/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g},
{'base':'L', 'letters':/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g},
{'base':'LJ','letters':/[\u01C7]/g},
{'base':'Lj','letters':/[\u01C8]/g},
{'base':'M', 'letters':/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g},
{'base':'N', 'letters':/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g},
{'base':'NJ','letters':/[\u01CA]/g},
{'base':'Nj','letters':/[\u01CB]/g},
{'base':'O', 'letters':/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g},
{'base':'OI','letters':/[\u01A2]/g},
{'base':'OO','letters':/[\uA74E]/g},
{'base':'OU','letters':/[\u0222]/g},
{'base':'P', 'letters':/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g},
{'base':'Q', 'letters':/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g},
{'base':'R', 'letters':/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g},
{'base':'S', 'letters':/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g},
{'base':'T', 'letters':/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g},
{'base':'TZ','letters':/[\uA728]/g},
{'base':'U', 'letters':/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g},
{'base':'V', 'letters':/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g},
{'base':'VY','letters':/[\uA760]/g},
{'base':'W', 'letters':/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g},
{'base':'X', 'letters':/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g},
{'base':'Y', 'letters':/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g},
{'base':'Z', 'letters':/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g},
{'base':'a', 'letters':/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g},
{'base':'aa','letters':/[\uA733]/g},
{'base':'ae','letters':/[\u00E6\u01FD\u01E3]/g},
{'base':'ao','letters':/[\uA735]/g},
{'base':'au','letters':/[\uA737]/g},
{'base':'av','letters':/[\uA739\uA73B]/g},
{'base':'ay','letters':/[\uA73D]/g},
{'base':'b', 'letters':/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g},
{'base':'c', 'letters':/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g},
{'base':'d', 'letters':/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g},
{'base':'dz','letters':/[\u01F3\u01C6]/g},
{'base':'e', 'letters':/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g},
{'base':'f', 'letters':/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g},
{'base':'g', 'letters':/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g},
{'base':'h', 'letters':/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g},
{'base':'hv','letters':/[\u0195]/g},
{'base':'i', 'letters':/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g},
{'base':'j', 'letters':/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g},
{'base':'k', 'letters':/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g},
{'base':'l', 'letters':/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g},
{'base':'lj','letters':/[\u01C9]/g},
{'base':'m', 'letters':/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g},
{'base':'n', 'letters':/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g},
{'base':'nj','letters':/[\u01CC]/g},
{'base':'o', 'letters':/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g},
{'base':'oi','letters':/[\u01A3]/g},
{'base':'ou','letters':/[\u0223]/g},
{'base':'oo','letters':/[\uA74F]/g},
{'base':'p','letters':/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g},
{'base':'q','letters':/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g},
{'base':'r','letters':/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g},
{'base':'s','letters':/[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g},
{'base':'t','letters':/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g},
{'base':'tz','letters':/[\uA729]/g},
{'base':'u','letters':/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g},
{'base':'v','letters':/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g},
{'base':'vy','letters':/[\uA761]/g},
{'base':'w','letters':/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g},
{'base':'x','letters':/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g},
{'base':'y','letters':/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g},
{'base':'z','letters':/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g}
];
for(var i=0; i<defaultDiacriticsRemovalMap.length; i++) {
str = str.replace(defaultDiacriticsRemovalMap[i].letters, defaultDiacriticsRemovalMap[i].base);
}
return str;
}
function escapeFilename(s, maxLength = 32) {
let output = removeDiacritics(s);
output = output.replace("\n\r", " ");
output = output.replace("\r\n", " ");
output = output.replace("\r", " ");
output = output.replace("\n", " ");
output = output.replace("\t", " ");
output = output.replace("\0", "");
const unsafe = "/\\:*\"'?<>|"; // In Windows
for (let i = 0; i < unsafe.length; i++) {
output = output.replace(unsafe[i], '_');
}
if (output.toLowerCase() == 'nul') output = 'n_l'; // For Windows...
return output.substr(0, maxLength);
}
export { removeDiacritics, escapeFilename };

View File

@@ -0,0 +1,375 @@
import { BaseItem } from 'lib/models/base-item.js';
import { Folder } from 'lib/models/folder.js';
import { Note } from 'lib/models/note.js';
import { Resource } from 'lib/models/resource.js';
import { BaseModel } from 'lib/base-model.js';
import { sprintf } from 'sprintf-js';
import { time } from 'lib/time-utils.js';
import { Logger } from 'lib/logger.js'
import moment from 'moment';
class Synchronizer {
constructor(db, api) {
this.state_ = 'idle';
this.db_ = db;
this.api_ = api;
this.syncDirName_ = '.sync';
this.resourceDirName_ = '.resource';
this.logger_ = new Logger();
}
state() {
return this.state_;
}
db() {
return this.db_;
}
api() {
return this.api_;
}
setLogger(l) {
this.logger_ = l;
}
logger() {
return this.logger_;
}
logSyncOperation(action, local, remote, reason) {
let line = ['Sync'];
line.push(action);
line.push(reason);
if (local) {
let s = [];
s.push(local.id);
if ('title' in local) s.push('"' + local.title + '"');
line.push('(Local ' + s.join(', ') + ')');
}
if (remote) {
let s = [];
s.push(remote.id);
if ('title' in remote) s.push('"' + remote.title + '"');
line.push('(Remote ' + s.join(', ') + ')');
}
this.logger().debug(line.join(': '));
}
async logSyncSummary(report) {
for (let n in report) {
if (!report.hasOwnProperty(n)) continue;
this.logger().info(n + ': ' + (report[n] ? report[n] : '-'));
}
let folderCount = await Folder.count();
let noteCount = await Note.count();
let resourceCount = await Resource.count();
this.logger().info('Total folders: ' + folderCount);
this.logger().info('Total notes: ' + noteCount);
this.logger().info('Total resources: ' + resourceCount);
}
randomFailure(options, name) {
if (!options.randomFailures) return false;
if (this.randomFailureChoice_ == name) {
options.onMessage('Random failure: ' + name);
return true;
}
return false;
}
async start(options = null) {
if (!options) options = {};
if (!options.onProgress) options.onProgress = function(o) {};
if (this.state() != 'idle') {
this.logger().warn('Synchronization is already in progress. State: ' + this.state());
return;
}
this.randomFailureChoice_ = Math.floor(Math.random() * 5);
// ------------------------------------------------------------------------
// First, find all the items that have been changed since the
// last sync and apply the changes to remote.
// ------------------------------------------------------------------------
let synchronizationId = time.unixMs().toString();
this.logger().info('Starting synchronization... [' + synchronizationId + ']');
this.state_ = 'started';
let report = {
remotesToUpdate: 0,
remotesToDelete: 0,
localsToUdpate: 0,
localsToDelete: 0,
createLocal: 0,
updateLocal: 0,
deleteLocal: 0,
createRemote: 0,
updateRemote: 0,
deleteRemote: 0,
itemConflict: 0,
noteConflict: 0,
};
try {
await this.api().mkdir(this.syncDirName_);
await this.api().mkdir(this.resourceDirName_);
let donePaths = [];
while (true) {
let result = await BaseItem.itemsThatNeedSync();
let locals = result.items;
report.remotesToUpdate += locals.length;
options.onProgress(report);
for (let i = 0; i < locals.length; i++) {
let local = locals[i];
let ItemClass = BaseItem.itemClass(local);
let path = BaseItem.systemPath(local);
// Safety check to avoid infinite loops:
if (donePaths.indexOf(path) > 0) throw new Error(sprintf('Processing a path that has already been done: %s. sync_time was not updated?', path));
let remote = await this.api().stat(path);
let content = await ItemClass.serialize(local);
let action = null;
let updateSyncTimeOnly = true;
let reason = '';
if (!remote) {
if (!local.sync_time) {
action = 'createRemote';
reason = 'remote does not exist, and local is new and has never been synced';
} else {
// Note or item was modified after having been deleted remotely
action = local.type_ == BaseModel.TYPE_NOTE ? 'noteConflict' : 'itemConflict';
reason = 'remote has been deleted, but local has changes';
}
} else {
if (remote.updated_time > local.sync_time) {
// Since, in this loop, we are only dealing with notes that require sync, if the
// remote has been modified after the sync time, it means both notes have been
// modified and so there's a conflict.
action = local.type_ == BaseModel.TYPE_NOTE ? 'noteConflict' : 'itemConflict';
reason = 'both remote and local have changes';
} else {
action = 'updateRemote';
reason = 'local has changes';
}
}
this.logSyncOperation(action, local, remote, reason);
if (local.type_ == BaseModel.TYPE_RESOURCE && (action == 'createRemote' || (action == 'itemConflict' && remote))) {
let remoteContentPath = this.resourceDirName_ + '/' + local.id;
let resourceContent = await Resource.content(local);
await this.api().put(remoteContentPath, resourceContent);
}
if (action == 'createRemote' || action == 'updateRemote') {
// Make the operation atomic by doing the work on a copy of the file
// and then copying it back to the original location.
let tempPath = this.syncDirName_ + '/' + path + '_' + time.unixMs();
await this.api().put(tempPath, content);
await this.api().setTimestamp(tempPath, local.updated_time);
await this.api().move(tempPath, path);
if (this.randomFailure(options, 0)) return;
await ItemClass.save({ id: local.id, sync_time: time.unixMs(), type_: local.type_ }, { autoTimestamp: false });
} else if (action == 'itemConflict') {
if (remote) {
let remoteContent = await this.api().get(path);
local = await BaseItem.unserialize(remoteContent);
local.sync_time = time.unixMs();
await ItemClass.save(local, { autoTimestamp: false });
} else {
await ItemClass.delete(local.id);
}
} else if (action == 'noteConflict') {
// - Create a duplicate of local note into Conflicts folder (to preserve the user's changes)
// - Overwrite local note with remote note
let conflictedNote = Object.assign({}, local);
delete conflictedNote.id;
conflictedNote.is_conflict = 1;
await Note.save(conflictedNote, { autoTimestamp: false });
if (this.randomFailure(options, 1)) return;
if (remote) {
let remoteContent = await this.api().get(path);
local = await BaseItem.unserialize(remoteContent);
local.sync_time = time.unixMs();
await ItemClass.save(local, { autoTimestamp: false });
} else {
await ItemClass.delete(local.id);
}
}
report[action]++;
donePaths.push(path);
options.onProgress(report);
}
if (!result.hasMore) break;
}
// ------------------------------------------------------------------------
// Delete the remote items that have been deleted locally.
// ------------------------------------------------------------------------
let deletedItems = await BaseItem.deletedItems();
report.remotesToDelete = deletedItems.length;
options.onProgress(report);
for (let i = 0; i < deletedItems.length; i++) {
let item = deletedItems[i];
let path = BaseItem.systemPath(item.item_id)
this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted');
await this.api().delete(path);
if (this.randomFailure(options, 2)) return;
await BaseItem.remoteDeletedItem(item.item_id);
report['deleteRemote']++;
options.onProgress(report);
}
// ------------------------------------------------------------------------
// Loop through all the remote items, find those that
// have been updated, and apply the changes to local.
// ------------------------------------------------------------------------
// At this point all the local items that have changed have been pushed to remote
// or handled as conflicts, so no conflict is possible after this.
let remoteIds = [];
let context = null;
while (true) {
let listResult = await this.api().list('', { context: context });
let remotes = listResult.items;
for (let i = 0; i < remotes.length; i++) {
let remote = remotes[i];
let path = remote.path;
remoteIds.push(BaseItem.pathToId(path));
if (donePaths.indexOf(path) > 0) continue;
let action = null;
let reason = '';
let local = await BaseItem.loadItemByPath(path);
if (!local) {
action = 'createLocal';
reason = 'remote exists but local does not';
} else {
if (remote.updated_time > local.updated_time) {
action = 'updateLocal';
reason = sprintf('remote is more recent than local');
}
}
if (!action) continue;
report.localsToUdpate++;
options.onProgress(report);
if (action == 'createLocal' || action == 'updateLocal') {
let content = await this.api().get(path);
if (content === null) {
this.logger().warn('Remote has been deleted between now and the list() call? In that case it will be handled during the next sync: ' + path);
continue;
}
content = await BaseItem.unserialize(content);
let ItemClass = BaseItem.itemClass(content);
let newContent = Object.assign({}, content);
newContent.sync_time = time.unixMs();
let options = {
autoTimestamp: false,
applyMetadataChanges: true,
};
if (action == 'createLocal') options.isNew = true;
if (newContent.type_ == BaseModel.TYPE_RESOURCE && action == 'createLocal') {
let localResourceContentPath = Resource.fullPath(newContent);
let remoteResourceContentPath = this.resourceDirName_ + '/' + newContent.id;
let remoteResourceContent = await this.api().get(remoteResourceContentPath, { encoding: 'binary' });
await Resource.setContent(newContent, remoteResourceContent);
}
await ItemClass.save(newContent, options);
this.logSyncOperation(action, local, content, reason);
} else {
this.logSyncOperation(action, local, remote, reason);
}
report[action]++;
options.onProgress(report);
}
if (!listResult.hasMore) break;
context = listResult.context;
}
// ------------------------------------------------------------------------
// Search, among the local IDs, those that don't exist remotely, which
// means the item has been deleted.
// ------------------------------------------------------------------------
if (this.randomFailure(options, 4)) return;
let items = await BaseItem.syncedItems();
for (let i = 0; i < items.length; i++) {
let item = items[i];
if (remoteIds.indexOf(item.id) < 0) {
report.localsToDelete++;
options.onProgress(report);
this.logSyncOperation('deleteLocal', { id: item.id }, null, 'remote has been deleted');
let ItemClass = BaseItem.itemClass(item);
await ItemClass.delete(item.id, { trackDeleted: false });
report['deleteLocal']++;
options.onProgress(report);
}
}
} catch (error) {
this.logger().error(error);
throw error;
}
options.onProgress(report);
this.logger().info('Synchronization complete [' + synchronizationId + ']:');
await this.logSyncSummary(report);
this.state_ = 'idle';
}
}
export { Synchronizer };

View File

@@ -0,0 +1,35 @@
import moment from 'moment';
let time = {
unix() {
return Math.floor((new Date()).getTime() / 1000);
},
unixMs() {
return (new Date()).getTime();
},
unixMsToS(ms) {
return Math.floor(ms / 1000);
},
unixMsToIso(ms) {
return moment.unix(ms / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
},
msleep(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
},
sleep(seconds) {
return this.msleep(seconds * 1000);
},
}
export { time };

View File

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