mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-15 09:04:04 +02:00
08af9de190
* Started revisions support * More rev changes * More rev changes * More revs changes * Fixed deletion algorithm * More tests and moved updated time to separate field * Display info when restoring note * Better handling of existing notes * wip * Further improvements and fixed tests * Better handling of changes created via sync * Enable chokidar again * Testing special case * Further improved logic to handle notes that existed before the revision service * Added tests * Better handling of encrypted revisions * Improved handling of deleted note revisions by moving logic to collectRevision * Improved handling of old notes by moving logic to collectRevision() * Handle case when deleting revisions while one is still encrypted * UI tweaks * Added revision service to mobile app * Fixed config screens on mobile and desktop * Enabled revisions on CLI app
249 lines
7.7 KiB
JavaScript
249 lines
7.7 KiB
JavaScript
const BaseModel = require('lib/BaseModel.js');
|
|
const BaseItem = require('lib/models/BaseItem.js');
|
|
const NoteTag = require('lib/models/NoteTag.js');
|
|
const Note = require('lib/models/Note.js');
|
|
const { time } = require('lib/time-utils.js');
|
|
const { _ } = require('lib/locale');
|
|
const DiffMatchPatch = require('diff-match-patch');
|
|
const ArrayUtils = require('lib/ArrayUtils.js');
|
|
const JoplinError = require('lib/JoplinError');
|
|
const { sprintf } = require('sprintf-js');
|
|
|
|
const dmp = new DiffMatchPatch();
|
|
|
|
class Revision extends BaseItem {
|
|
|
|
static tableName() {
|
|
return 'revisions';
|
|
}
|
|
|
|
static modelType() {
|
|
return BaseModel.TYPE_REVISION;
|
|
}
|
|
|
|
static createTextPatch(oldText, newText) {
|
|
return dmp.patch_toText(dmp.patch_make(oldText, newText));
|
|
}
|
|
|
|
static applyTextPatch(text, patch) {
|
|
patch = dmp.patch_fromText(patch);
|
|
const result = dmp.patch_apply(patch, text);
|
|
if (!result || !result.length) throw new Error('Could not apply patch');
|
|
return result[0];
|
|
}
|
|
|
|
static createObjectPatch(oldObject, newObject) {
|
|
if (!oldObject) oldObject = {};
|
|
|
|
const output = {
|
|
new: {},
|
|
deleted: [],
|
|
};
|
|
|
|
for (let k in newObject) {
|
|
if (!newObject.hasOwnProperty(k)) continue;
|
|
if (oldObject[k] === newObject[k]) continue;
|
|
output.new[k] = newObject[k];
|
|
}
|
|
|
|
for (let k in oldObject) {
|
|
if (!oldObject.hasOwnProperty(k)) continue;
|
|
if (!(k in newObject)) output.deleted.push(k);
|
|
}
|
|
|
|
return JSON.stringify(output);
|
|
}
|
|
|
|
static applyObjectPatch(object, patch) {
|
|
patch = JSON.parse(patch);
|
|
const output = Object.assign({}, object);
|
|
|
|
for (let k in patch.new) {
|
|
output[k] = patch.new[k];
|
|
}
|
|
|
|
for (let i = 0; i < patch.deleted.length; i++) {
|
|
delete output[patch.deleted[i]];
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
static async countRevisions(itemType, itemId) {
|
|
const r = await this.db().selectOne('SELECT count(*) as total FROM revisions WHERE item_type = ? AND item_id = ?', [
|
|
itemType,
|
|
itemId,
|
|
]);
|
|
|
|
return r ? r.total : 0;
|
|
}
|
|
|
|
static latestRevision(itemType, itemId) {
|
|
return this.modelSelectOne('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time DESC LIMIT 1', [
|
|
itemType,
|
|
itemId,
|
|
]);
|
|
}
|
|
|
|
static allByType(itemType, itemId) {
|
|
return this.modelSelectAll('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time ASC', [
|
|
itemType,
|
|
itemId,
|
|
]);
|
|
}
|
|
|
|
static async itemsWithRevisions(itemType, itemIds) {
|
|
if (!itemIds.length) return [];
|
|
const rows = await this.db().selectAll('SELECT distinct item_id FROM revisions WHERE item_type = ? AND item_id IN ("' + itemIds.join('","') + '")', [
|
|
itemType,
|
|
]);
|
|
|
|
return rows.map(r => r.item_id);
|
|
}
|
|
|
|
static async itemsWithNoRevisions(itemType, itemIds) {
|
|
const withRevs = await this.itemsWithRevisions(itemType, itemIds);
|
|
const output = [];
|
|
for (let i = 0; i < itemIds.length; i++) {
|
|
if (withRevs.indexOf(itemIds[i]) < 0) output.push(itemIds[i]);
|
|
}
|
|
return ArrayUtils.unique(output);
|
|
}
|
|
|
|
static moveRevisionToTop(revision, revs) {
|
|
let targetIndex = -1;
|
|
for (let i = revs.length - 1; i >= 0; i--) {
|
|
const rev = revs[i];
|
|
if (rev.id === revision.id) {
|
|
targetIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (targetIndex < 0) throw new Error('Could not find revision: ' + revision.id);
|
|
|
|
if (targetIndex !== revs.length - 1) {
|
|
revs = revs.slice();
|
|
const toTop = revs[targetIndex];
|
|
revs.splice(targetIndex, 1);
|
|
revs.push(toTop);
|
|
}
|
|
|
|
return revs;
|
|
}
|
|
|
|
// Note: revs must be sorted by update_time ASC (as returned by allByType)
|
|
static async mergeDiffs(revision, revs = null) {
|
|
if (!('encryption_applied' in revision) || !!revision.encryption_applied) throw new JoplinError('Target revision is encrypted', 'revision_encrypted');
|
|
|
|
if (!revs) {
|
|
revs = await this.modelSelectAll('SELECT * FROM revisions WHERE item_type = ? AND item_id = ? AND item_updated_time <= ? ORDER BY item_updated_time ASC', [
|
|
revision.item_type,
|
|
revision.item_id,
|
|
revision.item_updated_time,
|
|
]);
|
|
} else {
|
|
revs = revs.slice();
|
|
}
|
|
|
|
// Handle rare case where two revisions have been created at exactly the same millisecond
|
|
// Also handle even rarer case where a rev and its parent have been created at the
|
|
// same milliseconds. All code below expects target revision to be on top.
|
|
revs = this.moveRevisionToTop(revision, revs);
|
|
|
|
const output = {
|
|
title: '',
|
|
body: '',
|
|
metadata: {},
|
|
};
|
|
|
|
// Build up the list of revisions that are parents of the target revision.
|
|
const revIndexes = [revs.length - 1];
|
|
let parentId = revision.parent_id;
|
|
for (let i = revs.length - 2; i >= 0; i--) {
|
|
const rev = revs[i];
|
|
if (rev.id !== parentId) continue;
|
|
parentId = rev.parent_id;
|
|
revIndexes.push(i);
|
|
}
|
|
revIndexes.reverse();
|
|
|
|
for (const revIndex of revIndexes) {
|
|
const rev = revs[revIndex];
|
|
if (!!rev.encryption_applied) throw new JoplinError(sprintf('Revision "%s" is encrypted', rev.id), 'revision_encrypted');
|
|
output.title = this.applyTextPatch(output.title, rev.title_diff);
|
|
output.body = this.applyTextPatch(output.body, rev.body_diff);
|
|
output.metadata = this.applyObjectPatch(output.metadata, rev.metadata_diff);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
static async deleteOldRevisions(ttl) {
|
|
// When deleting old revisions, we need to make sure that the oldest surviving revision
|
|
// is a "merged" one (as opposed to a diff from a now deleted revision). So every time
|
|
// we deleted a revision, we need to find if there's a corresponding surviving revision
|
|
// and modify that revision into a "merged" one.
|
|
|
|
const cutOffDate = Date.now() - ttl;
|
|
const revisions = await this.modelSelectAll('SELECT * FROM revisions WHERE item_updated_time < ? ORDER BY item_updated_time DESC', [cutOffDate]);
|
|
const doneItems = {};
|
|
|
|
for (const rev of revisions) {
|
|
const doneKey = rev.item_type + '_' + rev.item_id;
|
|
if (doneItems[doneKey]) continue;
|
|
|
|
const keptRev = await this.modelSelectOne('SELECT * FROM revisions WHERE item_updated_time >= ? AND item_type = ? AND item_id = ? ORDER BY item_updated_time ASC LIMIT 1', [
|
|
cutOffDate,
|
|
rev.item_type,
|
|
rev.item_id,
|
|
]);
|
|
|
|
try {
|
|
const deleteQueryCondition = 'item_updated_time < ? AND item_id = ?';
|
|
const deleteQueryParams = [cutOffDate, rev.item_id];
|
|
const deleteQuery = { sql: 'DELETE FROM revisions WHERE ' + deleteQueryCondition, params: deleteQueryParams };
|
|
|
|
if (!keptRev) {
|
|
const hasEncrypted = await this.modelSelectOne('SELECT * FROM revisions WHERE encryption_applied = 1 AND ' + deleteQueryCondition, deleteQueryParams);
|
|
if (!!hasEncrypted) throw new JoplinError('One of the revision to be deleted is encrypted', 'revision_encrypted');
|
|
await this.db().transactionExecBatch([deleteQuery]);
|
|
} else {
|
|
// Note: we don't need to check for encrypted rev here because
|
|
// mergeDiff will already throw the revision_encrypted exception
|
|
// if a rev is encrypted.
|
|
const merged = await this.mergeDiffs(keptRev);
|
|
|
|
const queries = [
|
|
deleteQuery,
|
|
{ sql: 'UPDATE revisions SET title_diff = ?, body_diff = ?, metadata_diff = ? WHERE id = ?', params: [
|
|
this.createTextPatch('', merged.title),
|
|
this.createTextPatch('', merged.body),
|
|
this.createObjectPatch({}, merged.metadata),
|
|
keptRev.id,
|
|
] },
|
|
];
|
|
|
|
await this.db().transactionExecBatch(queries);
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'revision_encrypted') {
|
|
this.logger().info('Aborted deletion of old revisions for item ' + rev.item_id + ' because one of the revisions is still encrypted', error);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
doneItems[doneKey] = true;
|
|
}
|
|
}
|
|
|
|
static async revisionExists(itemType, itemId, updatedTime) {
|
|
const existingRev = await Revision.latestRevision(itemType, itemId);
|
|
return existingRev && existingRev.item_updated_time === updatedTime;
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = Revision;
|