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 (
- this.answerInput_ = input} - value={this.state.answer} - type="text" - onChange={(event) => onChange(event)} - onKeyDown={(event) => onKeyDown(event)} /> + {inputComp} {descComp}
- - + {buttonComps}
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)), + // });