1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-03 08:35:29 +02:00
joplin/packages/lib/models/Note.js

856 lines
28 KiB
JavaScript
Raw Normal View History

2020-11-05 18:58:23 +02:00
const BaseModel = require('../BaseModel').default;
const { sprintf } = require('sprintf-js');
2020-11-05 18:58:23 +02:00
const BaseItem = require('./BaseItem.js');
const ItemChange = require('./ItemChange.js');
const Resource = require('./Resource.js');
const Setting = require('./Setting').default;
const shim = require('../shim').default;
const { pregQuote } = require('../string-utils.js');
const time = require('../time').default;
const { _ } = require('../locale');
const ArrayUtils = require('../ArrayUtils.js');
const lodash = require('lodash');
2020-11-05 18:58:23 +02:00
const urlUtils = require('../urlUtils.js');
const markdownUtils = require('../markdownUtils').default;
const { isImageMimeType } = require('../resourceUtils');
const { MarkupToHtml } = require('@joplin/renderer');
2020-11-05 18:58:23 +02:00
const { ALL_NOTES_FILTER_ID } = require('../reserved-ids');
2017-05-10 21:21:09 +02:00
2017-06-15 20:18:48 +02:00
class Note extends BaseItem {
2017-05-10 21:51:43 +02:00
static tableName() {
return 'notes';
2017-05-10 21:51:43 +02:00
}
static fieldToLabel(field) {
const fieldsToLabels = {
2018-05-30 19:22:07 +02:00
title: _('title'),
user_updated_time: _('updated date'),
user_created_time: _('created date'),
order: _('custom order'),
};
return field in fieldsToLabels ? fieldsToLabels[field] : field;
}
2017-07-04 21:12:30 +02:00
static async serializeForEdit(note) {
return this.replaceResourceInternalToExternalLinks(await super.serialize(note, ['title', 'body']));
2017-07-04 21:12:30 +02:00
}
2017-07-05 20:31:11 +02:00
static async unserializeForEdit(content) {
2019-09-19 23:51:18 +02:00
content += `\n\ntype_: ${BaseModel.TYPE_NOTE}`;
const output = await super.unserialize(content);
if (!output.title) output.title = '';
if (!output.body) output.body = '';
output.body = await this.replaceResourceExternalToInternalLinks(output.body);
2017-07-13 23:26:45 +02:00
return output;
2017-07-05 20:31:11 +02:00
}
static async serializeAllProps(note) {
const fieldNames = this.fieldNames();
fieldNames.push('type_');
lodash.pull(fieldNames, 'title', 'body');
return super.serialize(note, fieldNames);
}
static minimalSerializeForDisplay(note) {
const n = Object.assign({}, note);
const 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);
}
Refactor note editor Refactor note editor using React Hooks and TypeScript and moved editor-specific code to separate files. Moved business logic into more maintainable custom hooks. Squashed commit of the following: commit f243d9bf89bdcfa1849ee26df5c0dd3e33405010 Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 16:04:14 2020 +0100 Fixed saving issue commit 055f68d2e8b6cf6f130336c38ac2ab480887583d Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 15:43:38 2020 +0100 Fixed HTML notes commit 99a3cf71f58d2fedcdf3001bf4110b6e8e3993da Merge: 9be85c45f2 b16ebbbf7a Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 12:54:42 2020 +0100 Merge branch 'master' into refactor_note_text commit 9be85c45f23e5cb1ecd612b0ee631947871ada6f Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 12:21:01 2020 +0100 Ident to space commit 848dde1869c010fe5851f493ef7287ada5f2991e Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 11:28:50 2020 +0100 Refactor prop types commit 13c3bbe2b4f9a522ea3f8a25e7e5e7bb026dfd4f Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 11:15:45 2020 +0100 Fixed resource loading issue commit 50cb38e3f00ef40ea8b6a468eadd66728a3ec332 Author: Laurent Cozic <laurent@cozic.net> Date: Fri May 1 23:46:58 2020 +0100 Fixed resource loading logic commit bc42ed03735f50c8394d597bb9e67312e55752fe Author: Laurent Cozic <laurent@cozic.net> Date: Fri May 1 23:08:41 2020 +0100 Various fixes commit 03c038e6d6cbde03bd474798b96c4eb120fd1647 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 23:22:49 2020 +0100 Fixed resource handling commit dc6c15302fac094c4e7dec5a20c9fcc4edb3d132 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 22:55:13 2020 +0100 Moved more code to files commit 398d5121e53df34de89b4148ef2cfd3a7bbe4feb Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 00:22:43 2020 +0000 More fixes commit 3ebbb80147d7d502fd955776c7fedb743400597f Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 00:12:44 2020 +0000 Various improvements and bug fixes commit 52a65ed3875e0709117ca93ba723e20624577d05 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 23:51:07 2020 +0000 Move more code to sub-files commit 33ccf530fb442d7ddae0852cbab2c335efdbbf33 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 23:25:12 2020 +0100 Moved code to sub-files commit ba3ad2cf9fcc1d7809df4afe93cd9737585a9960 Merge: 445acdab73 150ee14de6 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 22:28:56 2020 +0100 Merge branch 'master' into refactor_note_text commit 445acdab7368345369d7f69b9becd1e77c8383dc Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 19:01:41 2020 +0100 Imported more code commit 772481d3a3ac7f0b0b00e86394c0f4fd2f3a9fa7 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 23:43:17 2020 +0000 Handle save/load state commit b3b92192ae3a1a30e3018810346cebfad47ac5e3 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 23:11:11 2020 +0000 Clean up and added back scroll commit 7a19ecfd0cb7fef1d58ece2e024099c7e40986da Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 22:29:39 2020 +0100 More refactoring commit ac388afd381eaecfa4582b3566d032c9d953c4dc Author: Laurent Cozic <laurent@cozic.net> Date: Sun Apr 26 17:07:01 2020 +0100 Restored print commit 1d2c0ed389a5398dacc584d24922c5ea0dda861a Author: Laurent Cozic <laurent@cozic.net> Date: Sun Apr 26 12:03:15 2020 +0100 Put back search commit c618cb59d43fa3bb507dbd0b757b302ecfe907b3 Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 18:21:11 2020 +0100 Restore scrolling behaviour commit 324e6ea79ebafab1d2bca246ef030751147a47eb Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 10:22:31 2020 +0100 Simplified saving notes commit ef089aaf2289193bf275d94c1f2785f6d88657e4 Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 10:12:16 2020 +0100 More refactoring commit 61b102307d5a98d2c1502d7bf073592da21af720 Author: Laurent Cozic <laurent@cozic.net> Date: Fri Apr 24 18:04:44 2020 +0100 Added back note revisions commit 7d5e3694d0df044b8493d9114e89e2d81c9b69ad Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 22:51:52 2020 +0000 More note toolbar refactoring commit a56d58e7c80d91f29afadaffaaa004f3254482f7 Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 20:54:37 2020 +0100 Finished toolbar refactoring commit 6c8ef9f44f880a9569eed5c54c9c47dca2251e5e Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 19:17:44 2020 +0100 More refactoring commit 7de8057158a9256e2e0dcf948081e10a6a642216 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 23:48:42 2020 +0100 Started refactoring commands commit 177263c85e7d17d8ddc01b583738c2ab14b3acd7 Merge: f58f1a06e0 7ceb68d835 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 20:26:19 2020 +0100 Merge branch 'master' into refactor_note_text commit f58f1a06e08b3cf80e2ac7a794b15f4b5caf8932 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 20:25:43 2020 +0100 Moving Ace Editor to separate component commit a83d3a220515137985c0f334f5848c91b8539138 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 20:33:21 2020 +0000 Cleaned up directory structure for note editor commit c6f2e609c9443bac21de5033bbedf86ac6f12cc0 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 19:23:06 2020 +0100 Added "note" menu to move note-related items to it commit 1219465318ae5a7a2c777ae2ec15d3357e1499df Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 19:05:04 2020 +0100 Moved note related toolbar to separate component
2020-05-02 17:41:07 +02:00
static defaultTitle(noteBody) {
return this.defaultTitleFromBody(noteBody);
}
static defaultTitleFromBody(body) {
return markdownUtils.titleFromBody(body);
2017-08-21 22:46:31 +02:00
}
2017-07-18 21:27:10 +02:00
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) {
2019-07-29 15:43:53 +02:00
return sprintf('https://www.openstreetmap.org/?lat=%s&lon=%s&zoom=20', lat, long);
2017-07-18 21:27:10 +02:00
}
2017-07-03 21:50:45 +02:00
static modelType() {
return BaseModel.TYPE_NOTE;
2017-05-18 21:58:01 +02:00
}
2018-05-03 14:11:45 +02:00
static linkedItemIds(body) {
2017-08-20 16:29:18 +02:00
if (!body || body.length <= 32) return [];
const links = urlUtils.extractResourceUrls(body);
const itemIds = links.map(l => l.itemId);
return ArrayUtils.unique(itemIds);
2017-08-20 16:29:18 +02:00
}
2018-05-03 14:11:45 +02:00
static async linkedItems(body) {
const itemIds = this.linkedItemIds(body);
const r = await BaseItem.loadItemsByIds(itemIds);
return r;
2018-05-03 14:11:45 +02:00
}
static async linkedItemIdsByType(type, body) {
const items = await this.linkedItems(body);
const output = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type_ === type) output.push(item.id);
}
return output;
}
static async linkedResourceIds(body) {
return this.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, body);
2018-05-03 14:11:45 +02:00
}
static async linkedNoteIds(body) {
return this.linkedItemIdsByType(BaseModel.TYPE_NOTE, body);
}
Desktop: Resolves #176: Added experimental WYSIWYG editor (#2556) * Trying to get TuiEditor to work * Tests with TinyMCE * Fixed build * Improved asset loading * Added support for Joplin source blocks * Added support for Joplin source blocks * Better integration * Make sure noteDidUpdate event is always dispatched at the right time * Minor tweaks * Fixed tests * Add support for checkboxes * Minor refactoring * Added support for file attachments * Add support for fenced code blocks * Fix new line issue on code block * Added support for Fountain scripts * Refactoring * Better handling of saving and loading notes * Fix saving and loading ntoes * Handle multi-note selection and fixed new note creation issue * Fixed newline issue in test * Fixed newline issue in test * Improve saving and loading * Improve saving and loading note * Removed undeeded prop * Fixed issue when new note being saved is incorrectly reloaded * Refactoring and improve saving of note when unmounting component * Fixed TypeScript error * Small changes * Improved further handling of saving and loading notes * Handle provisional notes and fixed various saving and loading bugs * Adding back support for HTML notes * Added support for HTML notes * Better handling of editable nodes * Preserve image HTML tag when the size is set * Handle switching between editor when the note has note finished saving * Handle templates * Handle templates * Handle loading note that is being saved * Handle note being reloaded via sync * Clean up * Clean up and improved logging * Fixed TS error * Fixed a few issues * Fixed test * Logging * Various improvements * Add blockquote support * Moved CWD operation to shim * Removed deleted files * Added support for Joplin commands
2020-03-10 01:24:57 +02:00
static async replaceResourceInternalToExternalLinks(body, options = null) {
options = Object.assign({}, {
useAbsolutePaths: false,
}, options);
this.logger().debug('replaceResourceInternalToExternalLinks', 'options:', options, 'body:', body);
const resourceIds = await this.linkedResourceIds(body);
const Resource = this.getClass('Resource');
for (let i = 0; i < resourceIds.length; i++) {
const id = resourceIds[i];
const resource = await Resource.load(id);
2019-01-20 18:28:10 +02:00
if (!resource) continue;
const isImage = isImageMimeType(resource.mime);
// We add a timestamp parameter for images, so that when they
// change, the preview is updated inside the note. This is not
// needed for other resources since they are simple links.
const timestampParam = isImage ? `?t=${resource.updated_time}` : '';
const resourcePath = options.useAbsolutePaths ? `file://${Resource.fullPath(resource)}${timestampParam}` : Resource.relativePath(resource);
body = body.replace(new RegExp(`:/${id}`, 'gi'), markdownUtils.escapeLinkUrl(resourcePath));
}
this.logger().debug('replaceResourceInternalToExternalLinks result', body);
return body;
}
Desktop: Resolves #176: Added experimental WYSIWYG editor (#2556) * Trying to get TuiEditor to work * Tests with TinyMCE * Fixed build * Improved asset loading * Added support for Joplin source blocks * Added support for Joplin source blocks * Better integration * Make sure noteDidUpdate event is always dispatched at the right time * Minor tweaks * Fixed tests * Add support for checkboxes * Minor refactoring * Added support for file attachments * Add support for fenced code blocks * Fix new line issue on code block * Added support for Fountain scripts * Refactoring * Better handling of saving and loading notes * Fix saving and loading ntoes * Handle multi-note selection and fixed new note creation issue * Fixed newline issue in test * Fixed newline issue in test * Improve saving and loading * Improve saving and loading note * Removed undeeded prop * Fixed issue when new note being saved is incorrectly reloaded * Refactoring and improve saving of note when unmounting component * Fixed TypeScript error * Small changes * Improved further handling of saving and loading notes * Handle provisional notes and fixed various saving and loading bugs * Adding back support for HTML notes * Added support for HTML notes * Better handling of editable nodes * Preserve image HTML tag when the size is set * Handle switching between editor when the note has note finished saving * Handle templates * Handle templates * Handle loading note that is being saved * Handle note being reloaded via sync * Clean up * Clean up and improved logging * Fixed TS error * Fixed a few issues * Fixed test * Logging * Various improvements * Add blockquote support * Moved CWD operation to shim * Removed deleted files * Added support for Joplin commands
2020-03-10 01:24:57 +02:00
static async replaceResourceExternalToInternalLinks(body, options = null) {
options = Object.assign({}, {
useAbsolutePaths: false,
}, options);
const pathsToTry = [];
if (options.useAbsolutePaths) {
pathsToTry.push(`file://${Setting.value('resourceDir')}`);
pathsToTry.push(`file://${shim.pathRelativeToCwd(Setting.value('resourceDir'))}`);
Desktop: Resolves #176: Added experimental WYSIWYG editor (#2556) * Trying to get TuiEditor to work * Tests with TinyMCE * Fixed build * Improved asset loading * Added support for Joplin source blocks * Added support for Joplin source blocks * Better integration * Make sure noteDidUpdate event is always dispatched at the right time * Minor tweaks * Fixed tests * Add support for checkboxes * Minor refactoring * Added support for file attachments * Add support for fenced code blocks * Fix new line issue on code block * Added support for Fountain scripts * Refactoring * Better handling of saving and loading notes * Fix saving and loading ntoes * Handle multi-note selection and fixed new note creation issue * Fixed newline issue in test * Fixed newline issue in test * Improve saving and loading * Improve saving and loading note * Removed undeeded prop * Fixed issue when new note being saved is incorrectly reloaded * Refactoring and improve saving of note when unmounting component * Fixed TypeScript error * Small changes * Improved further handling of saving and loading notes * Handle provisional notes and fixed various saving and loading bugs * Adding back support for HTML notes * Added support for HTML notes * Better handling of editable nodes * Preserve image HTML tag when the size is set * Handle switching between editor when the note has note finished saving * Handle templates * Handle templates * Handle loading note that is being saved * Handle note being reloaded via sync * Clean up * Clean up and improved logging * Fixed TS error * Fixed a few issues * Fixed test * Logging * Various improvements * Add blockquote support * Moved CWD operation to shim * Removed deleted files * Added support for Joplin commands
2020-03-10 01:24:57 +02:00
} else {
pathsToTry.push(Resource.baseRelativeDirectoryPath());
}
this.logger().debug('replaceResourceExternalToInternalLinks', 'options:', options, 'pathsToTry:', pathsToTry, 'body:', body);
Desktop: Resolves #176: Added experimental WYSIWYG editor (#2556) * Trying to get TuiEditor to work * Tests with TinyMCE * Fixed build * Improved asset loading * Added support for Joplin source blocks * Added support for Joplin source blocks * Better integration * Make sure noteDidUpdate event is always dispatched at the right time * Minor tweaks * Fixed tests * Add support for checkboxes * Minor refactoring * Added support for file attachments * Add support for fenced code blocks * Fix new line issue on code block * Added support for Fountain scripts * Refactoring * Better handling of saving and loading notes * Fix saving and loading ntoes * Handle multi-note selection and fixed new note creation issue * Fixed newline issue in test * Fixed newline issue in test * Improve saving and loading * Improve saving and loading note * Removed undeeded prop * Fixed issue when new note being saved is incorrectly reloaded * Refactoring and improve saving of note when unmounting component * Fixed TypeScript error * Small changes * Improved further handling of saving and loading notes * Handle provisional notes and fixed various saving and loading bugs * Adding back support for HTML notes * Added support for HTML notes * Better handling of editable nodes * Preserve image HTML tag when the size is set * Handle switching between editor when the note has note finished saving * Handle templates * Handle templates * Handle loading note that is being saved * Handle note being reloaded via sync * Clean up * Clean up and improved logging * Fixed TS error * Fixed a few issues * Fixed test * Logging * Various improvements * Add blockquote support * Moved CWD operation to shim * Removed deleted files * Added support for Joplin commands
2020-03-10 01:24:57 +02:00
for (const basePath of pathsToTry) {
2020-05-30 16:28:42 +02:00
const reStrings = [
// Handles file://path/to/abcdefg.jpg?t=12345678
`${pregQuote(`${basePath}/`)}[a-zA-Z0-9.]+\\?t=[0-9]+`,
// Handles file://path/to/abcdefg.jpg
`${pregQuote(`${basePath}/`)}[a-zA-Z0-9.]+`,
];
for (const reString of reStrings) {
const re = new RegExp(reString, 'gi');
body = body.replace(re, match => {
const id = Resource.pathToId(match);
return `:/${id}`;
});
}
// Handles joplin://af0edffa4a60496bba1b0ba06b8fb39a
body = body.replace(/\(joplin:\/\/([a-zA-Z0-9]{32})\)/g, '(:/$1)');
Desktop: Resolves #176: Added experimental WYSIWYG editor (#2556) * Trying to get TuiEditor to work * Tests with TinyMCE * Fixed build * Improved asset loading * Added support for Joplin source blocks * Added support for Joplin source blocks * Better integration * Make sure noteDidUpdate event is always dispatched at the right time * Minor tweaks * Fixed tests * Add support for checkboxes * Minor refactoring * Added support for file attachments * Add support for fenced code blocks * Fix new line issue on code block * Added support for Fountain scripts * Refactoring * Better handling of saving and loading notes * Fix saving and loading ntoes * Handle multi-note selection and fixed new note creation issue * Fixed newline issue in test * Fixed newline issue in test * Improve saving and loading * Improve saving and loading note * Removed undeeded prop * Fixed issue when new note being saved is incorrectly reloaded * Refactoring and improve saving of note when unmounting component * Fixed TypeScript error * Small changes * Improved further handling of saving and loading notes * Handle provisional notes and fixed various saving and loading bugs * Adding back support for HTML notes * Added support for HTML notes * Better handling of editable nodes * Preserve image HTML tag when the size is set * Handle switching between editor when the note has note finished saving * Handle templates * Handle templates * Handle loading note that is being saved * Handle note being reloaded via sync * Clean up * Clean up and improved logging * Fixed TS error * Fixed a few issues * Fixed test * Logging * Various improvements * Add blockquote support * Moved CWD operation to shim * Removed deleted files * Added support for Joplin commands
2020-03-10 01:24:57 +02:00
}
this.logger().debug('replaceResourceExternalToInternalLinks result', body);
return body;
}
static new(parentId = '') {
const output = super.new();
2017-05-20 00:16:50 +02:00
output.parent_id = parentId;
return output;
2017-05-10 21:51:43 +02:00
}
static newTodo(parentId = '') {
const output = this.new(parentId);
2017-05-24 22:51:50 +02:00
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;
2019-07-29 15:43:53 +02:00
};
const noteFieldComp = (f1, f2) => {
if (f1 === f2) return 0;
return f1 < f2 ? -1 : +1;
2019-07-29 15:43:53 +02:00
};
// 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;
2019-07-29 15:43:53 +02:00
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() : '';
2019-07-29 15:43:53 +02:00
r = noteFieldComp(titleA, titleB);
if (r) return r;
2018-05-30 19:22:07 +02:00
return noteFieldComp(a.id, b.id);
2019-07-29 15:43:53 +02:00
};
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];
let aProp = a[order.by];
let bProp = b[order.by];
if (typeof aProp === 'string') aProp = aProp.toLowerCase();
if (typeof bProp === 'string') bProp = bProp.toLowerCase();
if (aProp < bProp) r = +1;
if (aProp > bProp) r = -1;
if (order.dir == 'ASC') r = -r;
if (r !== 0) return r;
}
return sortIdenticalNotes(a, b);
});
}
2017-07-15 20:13:31 +02:00
static previewFieldsWithDefaultValues(options = null) {
return Note.defaultValues(this.previewFields(options));
}
static previewFields(options = null) {
options = Object.assign({
includeTimestamps: true,
}, options);
const output = ['id', 'title', 'is_todo', 'todo_completed', 'todo_due', 'parent_id', 'encryption_applied', 'order', 'markup_language', 'is_conflict'];
if (options.includeTimestamps) {
output.push('updated_time');
output.push('user_updated_time');
output.push('user_created_time');
}
return output;
2017-06-25 14:49:46 +02:00
}
2018-09-27 20:35:10 +02:00
static previewFieldsSql(fields = null) {
if (fields === null) fields = this.previewFields();
return this.db().escapeFields(fields).join(',');
}
2017-07-15 17:35:40 +02:00
static async loadFolderNoteByField(folderId, field, value) {
if (!folderId) throw new Error('folderId is undefined');
2017-07-15 17:35:40 +02:00
const options = {
2019-09-19 23:51:18 +02:00
conditions: [`\`${field}\` = ?`],
2017-07-15 17:35:40 +02:00
conditionsParams: [value],
fields: '*',
2019-07-29 15:43:53 +02:00
};
2017-07-15 17:35:40 +02:00
const results = await this.previews(folderId, options);
2017-07-15 17:35:40 +02:00
return results.length ? results[0] : null;
2017-06-27 21:48:01 +02:00
}
static async previews(parentId, options = null) {
// Note: ordering logic must be duplicated in sortNotes(), which is used
// to sort already loaded notes.
2017-06-25 11:00:54 +02:00
if (!options) options = {};
2019-07-29 15:43:53 +02:00
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' }];
2017-07-03 20:58:01 +02:00
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;
2017-07-03 20:58:01 +02:00
const Folder = BaseItem.getClass('Folder');
// Conflicts are always displayed regardless of options, since otherwise
// it's confusing to have conflicts but with an empty conflict folder.
if (parentId === Folder.conflictFolderId()) options.showCompletedTodos = true;
if (parentId == Folder.conflictFolderId()) {
options.conditions.push('is_conflict = 1');
2017-07-15 17:35:40 +02:00
} else {
options.conditions.push('is_conflict = 0');
if (parentId && parentId !== ALL_NOTES_FILTER_ID) {
options.conditions.push('parent_id = ?');
2017-07-17 21:56:14 +02:00
options.conditionsParams.push(parentId);
}
}
if (options.anywherePattern) {
const pattern = options.anywherePattern.replace(/\*/g, '%');
options.conditions.push('(title LIKE ? OR body LIKE ?)');
2017-07-17 21:56:14 +02:00
options.conditionsParams.push(pattern);
options.conditionsParams.push(pattern);
}
2017-06-25 11:00:54 +02:00
let hasNotes = true;
let hasTodos = true;
2017-06-25 11:00:54 +02:00
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;
const 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)');
2017-06-25 11:00:54 +02:00
}
tempOptions = Object.assign({}, options);
tempOptions.conditions = cond;
if ('limit' in tempOptions) tempOptions.limit -= uncompletedTodos.length;
const theRest = await this.search(tempOptions);
return uncompletedTodos.concat(theRest);
}
if (hasNotes && hasTodos) {
// Nothing
} else if (hasNotes) {
options.conditions.push('is_todo = 0');
} else if (hasTodos) {
options.conditions.push('is_todo = 1');
2017-06-25 11:00:54 +02:00
}
2017-06-25 14:49:46 +02:00
2017-07-03 20:58:01 +02:00
return this.search(options);
2017-05-11 22:14:01 +02:00
}
2018-09-27 20:35:10 +02:00
static preview(noteId, options = null) {
if (!options) options = { fields: null };
2019-09-19 23:51:18 +02:00
return this.modelSelectOne(`SELECT ${this.previewFieldsSql(options.fields)} FROM notes WHERE is_conflict = 0 AND id = ?`, [noteId]);
2017-06-20 21:18:19 +02:00
}
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);
}
2017-06-20 21:25:01 +02:00
static conflictedNotes() {
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 1');
}
2017-07-15 17:35:40 +02:00
static async conflictedCount() {
const r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 1');
2017-07-15 17:35:40 +02:00
return r && r.total ? r.total : 0;
}
2017-07-02 23:01:37 +02:00
static unconflictedNotes() {
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
2017-07-02 23:01:37 +02:00
}
2017-07-11 20:17:23 +02:00
static async updateGeolocation(noteId) {
if (!Setting.value('trackLocation')) return;
2017-07-10 22:59:58 +02:00
if (!Note.updateGeolocationEnabled_) return;
const startWait = time.unixMs();
2017-07-11 20:17:23 +02:00
while (true) {
if (!this.geolocationUpdating_) break;
this.logger().info('Waiting for geolocation update...');
2017-07-11 20:17:23 +02:00
await time.sleep(1);
if (startWait + 1000 * 20 < time.unixMs()) {
2019-09-19 23:51:18 +02:00
this.logger().warn(`Failed to update geolocation for: timeout: ${noteId}`);
2017-07-11 20:17:23 +02:00
return;
}
}
2017-05-15 21:46:34 +02:00
2017-05-23 21:01:37 +02:00
let geoData = null;
2017-07-11 20:17:23 +02:00
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) {
2019-09-19 23:51:18 +02:00
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');
2017-07-11 20:17:23 +02:00
this.geolocationCache_ = geoData;
}
2019-09-19 23:51:18 +02:00
this.logger().info(`Updating lat/long of note ${noteId}`);
2017-07-11 20:17:23 +02:00
const note = await Note.load(noteId);
2017-07-11 20:17:23 +02:00
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);
2017-05-15 21:46:34 +02:00
}
2017-06-24 19:40:03 +02:00
static filter(note) {
if (!note) return note;
const 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);
2017-06-24 19:40:03 +02:00
return output;
}
2017-07-15 17:35:40 +02:00
static async copyToFolder(noteId, folderId) {
if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', this.getClass('Folder').conflictFolderTitle()));
2017-07-15 17:35:40 +02:00
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').conflictFolderTitle()));
2017-07-15 17:35:40 +02:00
2017-08-20 22:11:32 +02:00
// 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 = {
2017-07-15 17:35:40 +02:00
id: noteId,
parent_id: folderId,
is_conflict: 0,
2017-08-20 22:11:32 +02:00
updated_time: time.unixMs(),
};
return Note.save(modifiedNote, { autoTimestamp: false });
2017-07-15 17:35:40 +02:00
}
static changeNoteType(note, type) {
if (!('is_todo' in note)) throw new Error('Missing "is_todo" property');
2017-07-30 21:51:18 +02:00
const newIsTodo = type === 'todo' ? 1 : 0;
if (Number(note.is_todo) === newIsTodo) return note;
const output = Object.assign({}, note);
output.is_todo = newIsTodo;
2017-07-31 20:47:06 +02:00
output.todo_due = 0;
output.todo_completed = 0;
2017-07-30 21:51:18 +02:00
return output;
2017-07-17 22:22:05 +02:00
}
static toggleIsTodo(note) {
2019-07-29 15:43:53 +02:00
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();
}
2019-07-29 15:43:53 +02:00
return note;
}
2019-10-14 01:47:21 +02:00
static async duplicateMultipleNotes(noteIds, options = null) {
// if options.uniqueTitle is true, a unique title for the duplicated file will be assigned.
const ensureUniqueTitle = options && options.ensureUniqueTitle;
2019-10-14 01:47:21 +02:00
for (const noteId of noteIds) {
const noteOptions = {};
2019-10-14 01:47:21 +02:00
// If ensureUniqueTitle is truthy, set the original note's name as root for the unique title.
if (ensureUniqueTitle) {
const originalNote = await Note.load(noteId);
noteOptions.uniqueTitle = originalNote.title;
}
await Note.duplicate(noteId, noteOptions);
}
}
2017-07-11 20:17:23 +02:00
static async duplicate(noteId, options = null) {
const changes = options && options.changes;
const uniqueTitle = options && options.uniqueTitle;
2017-07-11 20:17:23 +02:00
const originalNote = await Note.load(noteId);
2019-09-19 23:51:18 +02:00
if (!originalNote) throw new Error(`Unknown note: ${noteId}`);
2017-07-11 20:17:23 +02:00
const newNote = Object.assign({}, originalNote);
const fieldsToReset = ['id', 'created_time', 'updated_time', 'user_created_time', 'user_updated_time'];
for (const field of fieldsToReset) {
delete newNote[field];
}
2017-07-11 20:17:23 +02:00
for (const n in changes) {
2017-07-11 20:17:23 +02:00
if (!changes.hasOwnProperty(n)) continue;
newNote[n] = changes[n];
}
if (uniqueTitle) {
const title = await Note.findUniqueItemTitle(uniqueTitle);
newNote.title = title;
}
2017-07-11 20:17:23 +02:00
return this.save(newNote);
}
static async noteIsOlderThan(noteId, date) {
const n = await this.db().selectOne('SELECT updated_time FROM notes WHERE id = ?', [noteId]);
2019-09-19 23:51:18 +02:00
if (!n) throw new Error(`No such note: ${noteId}`);
return n.updated_time < date;
}
static async save(o, options = null) {
const isNew = this.isNew(o, options);
2020-02-29 14:59:10 +02:00
const isProvisional = options && !!options.provisional;
const dispatchUpdateAction = options ? options.dispatchUpdateAction !== false : true;
if (isNew && !o.source) o.source = Setting.value('appName');
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
if (isNew && !('order' in o)) o.order = Date.now();
2017-06-29 22:52:52 +02:00
// 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.
// 2020-10-19: It's not ideal to reload the previous version of the note before saving it again
// but it should be relatively fast anyway. This is so that code that listens to the NOTE_UPDATE_ONE
// action can decide what to do based on the fields that have been modified.
// This is necessary for example so that the folder list is not refreshed every time a note is changed.
// Now it can look at the properties and refresh only if the "parent_id" property is changed.
// Trying to fix: https://github.com/laurent22/joplin/issues/3893
2020-10-20 12:49:15 +02:00
const oldNote = !isNew && o.id ? await Note.load(o.id) : null;
let beforeNoteJson = null;
2020-10-20 12:49:15 +02:00
if (oldNote && this.revisionService().isOldNote(o.id)) {
beforeNoteJson = JSON.stringify(oldNote);
}
const changedFields = [];
if (oldNote) {
for (const field in o) {
if (!o.hasOwnProperty(field)) continue;
if (o[field] !== oldNote[field]) {
changedFields.push(field);
}
}
}
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);
if (dispatchUpdateAction) {
this.dispatch({
type: 'NOTE_UPDATE_ONE',
note: note,
provisional: isProvisional,
changedFields: changedFields,
});
}
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,
});
}
2018-05-30 19:22:07 +02:00
return note;
2017-05-22 22:22:50 +02:00
}
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);
}
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;
}
static dueDateObject(note) {
if (!!note.is_todo && note.todo_due) {
if (!this.dueDateObjects_) this.dueDateObjects_ = {};
if (this.dueDateObjects_[note.todo_due]) return this.dueDateObjects_[note.todo_due];
this.dueDateObjects_[note.todo_due] = new Date(note.todo_due);
return this.dueDateObjects_[note.todo_due];
}
return null;
}
// 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;
}
static markupLanguageToLabel(markupLanguageId) {
if (markupLanguageId === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN) return 'Markdown';
if (markupLanguageId === MarkupToHtml.MARKUP_LANGUAGE_HTML) return 'HTML';
2019-09-19 23:51:18 +02:00
throw new Error(`Invalid markup language ID: ${markupLanguageId}`);
}
// When notes are sorted in "custom order", they are sorted by the "order" field first and,
// in those cases, where the order field is the same for some notes, by created time.
static customOrderByColumns(type = null) {
if (!type) type = 'object';
if (type === 'object') return [{ by: 'order', dir: 'DESC' }, { by: 'user_created_time', dir: 'DESC' }];
if (type === 'string') return 'ORDER BY `order` DESC, user_created_time DESC';
throw new Error(`Invalid type: ${type}`);
}
// Update the note "order" field without changing the user timestamps,
// which is generally what we want.
static async updateNoteOrder_(note, order) {
return Note.save(Object.assign({}, note, {
order: order,
user_updated_time: note.user_updated_time,
}), { autoTimestamp: false, dispatchUpdateAction: false });
}
// This method will disable the NOTE_UPDATE_ONE action to prevent a lot
// of unecessary updates, so it's the caller's responsability to update
// the UI once the call is finished. This is done by listening to the
// NOTE_IS_INSERTING_NOTES action in the application middleware.
static async insertNotesAt(folderId, noteIds, index) {
if (!noteIds.length) return;
const defer = () => {
this.dispatch({
type: 'NOTE_IS_INSERTING_NOTES',
value: false,
});
};
this.dispatch({
type: 'NOTE_IS_INSERTING_NOTES',
value: true,
});
try {
const noteSql = `
SELECT id, \`order\`, user_created_time, user_updated_time
FROM notes
WHERE is_conflict = 0 AND parent_id = ?
${this.customOrderByColumns('string')}`;
let notes = await this.modelSelectAll(noteSql, [folderId]);
// If the target index is the same as the source note index, exit now
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (note.id === noteIds[0] && index === i) return defer();
}
// If some of the target notes have order = 0, set the order field to user_created_time
// (historically, all notes had the order field set to 0)
let hasSetOrder = false;
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (!note.order) {
const updatedNote = await this.updateNoteOrder_(note, note.user_created_time);
notes[i] = updatedNote;
hasSetOrder = true;
}
}
if (hasSetOrder) notes = await this.modelSelectAll(noteSql, [folderId]);
// Find the order value for the first note to be inserted,
// and the increment between the order values of each inserted notes.
let newOrder = 0;
let intervalBetweenNotes = 0;
const defaultIntevalBetweeNotes = 60 * 60 * 1000;
if (!notes.length) { // If there's no notes in the target notebook
newOrder = Date.now();
intervalBetweenNotes = defaultIntevalBetweeNotes;
} else if (index >= notes.length) { // Insert at the end
intervalBetweenNotes = notes[notes.length - 1].order / (noteIds.length + 1);
newOrder = notes[notes.length - 1].order - intervalBetweenNotes;
} else if (index === 0) { // Insert at the beginning
const firstNoteOrder = notes[0].order;
if (firstNoteOrder >= Date.now()) {
intervalBetweenNotes = defaultIntevalBetweeNotes;
newOrder = firstNoteOrder + defaultIntevalBetweeNotes;
} else {
intervalBetweenNotes = (Date.now() - firstNoteOrder) / (noteIds.length + 1);
newOrder = firstNoteOrder + intervalBetweenNotes * noteIds.length;
}
} else { // Normal insert
let noteBefore = notes[index - 1];
let noteAfter = notes[index];
if (noteBefore.order === noteAfter.order) {
let previousOrder = noteBefore.order;
for (let i = index; i >= 0; i--) {
const n = notes[i];
if (n.order <= previousOrder) {
const o = previousOrder + defaultIntevalBetweeNotes;
const updatedNote = await this.updateNoteOrder_(n, o);
notes[i] = Object.assign({}, n, updatedNote);
previousOrder = o;
} else {
previousOrder = n.order;
}
}
noteBefore = notes[index - 1];
noteAfter = notes[index];
}
intervalBetweenNotes = (noteBefore.order - noteAfter.order) / (noteIds.length + 1);
newOrder = noteAfter.order + intervalBetweenNotes * noteIds.length;
}
// Set the order value for all the notes to be inserted
for (const noteId of noteIds) {
const note = await Note.load(noteId);
if (!note) throw new Error(`No such note: ${noteId}`);
await this.updateNoteOrder_({
id: noteId,
parent_id: folderId,
user_updated_time: note.user_updated_time,
}, newOrder);
newOrder -= intervalBetweenNotes;
}
} finally {
defer();
}
}
2017-05-10 21:21:09 +02:00
}
2017-07-10 22:59:58 +02:00
Note.updateGeolocationEnabled_ = true;
2017-07-11 20:17:23 +02:00
Note.geolocationUpdating_ = false;
2017-07-10 22:59:58 +02:00
2019-07-29 15:43:53 +02:00
module.exports = Note;