2019-05-06 22:35:29 +02:00
const BaseModel = require ( 'lib/BaseModel.js' ) ;
const BaseItem = require ( 'lib/models/BaseItem.js' ) ;
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 : [ ] ,
} ;
2020-03-14 01:46:14 +02:00
for ( const k in newObject ) {
2019-05-06 22:35:29 +02:00
if ( ! newObject . hasOwnProperty ( k ) ) continue ;
if ( oldObject [ k ] === newObject [ k ] ) continue ;
output . new [ k ] = newObject [ k ] ;
}
2020-03-14 01:46:14 +02:00
for ( const k in oldObject ) {
2019-05-06 22:35:29 +02:00
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 ) ;
2019-07-29 15:43:53 +02:00
2020-03-14 01:46:14 +02:00
for ( const k in patch . new ) {
2019-05-06 22:35:29 +02:00
output [ k ] = patch . new [ k ] ;
}
for ( let i = 0 ; i < patch . deleted . length ; i ++ ) {
delete output [ patch . deleted [ i ] ] ;
}
return output ;
}
2019-05-24 18:31:18 +02:00
static patchStats ( patch ) {
if ( typeof patch === 'object' ) throw new Error ( 'Not implemented' ) ;
2020-05-20 18:16:43 +02:00
const countChars = ( diffLine ) => {
2019-05-24 18:31:18 +02:00
return unescape ( diffLine ) . length - 1 ;
2019-07-29 15:43:53 +02:00
} ;
2019-05-24 18:31:18 +02:00
const lines = patch . split ( '\n' ) ;
let added = 0 ;
let removed = 0 ;
for ( const line of lines ) {
if ( line . indexOf ( '-' ) === 0 ) {
removed += countChars ( line ) ;
continue ;
}
if ( line . indexOf ( '+' ) === 0 ) {
added += countChars ( line ) ;
continue ;
}
}
return {
added : added ,
removed : removed ,
} ;
}
static revisionPatchStatsText ( rev ) {
const titleStats = this . patchStats ( rev . title _diff ) ;
const bodyStats = this . patchStats ( rev . body _diff ) ;
const total = {
added : titleStats . added + bodyStats . added ,
removed : titleStats . removed + bodyStats . removed ,
} ;
const output = [ ] ;
2019-09-19 23:51:18 +02:00
if ( total . removed ) output . push ( ` - ${ total . removed } ` ) ;
output . push ( ` + ${ total . added } ` ) ;
2019-05-24 18:31:18 +02:00
return output . join ( ', ' ) ;
}
2019-05-06 22:35:29 +02:00
static async countRevisions ( itemType , itemId ) {
2019-07-29 15:43:53 +02:00
const r = await this . db ( ) . selectOne ( 'SELECT count(*) as total FROM revisions WHERE item_type = ? AND item_id = ?' , [ itemType , itemId ] ) ;
2019-05-06 22:35:29 +02:00
return r ? r . total : 0 ;
}
static latestRevision ( itemType , itemId ) {
2019-07-29 15:43:53 +02:00
return this . modelSelectOne ( 'SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time DESC LIMIT 1' , [ itemType , itemId ] ) ;
2019-05-06 22:35:29 +02:00
}
static allByType ( itemType , itemId ) {
2019-07-29 15:43:53 +02:00
return this . modelSelectAll ( 'SELECT * FROM revisions WHERE item_type = ? AND item_id = ? ORDER BY item_updated_time ASC' , [ itemType , itemId ] ) ;
2019-05-06 22:35:29 +02:00
}
static async itemsWithRevisions ( itemType , itemIds ) {
if ( ! itemIds . length ) return [ ] ;
2019-09-19 23:51:18 +02:00
const rows = await this . db ( ) . selectAll ( ` SELECT distinct item_id FROM revisions WHERE item_type = ? AND item_id IN (" ${ itemIds . join ( '","' ) } ") ` , [ itemType ] ) ;
2019-05-06 22:35:29 +02:00
2020-05-20 18:16:43 +02:00
return rows . map ( ( r ) => r . item _id ) ;
2019-07-29 15:43:53 +02:00
}
2019-05-06 22:35:29 +02:00
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 ;
}
}
2019-09-19 23:51:18 +02:00
if ( targetIndex < 0 ) throw new Error ( ` Could not find revision: ${ revision . id } ` ) ;
2019-05-06 22:35:29 +02:00
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 ) {
2019-07-29 15:43:53 +02:00
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 ] ) ;
2019-05-06 22:35:29 +02:00
} 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 ] ;
2019-07-29 15:43:53 +02:00
if ( rev . encryption _applied ) throw new JoplinError ( sprintf ( 'Revision "%s" is encrypted' , rev . id ) , 'revision_encrypted' ) ;
2019-05-06 22:35:29 +02:00
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 ) {
2019-09-19 23:51:18 +02:00
const doneKey = ` ${ rev . item _type } _ ${ rev . item _id } ` ;
2019-05-06 22:35:29 +02:00
if ( doneItems [ doneKey ] ) continue ;
2019-07-29 15:43:53 +02:00
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 ] ) ;
2019-05-06 22:35:29 +02:00
try {
const deleteQueryCondition = 'item_updated_time < ? AND item_id = ?' ;
const deleteQueryParams = [ cutOffDate , rev . item _id ] ;
2019-09-19 23:51:18 +02:00
const deleteQuery = { sql : ` DELETE FROM revisions WHERE ${ deleteQueryCondition } ` , params : deleteQueryParams } ;
2019-05-06 22:35:29 +02:00
if ( ! keptRev ) {
2019-09-19 23:51:18 +02:00
const hasEncrypted = await this . modelSelectOne ( ` SELECT * FROM revisions WHERE encryption_applied = 1 AND ${ deleteQueryCondition } ` , deleteQueryParams ) ;
2019-07-29 15:43:53 +02:00
if ( hasEncrypted ) throw new JoplinError ( 'One of the revision to be deleted is encrypted' , 'revision_encrypted' ) ;
2019-05-06 22:35:29 +02:00
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 ) ;
2019-07-29 15:43:53 +02:00
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 ] } ] ;
2019-05-06 22:35:29 +02:00
await this . db ( ) . transactionExecBatch ( queries ) ;
}
} catch ( error ) {
if ( error . code === 'revision_encrypted' ) {
2019-09-19 23:51:18 +02:00
this . logger ( ) . info ( ` Aborted deletion of old revisions for item ${ rev . item _id } because one of the revisions is still encrypted ` , error ) ;
2019-05-06 22:35:29 +02:00
} 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 ;