Source: diff-sync/engine-adapters/diff-match-patch.js

/* AeroGear JavaScript Library
* https://github.com/aerogear/aerogear-js
* JBoss, Home of Professional Open Source
* Copyright Red Hat, Inc., and individual contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
    The diffMatchPatch adapter.
    @status Experimental
    @constructs AeroGear.DiffSyncEngine.adapters.diffMatchPatch
    @returns {Object} The created adapter
 */
AeroGear.DiffSyncEngine.adapters.diffMatchPatch = function() {
    if ( !( this instanceof AeroGear.DiffSyncEngine.adapters.diffMatchPatch ) ) {
        return new AeroGear.DiffSyncEngine.adapters.diffMatchPatch();
    }

    var stores = {
            docs: [],
            shadows: [],
            backups: [],
            edits: []
        },
        dmp = new diff_match_patch();


    /**
     * Adds a new document to this sync engine.
     *
     * @param doc the document to add.
     */
    this.addDocument = function( doc ) {
        this._saveDocument( JSON.parse( JSON.stringify( doc ) ) );
        this._saveShadow( JSON.parse( JSON.stringify( doc ) ) );
        this._saveShadowBackup( JSON.parse( JSON.stringify( doc ) ), 0 );
    };

    /**
     * Performs the client side of a differential sync.
     * When a client makes an update to it's document, it is first diffed against the shadow
     * document. The result of this is an {@link Edits} instance representing the changes.
     * There might be pending edits that represent edits that have not made it to the server
     * for some reason (for example packet drop). If a pending edit exits the contents (the diffs)
     * of the pending edit will be included in the returned Edits from this method.
     *
     * @param doc the updated document.
     * @returns {object} containing the diffs that between the clientDoc and it's shadow doc.
     */
    this.diff = function( doc ) {
        var diffDoc, patchMsg, docContent, shadowContent, pendingEdits,
            shadow = this._readData( doc.id, "shadows" )[ 0 ];

        if ( typeof doc.content === "string" ) {
            docContent = doc.content;
            shadowContent = shadow.content;
        } else {
            docContent = JSON.stringify( doc.content );
            shadowContent = JSON.stringify( shadow.content );
        }

        patchMsg = {
            msgType: "patch",
            id: doc.id,
            clientId: shadow.clientId,
            edits: [{
                clientVersion: shadow.clientVersion,
                serverVersion: shadow.serverVersion,
                // currently not implemented but we probably need this for checking the client and server shadow are identical be for patching.
                checksum: '',
                diffs: this._asAeroGearDiffs( dmp.diff_main( shadowContent, docContent ) )
            }]
        };

        shadow.clientVersion++;
        shadow.content = doc.content;
        this._saveShadow( JSON.parse( JSON.stringify( shadow ) ) );

        // add any pending edits from the store
        pendingEdits = this._getEdits( doc.id );
        if ( pendingEdits && pendingEdits.length > 0 ) {
            patchMsg.edits = pendingEdits.concat( patchMsg.edits );
        }

        return patchMsg;
    };

    /**
     * Performs the client side patch process.
     *
     * @param patchMsg the patch message that is sent from the server
     *
     * @example:
     * {
     *   "msgType":"patch",
     *   "id":"12345",
     *   "clientId":"3346dff7-aada-4d5f-a3da-c93ff0ffc472",
     *   "edits":[{
     *     "clientVersion":0,
     *     "serverVersion":0,
     *     "checksum":"5f9844b21c298ea1f3ed7bf37f96e42df03395b",
     *     "diffs":[
     *       {"operation":"UNCHANGED","text":"I'm a Je"},
     *       {"operation":"DELETE","text":"di"}]
     *   }]
     * }
    */
    this.patch = function( patchMsg ) {
        // Flow is based on the server side
        // patch the shadow
        var patchedShadow = this.patchShadow( patchMsg );
        // Then patch the document
        this.patchDocument( patchedShadow );
        // then save backup shadow
        this._saveShadowBackup( patchedShadow, patchedShadow.clientVersion );

    };

    this._asAeroGearDiffs = function( diffs ) {
        return diffs.map(function( value ) {
            return {
                operation: this._asAgOperation( value[ 0 ] ),
                text: value[ 1 ]
            };
        }.bind( this ) );
    };

    this._asDiffMatchPathDiffs = function( diffs ) {
        return diffs.map( function ( value ) {
            return [this._asDmpOperation ( value.operation ), value.text];
        }.bind( this ) );
    };

    this._asDmpOperation = function( op ) {
        if ( op === "DELETE" ) {
            return -1;
        } else if ( op === "ADD" ) {
            return 1;
        }
        return 0;
    };

    this._asAgOperation = function( op ) {
        if ( op === -1 ) {
            return "DELETE";
        } else if ( op === 1 ) {
            return "ADD";
        }
        return "UNCHANGED";
    };

    this.patchShadow = function( patchMsg ) {
        // First get the shadow document for this doc.id and clientId
        var i, patched, edit,
            shadow = this.getShadow( patchMsg.id ),
            edits = patchMsg.edits;
        //Iterate over the edits of the doc
        for ( i = 0; i < edits.length; i++ ) {
            edit = edits[i];

            //Check for dropped packets?
            // edit.clientVersion < shadow.ClientVersion
            if( edit.clientVersion < shadow.clientVersion && !this._isSeeded( edit ) ) {
                // Dropped packet?  // restore from back
                shadow = this._restoreBackup( shadow, edit );
                continue;
            }

            //check if we already have this one
            // IF SO discard the edit
            // edit.serverVersion < shadow.ServerVesion
            if( edit.serverVersion < shadow.serverVersion ) {
                // discard edit
                this._removeEdit( patchMsg.id, edit );
                continue;
            }

            //make sure the versions match
            if( (edit.serverVersion === shadow.serverVersion && edit.clientVersion === shadow.clientVersion) || this._isSeeded( edit )) {
                // Good ,  Patch the shadow
                this.applyEditsToShadow( edit, shadow );
                if ( this._isSeeded( edit ) ) {
                    shadow.clientVersion = 0;
                } else if ( edit.clientVersion >= 0 ) {
                    shadow.serverVersion++;
                }
                this._saveShadow( shadow );
                this._removeEdit( patchMsg.id, edit );
            }
        }

        //console.log('patched:', shadow);
        return shadow;
    };

    // A seeded patch is when all clients start with a base document. They all send this base version as
    // part of the addDocument call. The server will respond with a patchMsg enabling the client to
    // patch it's local version to get the latest updates. Such an edit is identified by a clientVersion
    // set to '-1'.
    this._isSeeded = function( edit ) {
        return edit.clientVersion === -1;
    };

    this.applyEditsToShadow = function ( edits, shadow ) {
        var doc, diffs, patches, patchResult;

        doc = typeof shadow.content === 'string' ? shadow.content : JSON.stringify( shadow.content );
        diffs = this._asDiffMatchPathDiffs( edits.diffs );
        patches = dmp.patch_make( doc, diffs );

        patchResult = dmp.patch_apply( patches, doc );
        try {
            shadow.content = JSON.parse( patchResult[ 0 ] );
        } catch( e ) {
            shadow.content = patchResult[ 0 ];
        }
        return shadow;
    };

    this.patchDocument = function( shadow ) {
        var doc, diffs, patches, patchApplied;

        // first get the document based on the shadowdocs ID
        doc = this.getDocument( shadow.id );

        // diff the doc and shadow and patch that shizzel
        diffs = dmp.diff_main( JSON.stringify( doc.content ), JSON.stringify( shadow.content ) );

        patches = dmp.patch_make( JSON.stringify( doc.content ), diffs );

        patchApplied = dmp.patch_apply( patches, JSON.stringify( doc.content ) );

        //save the newly patched document
        doc.content = JSON.parse( patchApplied[ 0 ] );

        this._saveDocument( doc );

        //return the applied patch?
        //console.log('patches: ', patchApplied);
        return patchApplied;
    };

    this._saveData = function( data, type ) {
        data = Array.isArray( data ) ? data : [ data ];

        stores[ type ] = data;
    };

    this._readData = function( id, type ) {
        return stores[ type ].filter( function( doc ) {
            return doc.id === id;
        });
    };

    this._saveDocument = function( doc ) {
        this._saveData( doc, "docs" );
        return doc;
    };

    this._saveShadow = function( doc ) {
        var shadow = {
            id: doc.id,
            serverVersion: doc.serverVersion || 0,
            clientId: doc.clientId,
            clientVersion: doc.clientVersion || 0,
            content: doc.content
        };

        this._saveData( shadow, "shadows" );
        return shadow;
    };

    this._saveShadowBackup = function( shadow, clientVersion ) {
        var backup = { id: shadow.id, clientVersion: clientVersion, content: shadow.content };
        this._saveData( backup, "backups" );
        return backup;
    };

    this.getDocument = function( id ) {
        return this._readData( id, "docs" )[ 0 ];
    };

    this.getShadow = function( id ) {
        return this._readData( id, "shadows" )[ 0 ];
    };

    this.getBackup = function( id ) {
        return this._readData( id, "backups" )[ 0 ];
    };

    this._saveEdits = function( patchMsg ) {
        var record = { id: patchMsg.id, clientId: patchMsg.clientId, edits: patchMsg.edits};
        this._saveData( record, "edits" );
        return record;
    };

    this._getEdits = function( id ) {
        var patchMessages = this._readData( id, "edits" );

        return patchMessages.length ? patchMessages.edits : [];
    };

    this._removeEdit = function( documentId,  edit ) {
        var pendingEdits = this._readData( documentId, "edits" ), i, j, pendingEdit;
        for ( i = 0; i < pendingEdits.length; i++ ) {
            pendingEdit = pendingEdits[i];
            for ( j = 0; j < pendingEdit.edits.length; j++) {
                if ( pendingEdit.edits[j].serverVersion === edit.serverVersion && pendingEdit.edits[j].clientVersion <= edit.clientVersion) {
                    pendingEdit.edits.splice(i, 1);
                    break;
                }
            }
        }
    };

    this._removeEdits = function( documentId ) {
        var edits = this._readData( documentId, "edits" ), i;
        edits.splice(0, edits.length);
    };

    this._restoreBackup = function( shadow, edit) {
        var patchedShadow, restoredBackup,
            backup = this.getBackup( shadow.id );

        if ( edit.clientVersion === backup.clientVersion ) {

            restoredBackup = {
                id: backup.id,
                clientVersion: backup.clientVersion,
                content: backup.content
            };

            patchedShadow = this.applyEditsToShadow( edit, restoredBackup );
            restoredBackup.serverVersion++;
            this._removeEdits( shadow.id );

            return this._saveShadow( patchedShadow );
        } else {
            throw "Edit's clientVersion '" + edit.clientVersion + "' does not match the backups clientVersion '" + backup.clientVersion + "'";
        }
    };
};