const BaseModel = require('lib/BaseModel.js'); const { sprintf } = require('sprintf-js'); const BaseItem = require('lib/models/BaseItem.js'); const ItemChange = require('lib/models/ItemChange.js'); const Resource = require('lib/models/Resource.js'); const Setting = require('lib/models/Setting.js'); const { shim } = require('lib/shim.js'); const { pregQuote } = require('lib/string-utils.js'); const { toSystemSlashes, toFileProtocolPath } = require('lib/path-utils.js'); const { time } = require('lib/time-utils.js'); const { _ } = require('lib/locale.js'); const ArrayUtils = require('lib/ArrayUtils.js'); const moment = require('moment'); const lodash = require('lodash'); class Note extends BaseItem { static tableName() { return 'notes'; } static fieldToLabel(field) { const fieldsToLabels = { title: _('title'), user_updated_time: _('updated date'), user_created_time: _('created date'), }; return field in fieldsToLabels ? fieldsToLabels[field] : field; } static async serializeForEdit(note) { return this.replaceResourceInternalToExternalLinks(await super.serialize(note, ['title', 'body'])); } static async unserializeForEdit(content) { content += "\n\ntype_: " + BaseModel.TYPE_NOTE; let output = await super.unserialize(content); if (!output.title) output.title = ''; if (!output.body) output.body = ''; output.body = await this.replaceResourceExternalToInternalLinks(output.body); return output; } static async serializeAllProps(note) { let fieldNames = this.fieldNames(); fieldNames.push('type_'); lodash.pull(fieldNames, 'title', 'body'); return super.serialize(note, fieldNames); } static minimalSerializeForDisplay(note) { let n = Object.assign({}, note); let fieldNames = this.fieldNames(); if (!n.is_conflict) lodash.pull(fieldNames, 'is_conflict'); if (!Number(n.latitude)) lodash.pull(fieldNames, 'latitude'); if (!Number(n.longitude)) lodash.pull(fieldNames, 'longitude'); if (!Number(n.altitude)) lodash.pull(fieldNames, 'altitude'); if (!n.author) lodash.pull(fieldNames, 'author'); if (!n.source_url) lodash.pull(fieldNames, 'source_url'); if (!n.is_todo) { lodash.pull(fieldNames, 'is_todo'); lodash.pull(fieldNames, 'todo_due'); lodash.pull(fieldNames, 'todo_completed'); } if (!n.application_data) lodash.pull(fieldNames, 'application_data'); lodash.pull(fieldNames, 'type_'); lodash.pull(fieldNames, 'title'); lodash.pull(fieldNames, 'body'); lodash.pull(fieldNames, 'created_time'); lodash.pull(fieldNames, 'updated_time'); lodash.pull(fieldNames, 'order'); return super.serialize(n, fieldNames); } static defaultTitle(note) { return this.defaultTitleFromBody(note.body); } static defaultTitleFromBody(body) { if (body && body.length) { const lines = body.trim().split("\n"); let output = lines[0].trim(); // Remove the first #, *, etc. while (output.length) { const c = output[0]; if (['#', ' ', "\n", "\t", '*', '`', '-'].indexOf(c) >= 0) { output = output.substr(1); } else { break; } } return output.substr(0, 80).trim(); } return _('Untitled'); } static geolocationUrl(note) { if (!('latitude' in note) || !('longitude' in note)) throw new Error('Latitude or longitude is missing'); if (!Number(note.latitude) && !Number(note.longitude)) throw new Error(_('This note does not have geolocation information.')); return this.geoLocationUrlFromLatLong(note.latitude, note.longitude); } static geoLocationUrlFromLatLong(lat, long) { return sprintf('https://www.openstreetmap.org/?lat=%s&lon=%s&zoom=20', lat, long) } static modelType() { return BaseModel.TYPE_NOTE; } static linkedItemIds(body) { if (!body || body.length <= 32) return []; // For example: ![](:/fcca2938a96a22570e8eae2565bc6b0b) let matches = body.match(/\(:\/[a-zA-Z0-9]{32}\)/g); if (!matches) matches = []; matches = matches.map((m) => m.substr(3, 32)); // For example: ![](:/fcca2938a96a22570e8eae2565bc6b0b "Some title") let matches2 = body.match(/\(:\/[a-zA-Z0-9]{32}\s(.*?)\)/g); if (!matches2) matches2 = []; matches2 = matches2.map((m) => m.substr(3, 32)); matches = matches.concat(matches2) // For example: const imgRegex = / { const id = Resource.pathToId(match); return ':/' + id; }); return body; } 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; } // Note: sort logic must be duplicated in previews(); static sortNotes(notes, orders, uncompletedTodosOnTop) { const noteOnTop = (note) => { return uncompletedTodosOnTop && note.is_todo && !note.todo_completed; } const noteFieldComp = (f1, f2) => { if (f1 === f2) return 0; return f1 < f2 ? -1 : +1; } // Makes the sort deterministic, so that if, for example, a and b have the // same updated_time, they aren't swapped every time a list is refreshed. const sortIdenticalNotes = (a, b) => { let r = null; r = noteFieldComp(a.user_updated_time, b.user_updated_time); if (r) return r; r = noteFieldComp(a.user_created_time, b.user_created_time); if (r) return r; const titleA = a.title ? a.title.toLowerCase() : ''; const titleB = b.title ? b.title.toLowerCase() : ''; r = noteFieldComp(titleA, titleB); if (r) return r; return noteFieldComp(a.id, b.id); } return notes.sort((a, b) => { if (noteOnTop(a) && !noteOnTop(b)) return -1; if (!noteOnTop(a) && noteOnTop(b)) return +1; let r = 0; for (let i = 0; i < orders.length; i++) { const order = orders[i]; if (a[order.by] < b[order.by]) r = +1; if (a[order.by] > b[order.by]) r = -1; if (order.dir == 'ASC') r = -r; if (r !== 0) return r; } return sortIdenticalNotes(a, b); }); } static previewFields() { // return ['id', 'title', 'body', 'is_todo', 'todo_completed', 'parent_id', 'updated_time', 'user_updated_time', 'user_created_time', 'encryption_applied']; return ['id', 'title', 'is_todo', 'todo_completed', 'parent_id', 'updated_time', 'user_updated_time', 'user_created_time', 'encryption_applied']; } static previewFieldsSql(fields = null) { if (fields === null) fields = this.previewFields(); return this.db().escapeFields(fields).join(','); } static async loadFolderNoteByField(folderId, field, value) { if (!folderId) throw new Error('folderId is undefined'); let options = { conditions: ['`' + field + '` = ?'], conditionsParams: [value], fields: '*', } let results = await this.previews(folderId, options); return results.length ? results[0] : null; } static async previews(parentId, options = null) { // Note: ordering logic must be duplicated in sortNotes(), which // is used to sort already loaded notes. if (!options) options = {}; if (!('order' in options)) options.order = [ { by: 'user_updated_time', dir: 'DESC' }, { by: 'user_created_time', dir: 'DESC' }, { by: 'title', dir: 'DESC' }, { by: 'id', dir: 'DESC' }, ]; if (!options.conditions) options.conditions = []; if (!options.conditionsParams) options.conditionsParams = []; if (!options.fields) options.fields = this.previewFields(); if (!options.uncompletedTodosOnTop) options.uncompletedTodosOnTop = false; if (!('showCompletedTodos' in options)) options.showCompletedTodos = true; if (parentId == BaseItem.getClass('Folder').conflictFolderId()) { options.conditions.push('is_conflict = 1'); } else { options.conditions.push('is_conflict = 0'); if (parentId) { options.conditions.push('parent_id = ?'); options.conditionsParams.push(parentId); } } if (options.anywherePattern) { let pattern = options.anywherePattern.replace(/\*/g, '%'); options.conditions.push('(title LIKE ? OR body LIKE ?)'); options.conditionsParams.push(pattern); options.conditionsParams.push(pattern); } let hasNotes = true; let hasTodos = true; if (options.itemTypes && options.itemTypes.length) { if (options.itemTypes.indexOf('note') < 0) { hasNotes = false; } else if (options.itemTypes.indexOf('todo') < 0) { hasTodos = false; } } if (!options.showCompletedTodos) { options.conditions.push('todo_completed <= 0'); } if (options.uncompletedTodosOnTop && hasTodos) { let cond = options.conditions.slice(); cond.push('is_todo = 1'); cond.push('(todo_completed <= 0 OR todo_completed IS NULL)'); let tempOptions = Object.assign({}, options); tempOptions.conditions = cond; let uncompletedTodos = await this.search(tempOptions); cond = options.conditions.slice(); if (hasNotes && hasTodos) { cond.push('(is_todo = 0 OR (is_todo = 1 AND todo_completed > 0))'); } else { cond.push('(is_todo = 1 AND todo_completed > 0)'); } tempOptions = Object.assign({}, options); tempOptions.conditions = cond; if ('limit' in tempOptions) tempOptions.limit -= uncompletedTodos.length; let theRest = await this.search(tempOptions); return uncompletedTodos.concat(theRest); } if (hasNotes && hasTodos) { } else if (hasNotes) { options.conditions.push('is_todo = 0'); } else if (hasTodos) { options.conditions.push('is_todo = 1'); } return this.search(options); } static preview(noteId, options = null) { if (!options) options = { fields: null }; return this.modelSelectOne('SELECT ' + this.previewFieldsSql(options.fields) + ' FROM notes WHERE is_conflict = 0 AND id = ?', [noteId]); } static async search(options = null) { if (!options) options = {}; if (!options.conditions) options.conditions = []; if (!options.conditionsParams) options.conditionsParams = []; if (options.bodyPattern) { const pattern = options.bodyPattern.replace(/\*/g, '%'); options.conditions.push('body LIKE ?'); options.conditionsParams.push(pattern); } return super.search(options); } static conflictedNotes() { return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 1'); } static async conflictedCount() { let r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 1'); return r && r.total ? r.total : 0; } static unconflictedNotes() { return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0'); } static async updateGeolocation(noteId) { if (!Setting.value('trackLocation')) return; if (!Note.updateGeolocationEnabled_) return; let startWait = time.unixMs(); while (true) { if (!this.geolocationUpdating_) break; this.logger().info('Waiting for geolocation update...'); await time.sleep(1); if (startWait + 1000 * 20 < time.unixMs()) { this.logger().warn('Failed to update geolocation for: timeout: ' + noteId); return; } } let geoData = null; if (this.geolocationCache_ && this.geolocationCache_.timestamp + 1000 * 60 * 10 > time.unixMs()) { geoData = Object.assign({}, this.geolocationCache_); } else { this.geolocationUpdating_ = true; this.logger().info('Fetching geolocation...'); try { geoData = await shim.Geolocation.currentPosition(); } catch (error) { this.logger().error('Could not get lat/long for note ' + noteId + ': ', error); geoData = null; } this.geolocationUpdating_ = false; if (!geoData) return; this.logger().info('Got lat/long'); this.geolocationCache_ = geoData; } this.logger().info('Updating lat/long of note ' + noteId); let note = await Note.load(noteId); 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); } 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 async copyToFolder(noteId, folderId) { if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', this.getClass('Folder').conflictFolderIdTitle())); return Note.duplicate(noteId, { changes: { parent_id: folderId, is_conflict: 0, // Also reset the conflict flag in case we're moving the note out of the conflict folder }, }); } static async moveToFolder(noteId, folderId) { if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', this.getClass('Folder').conflictFolderIdTitle())); // When moving a note to a different folder, the user timestamp is not updated. // However updated_time is updated so that the note can be synced later on. const modifiedNote = { id: noteId, parent_id: folderId, is_conflict: 0, updated_time: time.unixMs(), }; return Note.save(modifiedNote, { autoTimestamp: false }); } static changeNoteType(note, type) { if (!('is_todo' in note)) throw new Error('Missing "is_todo" property'); const newIsTodo = type === 'todo' ? 1 : 0; if (Number(note.is_todo) === newIsTodo) return note; const output = Object.assign({}, note); output.is_todo = newIsTodo; output.todo_due = 0; output.todo_completed = 0; return output; } static toggleIsTodo(note) { return this.changeNoteType(note, !!note.is_todo ? 'note' : 'todo'); } static toggleTodoCompleted(note) { if (!('todo_completed' in note)) throw new Error('Missing "todo_completed" property'); note = Object.assign({}, note); if (note.todo_completed) { note.todo_completed = 0; } else { note.todo_completed = Date.now(); } return note; } static async duplicate(noteId, options = null) { const changes = options && options.changes; const uniqueTitle = options && options.uniqueTitle; const originalNote = await Note.load(noteId); if (!originalNote) throw new Error('Unknown note: ' + noteId); let newNote = Object.assign({}, originalNote); delete newNote.id; for (let n in changes) { if (!changes.hasOwnProperty(n)) continue; newNote[n] = changes[n]; } if (uniqueTitle) { const title = await Note.findUniqueItemTitle(uniqueTitle); newNote.title = title; } return this.save(newNote); } static async noteIsOlderThan(noteId, date) { const n = await this.db().selectOne('SELECT updated_time FROM notes WHERE id = ?', [noteId]); if (!n) throw new Error('No such note: ' + noteId); return n.updated_time < date; } static async 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'); // We only keep the previous note content for "old notes" (see Revision Service for more info) // In theory, we could simply save all the previous note contents, and let the revision service // decide what to keep and what to ignore, but in practice keeping the previous content is a bit // heavy - the note needs to be reloaded here, the JSON blob needs to be saved, etc. // So the check for old note here is basically an optimisation. let beforeNoteJson = null; if (!isNew && this.revisionService().isOldNote(o.id)) { beforeNoteJson = await Note.load(o.id); if (beforeNoteJson) beforeNoteJson = JSON.stringify(beforeNoteJson); } const note = await super.save(o, options); const changeSource = options && options.changeSource ? options.changeSource : null; ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson); this.dispatch({ 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; } static async batchDelete(ids, options = null) { ids = ids.slice(); while (ids.length) { const processIds = ids.splice(0, 50); const notes = await Note.byIds(processIds); const beforeChangeItems = {}; for (const note of notes) { beforeChangeItems[note.id] = JSON.stringify(note); } const result = await super.batchDelete(processIds, options); const changeSource = options && options.changeSource ? options.changeSource : null; for (let i = 0; i < processIds.length; i++) { const id = processIds[i]; ItemChange.add(BaseModel.TYPE_NOTE, id, ItemChange.TYPE_DELETE, changeSource, beforeChangeItems[id]); this.dispatch({ type: 'NOTE_DELETE', id: id, }); } } } static dueNotes() { return this.modelSelectAll('SELECT id, title, body, is_todo, todo_due, todo_completed, is_conflict 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 if (localNote.id !== remoteNote.id) throw new Error('Cannot handle conflict for two different notes'); // For encrypted notes the conflict must always be handled if (localNote.encryption_cipher_text || remoteNote.encryption_cipher_text) return true; // Otherwise only handle the conflict if there's a different on the title or body if (localNote.title !== remoteNote.title) return true; if (localNote.body !== remoteNote.body) return true; return false; } } Note.updateGeolocationEnabled_ = true; Note.geolocationUpdating_ = false; module.exports = Note;