diff --git a/CliClient/tests/base-model.js b/CliClient/tests/base-model.js
deleted file mode 100644
index a4b690add9..0000000000
--- a/CliClient/tests/base-model.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const { time } = require('lib/time-utils.js');
-const { setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient } = require('test-utils.js');
-const { Folder } = require('lib/models/folder.js');
-const { Note } = require('lib/models/note.js');
-const { Setting } = require('lib/models/setting.js');
-const { BaseItem } = require('lib/models/base-item.js');
-const { BaseModel } = require('lib/base-model.js');
-
-process.on('unhandledRejection', (reason, p) => {
- console.error('Unhandled promise rejection at: Promise', p, 'reason:', reason);
-});
-
-describe('BaseItem', function() {
-
- beforeEach( async (done) => {
- await setupDatabaseAndSynchronizer(1);
- switchClient(1);
- done();
- });
-
- it('should create a deleted_items record', async (done) => {
- let folder1 = await Folder.save({ title: 'folder1' });
- let folder2 = await Folder.save({ title: 'folder2' });
-
- await Folder.delete(folder1.id);
-
- let items = await BaseItem.deletedItems();
-
- expect(items.length).toBe(1);
- expect(items[0].item_id).toBe(folder1.id);
- expect(items[0].item_type).toBe(folder1.type_);
-
- let folders = await Folder.all();
-
- expect(folders.length).toBe(1);
-
- done();
- });
-
-});
\ No newline at end of file
diff --git a/CliClient/tests/test-utils.js b/CliClient/tests/test-utils.js
index d9ca690920..0cfcd0f0b5 100644
--- a/CliClient/tests/test-utils.js
+++ b/CliClient/tests/test-utils.js
@@ -19,6 +19,7 @@ const { time } = require('lib/time-utils.js');
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
const SyncTargetMemory = require('lib/SyncTargetMemory.js');
const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
+const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
let databases_ = [];
let synchronizers_ = [];
@@ -34,6 +35,7 @@ fs.mkdirpSync(logDir, 0o755);
SyncTargetRegistry.addClass(SyncTargetMemory);
SyncTargetRegistry.addClass(SyncTargetFilesystem);
+SyncTargetRegistry.addClass(SyncTargetOneDrive);
const syncTargetId_ = SyncTargetRegistry.nameToId('memory');
const syncDir = __dirname + '/../tests/sync';
diff --git a/ElectronClient/app/ElectronAppWrapper.js b/ElectronClient/app/ElectronAppWrapper.js
index 20f8393a9f..722f86d36f 100644
--- a/ElectronClient/app/ElectronAppWrapper.js
+++ b/ElectronClient/app/ElectronAppWrapper.js
@@ -51,7 +51,8 @@ class ElectronAppWrapper {
slashes: true
}))
- //if (this.env_ === 'dev') this.win_.webContents.openDevTools();
+ // Uncomment this to view errors if the application does not start
+ // if (this.env_ === 'dev') this.win_.webContents.openDevTools();
this.win_.on('close', (event) => {
if (this.willQuitApp_ || process.platform !== 'darwin') {
diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js
index d2bbc15232..8c547d824b 100644
--- a/ElectronClient/app/app.js
+++ b/ElectronClient/app/app.js
@@ -15,6 +15,8 @@ const { JoplinDatabase } = require('lib/joplin-database.js');
const { DatabaseDriverNode } = require('lib/database-driver-node.js');
const { ElectronAppWrapper } = require('./ElectronAppWrapper');
const { defaultState } = require('lib/reducer.js');
+const AlarmService = require('lib/services/AlarmService.js');
+const AlarmServiceDriverNode = require('lib/services/AlarmServiceDriverNode');
const { bridge } = require('electron').remote.require('./bridge');
const Menu = bridge().Menu;
@@ -135,6 +137,10 @@ class Application extends BaseApplication {
if (!await reg.syncTarget().syncStarted()) reg.scheduleSync();
}
+ if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) {
+ await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
+ }
+
const result = await super.generalMiddleware(store, next, action);
const newState = store.getState();
@@ -305,6 +311,9 @@ class Application extends BaseApplication {
async start(argv) {
argv = await super.start(argv);
+ AlarmService.setDriver(new AlarmServiceDriverNode());
+ AlarmService.setLogger(reg.logger());
+
if (Setting.value('openDevTools')) {
bridge().window().webContents.openDevTools();
}
diff --git a/ElectronClient/app/gui/MainScreen.jsx b/ElectronClient/app/gui/MainScreen.jsx
index 47564544d6..58d1552d93 100644
--- a/ElectronClient/app/gui/MainScreen.jsx
+++ b/ElectronClient/app/gui/MainScreen.jsx
@@ -175,6 +175,38 @@ class MainScreenComponent extends React.Component {
id: searchId,
});
}
+ this.setState({ promptOptions: null });
+ }
+ },
+ });
+ } else if (command.name === 'editAlarm') {
+ const note = await Note.load(command.noteId);
+
+ this.setState({
+ promptOptions: {
+ label: _('Set or clear alarm:'),
+ inputType: 'datetime',
+ buttons: ['ok', 'cancel', 'clear'],
+ value: note.todo_due ? new Date(note.todo_due) : null,
+ onClose: async (answer, buttonType) => {
+ let newNote = null;
+
+ if (buttonType === 'clear') {
+ newNote = {
+ id: note.id,
+ todo_due: 0,
+ };
+ } else if (answer !== null) {
+ newNote = {
+ id: note.id,
+ todo_due: answer.getTime(),
+ };
+ }
+
+ if (newNote) {
+ await Note.save(newNote);
+ }
+
this.setState({ promptOptions: null });
}
},
@@ -274,10 +306,12 @@ class MainScreenComponent extends React.Component {
value={promptOptions && promptOptions.value ? promptOptions.value : ''}
theme={this.props.theme}
style={promptStyle}
- onClose={(answer) => promptOptions.onClose(answer)}
+ onClose={(answer, buttonType) => promptOptions.onClose(answer, buttonType)}
label={promptOptions ? promptOptions.label : ''}
description={promptOptions ? promptOptions.description : null}
- visible={!!this.state.promptOptions} />
+ visible={!!this.state.promptOptions}
+ buttons={promptOptions && ('buttons' in promptOptions) ? promptOptions.buttons : null}
+ inputType={promptOptions && ('inputType' in promptOptions) ? promptOptions.inputType : null} />
diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx
index 15ac652cdd..51deb545d2 100644
--- a/ElectronClient/app/gui/NoteText.jsx
+++ b/ElectronClient/app/gui/NoteText.jsx
@@ -336,6 +336,14 @@ class NoteTextComponent extends React.Component {
}
}}));
+ menu.append(new MenuItem({label: _('Set or clear alarm'), click: async () => {
+ this.props.dispatch({
+ type: 'WINDOW_COMMAND',
+ name: 'editAlarm',
+ noteId: noteId,
+ });
+ }}));
+
menu.popup(bridge().window());
}
diff --git a/ElectronClient/app/gui/PromptDialog.jsx b/ElectronClient/app/gui/PromptDialog.jsx
index 846bf71809..8e81bb6002 100644
--- a/ElectronClient/app/gui/PromptDialog.jsx
+++ b/ElectronClient/app/gui/PromptDialog.jsx
@@ -2,6 +2,7 @@ const React = require('react');
const { connect } = require('react-redux');
const { _ } = require('lib/locale.js');
const { themeStyle } = require('../theme.js');
+const Datetime = require('react-datetime');
class PromptDialog extends React.Component {
@@ -32,6 +33,7 @@ class PromptDialog extends React.Component {
render() {
const style = this.props.style;
const theme = themeStyle(this.props.theme);
+ const buttonTypes = this.props.buttons ? this.props.buttons : ['ok', 'cancel'];
const modalLayerStyle = {
zIndex: 9999,
@@ -76,8 +78,8 @@ class PromptDialog extends React.Component {
marginTop: 10,
});
- const onClose = (accept) => {
- if (this.props.onClose) this.props.onClose(accept ? this.state.answer : null);
+ const onClose = (accept, buttonType) => {
+ if (this.props.onClose) this.props.onClose(accept ? this.state.answer : null, buttonType);
this.setState({ visible: false, answer: '' });
}
@@ -85,6 +87,10 @@ class PromptDialog extends React.Component {
this.setState({ answer: event.target.value });
}
+ const onDateTimeChange = (momentObject) => {
+ this.setState({ answer: momentObject.toDate() });
+ }
+
const onKeyDown = (event) => {
if (event.key === 'Enter') {
onClose(true);
@@ -95,23 +101,41 @@ class PromptDialog extends React.Component {
const descComp = this.props.description ?
{this.props.description}
: null;
+ let inputComp = null;
+
+ if (this.props.inputType === 'datetime') {
+ inputComp = onDateTimeChange(momentObject)}
+ />
+ } else {
+ inputComp = this.answerInput_ = input}
+ value={this.state.answer}
+ type="text"
+ onChange={(event) => onChange(event)}
+ onKeyDown={(event) => onKeyDown(event)}
+ />
+ }
+
+ const buttonComps = [];
+ if (buttonTypes.indexOf('ok') >= 0) buttonComps.push();
+ if (buttonTypes.indexOf('cancel') >= 0) buttonComps.push();
+ if (buttonTypes.indexOf('clear') >= 0) buttonComps.push();
+
return (
diff --git a/ElectronClient/app/index.html b/ElectronClient/app/index.html
index ea72068d51..6bb72a1984 100644
--- a/ElectronClient/app/index.html
+++ b/ElectronClient/app/index.html
@@ -5,6 +5,7 @@
Joplin
+
diff --git a/ElectronClient/app/package-lock.json b/ElectronClient/app/package-lock.json
index c055dcca8f..6ad6af0877 100644
--- a/ElectronClient/app/package-lock.json
+++ b/ElectronClient/app/package-lock.json
@@ -1080,6 +1080,16 @@
"capture-stack-trace": "1.0.0"
}
},
+ "create-react-class": {
+ "version": "15.6.2",
+ "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.2.tgz",
+ "integrity": "sha1-zx7RXxKq1/FO9fLf4F5sQvke8Co=",
+ "requires": {
+ "fbjs": "0.8.16",
+ "loose-envify": "1.3.1",
+ "object-assign": "4.1.1"
+ }
+ },
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@@ -3561,6 +3571,24 @@
"prop-types": "15.6.0"
}
},
+ "react-datetime": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/react-datetime/-/react-datetime-2.11.0.tgz",
+ "integrity": "sha512-LfToQVZrFjH9b+R4PWemo/jJcWawHWHL+eF1HFWGZ9zGXXgy7d/X1+PzJUBYiZfteFM+VSdaHvYyqReqP/nGaQ==",
+ "requires": {
+ "create-react-class": "15.6.2",
+ "object-assign": "3.0.0",
+ "prop-types": "15.6.0",
+ "react-onclickoutside": "6.7.0"
+ },
+ "dependencies": {
+ "object-assign": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz",
+ "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I="
+ }
+ }
+ },
"react-dom": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.1.1.tgz",
@@ -3572,6 +3600,11 @@
"prop-types": "15.6.0"
}
},
+ "react-onclickoutside": {
+ "version": "6.7.0",
+ "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.7.0.tgz",
+ "integrity": "sha512-IBivBP7xayM7SbbVlAnKgHgoWdfCVqnNBNgQRY5x9iFQm55tFdolR02hX1fCJJtTEKnbaL1stB72/TZc6+p2+Q=="
+ },
"react-redux": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.6.tgz",
diff --git a/ElectronClient/app/package.json b/ElectronClient/app/package.json
index e6b0c8331b..9c5f426048 100644
--- a/ElectronClient/app/package.json
+++ b/ElectronClient/app/package.json
@@ -69,6 +69,7 @@
"query-string": "^5.0.1",
"react": "^16.0.0",
"react-ace": "^5.5.0",
+ "react-datetime": "^2.11.0",
"react-dom": "^16.0.0",
"react-redux": "^5.0.6",
"redux": "^3.7.2",
diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js
index e8c8bd094e..7d20f9e19c 100644
--- a/ReactNativeClient/lib/BaseApplication.js
+++ b/ReactNativeClient/lib/BaseApplication.js
@@ -354,7 +354,7 @@ class BaseApplication {
this.logger_.info('Profile directory: ' + profileDir);
this.database_ = new JoplinDatabase(new DatabaseDriverNode());
- //this.database_.setLogExcludedQueryTypes(['SELECT']);
+ this.database_.setLogExcludedQueryTypes(['SELECT']);
this.database_.setLogger(this.dbLogger_);
await this.database_.open({ name: profileDir + '/database.sqlite' });
diff --git a/ReactNativeClient/lib/base-model.js b/ReactNativeClient/lib/base-model.js
index 7230239240..5f4e1e3ec1 100644
--- a/ReactNativeClient/lib/base-model.js
+++ b/ReactNativeClient/lib/base-model.js
@@ -286,7 +286,7 @@ class BaseModel {
return this.db().transactionExecBatch(queries).then(() => {
o = Object.assign({}, o);
- o.id = modelId;
+ if (modelId) o.id = modelId;
if ('updated_time' in saveQuery.modObject) o.updated_time = saveQuery.modObject.updated_time;
if ('created_time' in saveQuery.modObject) o.created_time = saveQuery.modObject.created_time;
if ('user_updated_time' in saveQuery.modObject) o.user_updated_time = saveQuery.modObject.user_updated_time;
@@ -360,6 +360,7 @@ BaseModel.TYPE_RESOURCE = 4;
BaseModel.TYPE_TAG = 5;
BaseModel.TYPE_NOTE_TAG = 6;
BaseModel.TYPE_SEARCH = 7;
+BaseModel.TYPE_ALARM = 8;
BaseModel.db_ = null;
BaseModel.dispatch = function(o) {};
diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js
index 6b7f84585a..6a6407012a 100644
--- a/ReactNativeClient/lib/components/screens/note.js
+++ b/ReactNativeClient/lib/components/screens/note.js
@@ -30,6 +30,7 @@ const { DocumentPicker, DocumentPickerUtil } = require('react-native-document-pi
const ImageResizer = require('react-native-image-resizer').default;
const shared = require('lib/components/shared/note-screen-shared.js');
const ImagePicker = require('react-native-image-picker');
+const AlarmService = require('lib/services/AlarmService.js');
const { SelectDateTimeDialog } = require('lib/components/select-date-time-dialog.js');
class NoteScreenComponent extends BaseScreenComponent {
@@ -347,12 +348,9 @@ class NoteScreenComponent extends BaseScreenComponent {
let newNote = Object.assign({}, this.state.note);
newNote.todo_due = date ? date.getTime() : 0;
- this.setState({
- alarmDialogShown: false,
- note: newNote,
- });
- //await this.saveOneProperty('todo_due', date ? date.getTime() : 0);
- //this.forceUpdate();
+ await this.saveOneProperty('todo_due', date ? date.getTime() : 0);
+
+ this.setState({ alarmDialogShown: false });
}
onAlarmDialogReject() {
@@ -389,14 +387,12 @@ class NoteScreenComponent extends BaseScreenComponent {
output.push({ title: _('Attach image'), onPress: () => { this.attachImage_onPress(); } });
output.push({ title: _('Attach any other file'), onPress: () => { this.attachFile_onPress(); } });
}
+
+ if (isTodo) {
+ output.push({ title: _('Set or clear alarm'), onPress: () => { this.setState({ alarmDialogShown: true }) }});;
+ }
+
output.push({ title: _('Delete note'), onPress: () => { this.deleteNote_onPress(); } });
- output.push({ title: _('Alarm'), onPress: () => { this.setState({ alarmDialogShown: true }) }});;
-
- // if (isTodo) {
- // let text = note.todo_due ? _('Edit/Clear alarm') : _('Set an alarm');
- // output.push({ title: text, onPress: () => { this.setAlarm_onPress(); } });
- // }
-
output.push({ title: isTodo ? _('Convert to regular note') : _('Convert to todo'), onPress: () => { this.toggleIsTodo_onPress(); } });
if (this.props.showAdvancedOptions) output.push({ title: this.state.showNoteMetadata ? _('Hide metadata') : _('Show metadata'), onPress: () => { this.showMetadata_onPress(); } });
output.push({ title: _('View location on map'), onPress: () => { this.showOnMap_onPress(); } });
diff --git a/ReactNativeClient/lib/components/shared/note-screen-shared.js b/ReactNativeClient/lib/components/shared/note-screen-shared.js
index 2ed552d27d..bd9a3c23f5 100644
--- a/ReactNativeClient/lib/components/shared/note-screen-shared.js
+++ b/ReactNativeClient/lib/components/shared/note-screen-shared.js
@@ -33,12 +33,22 @@ shared.saveNoteButton_press = async function(comp) {
if (isNew && !note.title) {
note.title = Note.defaultTitle(note);
}
-
- note = await Note.save(note);
+
+ // Save only the properties that have changed
+ const diff = BaseModel.diffObjects(comp.state.lastSavedNote, note);
+ diff.type_ = note.type_;
+ diff.id = note.id;
+
+ const savedNote = await Note.save(diff);
+
+ // Re-assign any property that might have changed during saving (updated_time, etc.)
+ note = Object.assign(note, savedNote);
+
comp.setState({
lastSavedNote: Object.assign({}, note),
note: note,
});
+
if (isNew) Note.updateGeolocation(note.id);
comp.refreshNoteMetadata();
}
diff --git a/ReactNativeClient/lib/database-driver-node.js b/ReactNativeClient/lib/database-driver-node.js
index 8027c781d6..16976a7349 100644
--- a/ReactNativeClient/lib/database-driver-node.js
+++ b/ReactNativeClient/lib/database-driver-node.js
@@ -67,6 +67,10 @@ class DatabaseDriverNode {
});
}
+ lastInsertId() {
+ throw new Error('NOT IMPLEMENTED');
+ }
+
}
module.exports = { DatabaseDriverNode };
\ No newline at end of file
diff --git a/ReactNativeClient/lib/database-driver-react-native.js b/ReactNativeClient/lib/database-driver-react-native.js
index d4669f020f..fcff2a6be6 100644
--- a/ReactNativeClient/lib/database-driver-react-native.js
+++ b/ReactNativeClient/lib/database-driver-react-native.js
@@ -2,6 +2,10 @@ const SQLite = require('react-native-sqlite-storage');
class DatabaseDriverReactNative {
+ constructor() {
+ this.lastInsertId_ = null;
+ }
+
open(options) {
//SQLite.DEBUG(true);
return new Promise((resolve, reject) => {
@@ -45,6 +49,7 @@ class DatabaseDriverReactNative {
exec(sql, params = null) {
return new Promise((resolve, reject) => {
this.db_.executeSql(sql, params, (r) => {
+ if ('insertId' in r) this.lastInsertId_ = r.insertId;
resolve(r);
}, (error) => {
reject(error);
@@ -52,6 +57,10 @@ class DatabaseDriverReactNative {
});
}
+ lastInsertId() {
+ return this.lastInsertId_;
+ }
+
}
module.exports = { DatabaseDriverReactNative };
\ No newline at end of file
diff --git a/ReactNativeClient/lib/database.js b/ReactNativeClient/lib/database.js
index 202725e5be..20331a1fa2 100644
--- a/ReactNativeClient/lib/database.js
+++ b/ReactNativeClient/lib/database.js
@@ -78,8 +78,10 @@ class Database {
} 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());
+ // NOTE: don't put logger statements here because it might log to the database, which
+ // could result in an error being thrown again.
+ // 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;
diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js
index 4127da1782..95af9a577d 100644
--- a/ReactNativeClient/lib/joplin-database.js
+++ b/ReactNativeClient/lib/joplin-database.js
@@ -160,6 +160,7 @@ class JoplinDatabase extends Database {
let tableName = tableRows[i].name;
if (tableName == 'android_metadata') continue;
if (tableName == 'table_fields') continue;
+ if (tableName == 'sqlite_sequence') continue;
chain.push(() => {
return this.selectAll('PRAGMA table_info("' + tableName + '")').then((pragmas) => {
for (let i = 0; i < pragmas.length; i++) {
@@ -201,7 +202,7 @@ class JoplinDatabase extends Database {
// default value and thus might cause problems. In that case, the default value
// must be set in the synchronizer too.
- const existingDatabaseVersions = [0, 1, 2, 3, 4, 5];
+ const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
if (currentVersionIndex == existingDatabaseVersions.length - 1) return false;
@@ -253,6 +254,11 @@ class JoplinDatabase extends Database {
}
}
+ if (targetVersion == 6) {
+ queries.push('CREATE TABLE alarms (id INTEGER PRIMARY KEY AUTOINCREMENT, note_id TEXT NOT NULL, trigger_time INT NOT NULL)');
+ queries.push('CREATE INDEX alarm_note_id ON alarms (note_id)');
+ }
+
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
await this.transactionExecBatch(queries);
diff --git a/ReactNativeClient/lib/models/Alarm.js b/ReactNativeClient/lib/models/Alarm.js
new file mode 100644
index 0000000000..558b1ec852
--- /dev/null
+++ b/ReactNativeClient/lib/models/Alarm.js
@@ -0,0 +1,31 @@
+const { BaseModel } = require('lib/base-model.js');
+
+class Alarm extends BaseModel {
+
+ static tableName() {
+ return 'alarms';
+ }
+
+ static modelType() {
+ return BaseModel.TYPE_ALARM;
+ }
+
+ static byNoteId(noteId) {
+ return this.modelSelectOne('SELECT * FROM alarms WHERE note_id = ?', [noteId]);
+ }
+
+ static async garbageCollect() {
+ // Delete alarms that have already been triggered
+ await this.db().exec('DELETE FROM alarms WHERE trigger_time <= ?', [Date.now()]);
+
+ // Delete alarms that correspond to non-existent notes
+ // https://stackoverflow.com/a/4967229/561309
+ await this.db().exec('DELETE FROM alarms WHERE id IN (SELECT alarms.id FROM alarms LEFT JOIN notes ON alarms.note_id = notes.id WHERE notes.id IS NULL)');
+
+ // TODO: Check for duplicate alarms for a note
+ // const rows = await this.db().exec('SELECT count(*) as note_count, note_id from alarms group by note_id having note_count >= 2');
+ }
+
+}
+
+module.exports = Alarm;
\ No newline at end of file
diff --git a/ReactNativeClient/lib/models/note.js b/ReactNativeClient/lib/models/note.js
index 14c72452a3..ccaa896779 100644
--- a/ReactNativeClient/lib/models/note.js
+++ b/ReactNativeClient/lib/models/note.js
@@ -389,6 +389,13 @@ class Note extends BaseItem {
type: 'NOTE_UPDATE_ONE',
note: note,
});
+
+ if ('todo_due' in o || 'todo_completed' in o || 'is_todo' in o || 'is_conflict' in o) {
+ this.dispatch({
+ type: 'EVENT_NOTE_ALARM_FIELD_CHANGE',
+ id: note.id,
+ });
+ }
return note;
});
@@ -414,6 +421,14 @@ class Note extends BaseItem {
return result;
}
+ static dueNotes() {
+ return this.modelSelectAll('SELECT id, title, body, todo_due FROM notes WHERE is_conflict = 0 AND is_todo = 1 AND todo_completed = 0 AND todo_due > ?', [time.unixMs()]);
+ }
+
+ static needAlarm(note) {
+ return note.is_todo && !note.todo_completed && note.todo_due >= time.unixMs() && !note.is_conflict;
+ }
+
// Tells whether the conflict between the local and remote note can be ignored.
static mustHandleConflict(localNote, remoteNote) {
// That shouldn't happen so throw an exception
diff --git a/ReactNativeClient/lib/services/AlarmService.js b/ReactNativeClient/lib/services/AlarmService.js
new file mode 100644
index 0000000000..778aee11a5
--- /dev/null
+++ b/ReactNativeClient/lib/services/AlarmService.js
@@ -0,0 +1,72 @@
+const { Note } = require('lib/models/note.js');
+const Alarm = require('lib/models/Alarm.js');
+
+class AlarmService {
+
+ static setDriver(v) {
+ this.driver_ = v;
+ }
+
+ static driver() {
+ if (!this.driver_) throw new Error('AlarmService driver not set!');
+ return this.driver_;
+ }
+
+ static setLogger(v) {
+ this.logger_ = v;
+ }
+
+ static logger() {
+ return this.logger_;
+ }
+
+ static async updateNoteNotification(noteId, isDeleted = false) {
+ const note = await Note.load(noteId);
+ if (!note && !isDeleted) return;
+
+ let alarm = await Alarm.byNoteId(note.id);
+ let clearAlarm = false;
+
+ if (isDeleted ||
+ !Note.needAlarm(note) ||
+ (alarm && alarm.trigger_time !== note.todo_due))
+ {
+ clearAlarm = !!alarm;
+ }
+
+ if (!clearAlarm && alarm) return; // Alarm already exists and set at the right time
+
+ if (clearAlarm) {
+ this.logger().info('Clearing notification for note ' + noteId);
+ await this.driver().clearNotification(alarm.id);
+ await Alarm.delete(alarm.id);
+ }
+
+ if (isDeleted || !Note.needAlarm(note)) return;
+
+ await Alarm.save({
+ note_id: note.id,
+ trigger_time: note.todo_due,
+ });
+
+ // Reload alarm to get its ID
+ alarm = await Alarm.byNoteId(note.id);
+
+ const notification = {
+ id: alarm.id,
+ date: new Date(note.todo_due),
+ title: note.title,
+ };
+
+ if (note.body) notification.body = note.body;
+
+ this.logger().info('Scheduling notification for note ' + note.id, notification);
+ await this.driver().scheduleNotification(notification);
+ }
+
+ // TODO: inner notifications (when app is active)
+ // TODO: locale-dependent format
+
+}
+
+module.exports = AlarmService;
\ No newline at end of file
diff --git a/ReactNativeClient/lib/services/AlarmServiceDriver.android.js b/ReactNativeClient/lib/services/AlarmServiceDriver.android.js
new file mode 100644
index 0000000000..056dd3f7d6
--- /dev/null
+++ b/ReactNativeClient/lib/services/AlarmServiceDriver.android.js
@@ -0,0 +1,23 @@
+const PushNotification = require('react-native-push-notification');
+
+class AlarmServiceDriver {
+
+ async clearNotification(id) {
+ PushNotification.cancelLocalNotifications({ id: id });
+ }
+
+ async scheduleNotification(notification) {
+ const androidNotification = {
+ id: notification.id,
+ message: notification.title.substr(0, 100), // No idea what the limits are for title and body but set something reasonable anyway
+ date: notification.date,
+ };
+
+ if ('body' in notification) androidNotification.body = notification.body.substr(0, 512);
+
+ PushNotification.localNotificationSchedule(androidNotification);
+ }
+
+}
+
+module.exports = AlarmServiceDriver;
\ No newline at end of file
diff --git a/ReactNativeClient/lib/services/AlarmServiceDriverNode.js b/ReactNativeClient/lib/services/AlarmServiceDriverNode.js
new file mode 100644
index 0000000000..9173558e70
--- /dev/null
+++ b/ReactNativeClient/lib/services/AlarmServiceDriverNode.js
@@ -0,0 +1,23 @@
+class AlarmServiceDriverNode {
+
+ async clearNotification(id) {
+ console.info('AlarmServiceDriverNode::clearNotification', id);
+ }
+
+ async scheduleNotification(notification) {
+ console.info('AlarmServiceDriverNode::scheduleNotification', notification);
+
+ // const androidNotification = {
+ // id: notification.id,
+ // message: notification.title.substr(0, 100), // No idea what the limits are for title and body but set something reasonable anyway
+ // date: notification.date,
+ // };
+
+ // if ('body' in notification) androidNotification.body = notification.body.substr(0, 512);
+
+ // PushNotification.localNotificationSchedule(androidNotification);
+ }
+
+}
+
+module.exports = AlarmServiceDriverNode;
\ No newline at end of file
diff --git a/ReactNativeClient/lib/synchronizer.js b/ReactNativeClient/lib/synchronizer.js
index dbc9253d28..2d2384630a 100644
--- a/ReactNativeClient/lib/synchronizer.js
+++ b/ReactNativeClient/lib/synchronizer.js
@@ -419,8 +419,20 @@ class Synchronizer {
}
content = await BaseItem.unserialize(content);
let ItemClass = BaseItem.itemClass(content);
+ content = ItemClass.filter(content);
+
+ let newContent = null;
+
+ if (action === 'createLocal') {
+ newContent = Object.assign({}, content);
+ } else if (action === 'updateLocal') {
+ newContent = BaseModel.diffObjects(local, content);
+ newContent.type_ = content.type_;
+ newContent.id = content.id;
+ } else {
+ throw new Error('Unknown action: ' + action);
+ }
- let newContent = Object.assign({}, content);
let options = {
autoTimestamp: false,
nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, newContent, time.unixMs()),
diff --git a/ReactNativeClient/lib/time-utils.js b/ReactNativeClient/lib/time-utils.js
index 31cf058660..b5d16f751e 100644
--- a/ReactNativeClient/lib/time-utils.js
+++ b/ReactNativeClient/lib/time-utils.js
@@ -3,11 +3,11 @@ const moment = require('moment');
let time = {
unix() {
- return Math.floor((new Date()).getTime() / 1000);
+ return Math.floor(Date.now() / 1000);
},
unixMs() {
- return (new Date()).getTime();
+ return Date.now();
},
unixMsToObject(ms) {
diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js
index 72a153ade9..7cb4b4b7ab 100644
--- a/ReactNativeClient/root.js
+++ b/ReactNativeClient/root.js
@@ -1,7 +1,9 @@
const React = require('react'); const Component = React.Component;
-const { Keyboard, NativeModules, PushNotificationIOS } = require('react-native');
+const { Keyboard, NativeModules } = require('react-native');
const { connect, Provider } = require('react-redux');
const { BackButtonService } = require('lib/services/back-button.js');
+const AlarmService = require('lib/services/AlarmService.js');
+const AlarmServiceDriver = require('lib/services/AlarmServiceDriver');
const { createStore, applyMiddleware } = require('redux');
const { shimInit } = require('lib/shim-init-react.js');
const { Log } = require('lib/log.js');
@@ -37,7 +39,6 @@ const { _, setLocale, closestSupportedLocale, defaultLocale } = require('lib/loc
const RNFetchBlob = require('react-native-fetch-blob').default;
const { PoorManIntervals } = require('lib/poor-man-intervals.js');
const { reducer, defaultState } = require('lib/reducer.js');
-const PushNotification = require('react-native-push-notification');
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
@@ -290,6 +291,9 @@ async function initialize(dispatch, backButtonHandler) {
BaseItem.loadClass('Tag', Tag);
BaseItem.loadClass('NoteTag', NoteTag);
+ AlarmService.setDriver(new AlarmServiceDriver());
+ AlarmService.setLogger(mainLogger);
+
try {
if (Setting.value('env') == 'prod') {
await db.open({ name: 'joplin.sqlite' })
@@ -366,13 +370,14 @@ async function initialize(dispatch, backButtonHandler) {
}
- reg.logger().info('Scheduling iOS notification');
- PushNotificationIOS.scheduleLocalNotification({
- alertTitle: "From Joplin",
- alertBody : "Testing notification on iOS",
- fireDate: new Date(Date.now() + (10 * 1000)),
- });
+ //reg.logger().info('Scheduling iOS notification');
+
+ // PushNotificationIOS.scheduleLocalNotification({
+ // alertTitle: "From Joplin",
+ // alertBody : "Testing notification on iOS",
+ // fireDate: new Date(Date.now() + (10 * 1000)),
+ // });