1/**
2 * Copyright (c) 2006-2018, JGraph Ltd
3 * Copyright (c) 2006-2018, Gaudenz Alder
4 *
5 * Realtime collaboration for any file.
6 */
7DrawioFileSync = function(file)
8{
9	mxEventSource.call(this);
10
11	this.lastActivity = new Date();
12	this.clientId = Editor.guid();
13	this.ui = file.ui;
14	this.file = file;
15
16    // Listens to online state changes
17	this.onlineListener = mxUtils.bind(this, function()
18	{
19		this.updateOnlineState();
20
21		if (this.isConnected())
22		{
23			this.fileChangedNotify();
24		}
25	});
26
27	mxEvent.addListener(window, 'online', this.onlineListener);
28
29    // Listens to visible state changes
30	this.visibleListener = mxUtils.bind(this, function()
31	{
32		if (document.visibilityState == 'hidden')
33		{
34			if (this.isConnected())
35			{
36				this.stop();
37			}
38		}
39		else
40		{
41			this.start();
42		}
43	});
44
45	mxEvent.addListener(document, 'visibilitychange', this.visibleListener);
46
47    // Listens to visible state changes
48	this.activityListener = mxUtils.bind(this, function(evt)
49	{
50		this.lastActivity = new Date();
51		this.start();
52	});
53
54	mxEvent.addListener(document, (mxClient.IS_POINTER) ? 'pointermove' : 'mousemove', this.activityListener);
55	mxEvent.addListener(document, 'keypress', this.activityListener);
56	mxEvent.addListener(window, 'focus', this.activityListener);
57
58	if (!mxClient.IS_POINTER && mxClient.IS_TOUCH)
59	{
60		mxEvent.addListener(document, 'touchstart', this.activityListener);
61		mxEvent.addListener(document, 'touchmove', this.activityListener);
62	}
63
64	// Listens to errors in the pusher API
65	this.pusherErrorListener = mxUtils.bind(this, function(err)
66	{
67		if (err.error != null && err.error.data != null &&
68			err.error.data.code === 4004)
69		{
70			EditorUi.logError('Error: Pusher Limit', null, this.file.getId());
71		}
72	});
73
74    // Listens to connection state changes
75	this.connectionListener = mxUtils.bind(this, function()
76	{
77		this.updateOnlineState();
78		this.updateStatus();
79
80		if (this.isConnected())
81		{
82			if (!this.announced)
83			{
84				var user = this.file.getCurrentUser();
85				var join = {a: 'join'};
86
87				if (user != null)
88				{
89					join.name = encodeURIComponent(user.displayName);
90					join.uid = user.id;
91				}
92
93				mxUtils.post(EditorUi.cacheUrl, this.getIdParameters() +
94					'&msg=' + encodeURIComponent(this.objectToString(
95					this.createMessage(join))));
96				this.file.stats.msgSent++;
97				this.announced = true;
98			}
99			else
100			{
101				// Catchup on any lost edits
102				this.fileChangedNotify();
103			}
104		}
105	});
106
107	// Listens to messages
108	this.changeListener = mxUtils.bind(this, function(data)
109	{
110		this.file.stats.msgReceived++;
111		this.lastActivity = new Date();
112
113		if (this.enabled && !this.file.inConflictState &&
114			!this.file.redirectDialogShowing)
115		{
116			try
117			{
118				var msg = this.stringToObject(data);
119
120				if (msg != null)
121				{
122					EditorUi.debug('Sync.message', [this], msg, data.length, 'bytes');
123
124					// Handles protocol mismatch
125					if (msg.v > DrawioFileSync.PROTOCOL)
126					{
127						this.file.redirectToNewApp(mxUtils.bind(this, function()
128						{
129							// Callback adds cancel option
130						}));
131					}
132					else if (msg.v === DrawioFileSync.PROTOCOL && msg.d != null)
133					{
134						this.handleMessageData(msg.d);
135					}
136				}
137			}
138			catch (e)
139			{
140				// Checks if file was changed
141				if (this.isConnected())
142				{
143					this.fileChangedNotify();
144				}
145
146				// NOTE: Probably UTF16 in username for join/leave message causing this
147//				var len = (data != null) ? data.length : 'null';
148//
149//				EditorUi.logError('Protocol Error ' + e.message,
150//					null, 'data_' + len + '_file_' + this.file.getHash() +
151//					'_client_' + this.clientId);
152//
153//				if (window.console != null)
154//				{
155//					console.log(e);
156//				}
157			}
158		}
159	});
160};
161
162/**
163 * Protocol version to be added to all communcations and diffs to check
164 * if a client is out of date and force a refresh. Note that this must
165 * be incremented if new messages are added or the format is changed.
166 * This must be numeric to compare older vs newer protocol versions.
167 */
168DrawioFileSync.PROTOCOL = 6;
169
170//Extends mxEventSource
171mxUtils.extend(DrawioFileSync, mxEventSource);
172
173/**
174 * Maximum size in bytes for cache values.
175 */
176DrawioFileSync.prototype.maxCacheEntrySize = 1000000;
177
178/**
179 * Specifies if notifications should be sent and received for changes.
180 */
181DrawioFileSync.prototype.enabled = true;
182
183/**
184 * True if a change event is fired for a remote change.
185 */
186DrawioFileSync.prototype.updateStatusInterval = 10000;
187
188/**
189 * Holds the channel ID for sending and receiving change notifications.
190 */
191DrawioFileSync.prototype.channelId = null;
192
193/**
194 * Holds the channel ID for sending and receiving change notifications.
195 */
196DrawioFileSync.prototype.channel = null;
197
198/**
199 * Specifies if descriptor change events should be ignored.
200 */
201DrawioFileSync.prototype.catchupRetryCount = 0;
202
203/**
204 * Specifies if descriptor change events should be ignored.
205 */
206DrawioFileSync.prototype.maxCatchupRetries = 15;
207
208/**
209 * Specifies if descriptor change events should be ignored.
210 */
211DrawioFileSync.prototype.maxCacheReadyRetries = 1;
212
213/**
214 * Specifies if descriptor change events should be ignored.
215 */
216DrawioFileSync.prototype.cacheReadyDelay = 700;
217
218/**
219 * Specifies if descriptor change events should be ignored.
220 */
221DrawioFileSync.prototype.maxOptimisticReloadRetries = 6;
222
223/**
224 * Inactivity timeout is 30 minutes.
225 */
226DrawioFileSync.prototype.inactivityTimeoutSeconds = 1800;
227
228/**
229 * Specifies if notifications should be sent and received for changes.
230 */
231DrawioFileSync.prototype.lastActivity = null;
232
233/**
234 * Adds all listeners.
235 */
236DrawioFileSync.prototype.start = function()
237{
238	if (this.channelId == null)
239	{
240		this.channelId = this.file.getChannelId();
241	}
242
243	if (this.key == null)
244	{
245		this.key = this.file.getChannelKey();
246	}
247
248	if (this.pusher == null && this.channelId != null &&
249		document.visibilityState != 'hidden')
250	{
251		this.pusher = this.ui.getPusher();
252
253		if (this.pusher != null)
254		{
255			try
256			{
257				// Error listener must be installed before trying to create channel
258				if (this.pusher.connection != null)
259				{
260					this.pusher.connection.bind('error', this.pusherErrorListener);
261				}
262			}
263			catch (e)
264			{
265				// ignore
266			}
267
268			try
269			{
270				this.pusher.connect();
271				this.channel = this.pusher.subscribe(this.channelId);
272				EditorUi.debug('Sync.start', [this, 'v' + DrawioFileSync.PROTOCOL], 'rev', this.file.getCurrentRevisionId());
273			}
274			catch (e)
275			{
276				// ignore
277			}
278
279			this.installListeners();
280		}
281
282		window.setTimeout(mxUtils.bind(this, function()
283		{
284			this.lastModified = this.file.getLastModifiedDate();
285			this.lastActivity = new Date();
286			this.resetUpdateStatusThread();
287			this.updateOnlineState();
288			this.updateStatus();
289		}, 0));
290	}
291};
292
293/**
294 * Draw function for the collaborator list.
295 */
296DrawioFileSync.prototype.isConnected = function()
297{
298	if (this.pusher != null && this.pusher.connection != null)
299	{
300		return this.pusher.connection.state == 'connected';
301	}
302	else
303	{
304		return false;
305	}
306};
307
308/**
309 * Draw function for the collaborator list.
310 */
311DrawioFileSync.prototype.updateOnlineState = function()
312{
313	//For RT in embeded mode, we don't need this icon
314	if (urlParams['embedRT'] == '1')
315	{
316		return;
317	}
318
319	var addClickHandler = mxUtils.bind(this, function(elt)
320	{
321		mxEvent.addListener(elt, 'click', mxUtils.bind(this, function(evt)
322		{
323			this.enabled = !this.enabled;
324			this.ui.updateButtonContainer();
325			this.resetUpdateStatusThread();
326			this.updateOnlineState();
327			this.updateStatus();
328
329			if (!this.file.inConflictState && this.enabled)
330			{
331				this.fileChangedNotify();
332			}
333		}));
334	});
335
336	if (uiTheme == 'min' && this.ui.buttonContainer != null && urlParams['sketch'] != '1')
337	{
338		if (this.collaboratorsElement == null)
339		{
340			var elt = document.createElement('a');
341    		elt.className = 'geToolbarButton';
342			elt.style.cssText = 'display:inline-block;position:relative;box-sizing:border-box;margin-right:4px;cursor:pointer;float:left;';
343    		elt.style.backgroundPosition = 'center center';
344        	elt.style.backgroundRepeat = 'no-repeat';
345        	elt.style.backgroundSize = '24px 24px';
346        	elt.style.height = '24px';
347        	elt.style.width = '24px';
348
349        	addClickHandler(elt);
350        	this.ui.buttonContainer.appendChild(elt);
351        	this.collaboratorsElement = elt;
352		}
353	}
354	else if (this.ui.toolbarContainer != null)
355	{
356		if (this.collaboratorsElement == null)
357		{
358			var elt = document.createElement('a');
359			elt.className = 'geButton';
360			elt.style.position = 'absolute';
361			elt.style.display = 'inline-block';
362			elt.style.verticalAlign = 'bottom';
363			elt.style.color = '#666';
364			elt.style.top = '6px';
365			elt.style.right = (uiTheme != 'atlas') ?  '70px' : '50px';
366			elt.style.padding = '2px';
367			elt.style.fontSize = '8pt';
368			elt.style.verticalAlign = 'middle';
369			elt.style.textDecoration = 'none';
370	    	elt.style.backgroundPosition = 'center center';
371	    	elt.style.backgroundRepeat = 'no-repeat';
372	    	elt.style.backgroundSize = '16px 16px';
373			elt.style.width = '16px';
374			elt.style.height = '16px';
375	    	mxUtils.setOpacity(elt, 60);
376
377			if (uiTheme == 'dark')
378			{
379				elt.style.filter = 'invert(100%)';
380			}
381
382			// Prevents focus
383		    mxEvent.addListener(elt, (mxClient.IS_POINTER) ? 'pointerdown' : 'mousedown',
384	        	mxUtils.bind(this, function(evt)
385	    	{
386				evt.preventDefault();
387			}));
388
389			addClickHandler(elt);
390			this.ui.toolbarContainer.appendChild(elt);
391			this.collaboratorsElement = elt;
392		}
393	}
394
395	if (this.collaboratorsElement != null)
396	{
397		var status = '';
398
399		if (!this.enabled)
400		{
401			status = mxResources.get('disconnected');
402		}
403		else if (this.file.invalidChecksum)
404		{
405			status = mxResources.get('error') + ': ' + mxResources.get('checksum');
406		}
407		else if (this.ui.isOffline(true) || !this.isConnected())
408		{
409			status = mxResources.get('offline');
410		}
411		else
412		{
413			status = mxResources.get('online');
414		}
415
416		this.collaboratorsElement.setAttribute('title', status);
417		this.collaboratorsElement.style.backgroundImage = 'url(' + ((!this.enabled) ? Editor.syncDisabledImage :
418			((!this.ui.isOffline(true) && this.isConnected() && !this.file.invalidChecksum) ?
419			Editor.syncImage : Editor.syncProblemImage)) + ')';
420	}
421};
422
423
424/**
425 * Updates the status bar with the latest change.
426 */
427DrawioFileSync.prototype.updateStatus = function()
428{
429	if (this.isConnected() && this.lastActivity != null &&
430		(new Date().getTime() - this.lastActivity.getTime()) / 1000 >
431		this.inactivityTimeoutSeconds)
432	{
433		this.stop();
434	}
435
436	if (!this.file.isModified() && !this.file.inConflictState &&
437		this.file.autosaveThread == null && !this.file.savingFile &&
438		!this.file.redirectDialogShowing)
439	{
440		if (this.enabled && this.ui.statusContainer != null)
441		{
442			// LATER: Write out modified date for more than 2 weeks ago
443			var str = this.ui.timeSince(new Date(this.lastModified));
444
445			if (str == null)
446			{
447				str = mxResources.get('lessThanAMinute');
448			}
449
450			var history = this.file.isRevisionHistorySupported();
451
452			// Consumed and displays last message
453			var msg = this.lastMessage;
454			this.lastMessage = null;
455
456			if (msg != null && msg.length > 40)
457			{
458				msg = msg.substring(0, 40) + '...';
459			}
460
461			var label = mxResources.get('lastChange', [str]);
462
463			this.ui.editor.setStatus('<div title="'+ mxUtils.htmlEntities(label) + '">' + mxUtils.htmlEntities(label) + '</div>' +
464				(this.file.isEditable() ? '' : '<div class="geStatusAlert">' + mxUtils.htmlEntities(mxResources.get('readOnly')) + '</div>') +
465				(this.isConnected() ? '' : '<div class="geStatusAlert">' + mxUtils.htmlEntities(mxResources.get('disconnected')) + '</div>') +
466				((msg != null) ? ' <span title="' + mxUtils.htmlEntities(msg) + '">(' + mxUtils.htmlEntities(msg) + ')</span>' : ''));
467			var links = this.ui.statusContainer.getElementsByTagName('div');
468
469			if (links.length > 0 && history)
470			{
471				links[0].style.display = 'inline-block';
472
473				if (history)
474				{
475					links[0].style.cursor = 'pointer';
476					links[0].style.textDecoration = 'underline';
477
478					mxEvent.addListener(links[0], 'click', mxUtils.bind(this, function()
479					{
480						this.ui.actions.get('revisionHistory').funct();
481					}));
482				}
483			}
484
485			// Fades in/out last message
486			var spans = this.ui.statusContainer.getElementsByTagName('span');
487
488			if (spans.length > 0)
489			{
490				var temp = spans[0];
491				temp.style.opacity = '0';
492				mxUtils.setPrefixedStyle(temp.style, 'transition', 'all 0.2s ease');
493
494				window.setTimeout(mxUtils.bind(this, function()
495				{
496					mxUtils.setOpacity(temp, 100);
497					mxUtils.setPrefixedStyle(temp.style, 'transition', 'all 1s ease');
498
499					window.setTimeout(mxUtils.bind(this, function()
500					{
501						mxUtils.setOpacity(temp, 0);
502					}), this.updateStatusInterval / 2);
503				}), 0);
504			}
505
506			this.resetUpdateStatusThread();
507		}
508		else
509		{
510			this.file.addAllSavedStatus();
511		}
512	}
513};
514
515/**
516 * Resets the thread to update the status.
517 */
518DrawioFileSync.prototype.resetUpdateStatusThread = function()
519{
520	if (this.updateStatusThread != null)
521	{
522		window.clearInterval(this.updateStatusThread);
523	}
524
525	if (this.channel != null)
526	{
527		this.updateStatusThread = window.setInterval(mxUtils.bind(this, function()
528		{
529			this.updateStatus();
530		}), this.updateStatusInterval);
531	}
532};
533
534/**
535 * Installs all required listeners for syncing the current file.
536 */
537DrawioFileSync.prototype.installListeners = function()
538{
539	if (this.pusher != null && this.pusher.connection != null)
540	{
541		this.pusher.connection.bind('state_change', this.connectionListener);
542	}
543
544	if (this.channel != null)
545    {
546    	this.channel.bind('changed', this.changeListener);
547    }
548};
549
550/**
551 * Adds the listener for automatically saving the diagram for local changes.
552 */
553DrawioFileSync.prototype.handleMessageData = function(data)
554{
555	if (data.a == 'desc')
556	{
557		if (!this.file.savingFile)
558		{
559			this.reloadDescriptor();
560		}
561	}
562	else if (data.a == 'join' || data.a == 'leave')
563	{
564		if (data.a == 'join')
565		{
566			this.file.stats.joined++;
567		}
568
569		if (data.name != null)
570		{
571			this.lastMessage = mxResources.get((data.a == 'join') ?
572				'userJoined' : 'userLeft', [decodeURIComponent(data.name)]);
573			this.resetUpdateStatusThread();
574			this.updateStatus();
575		}
576	}
577	else if (data.m != null)
578	{
579		var mod = new Date(data.m);
580
581		// Ignores obsolete messages
582		if (this.lastMessageModified == null || this.lastMessageModified < mod)
583		{
584			this.lastMessageModified = mod;
585			this.fileChangedNotify(data);
586		}
587	}
588};
589
590/**
591 * Adds the listener for automatically saving the diagram for local changes.
592 */
593DrawioFileSync.prototype.isValidState = function()
594{
595	return this.ui.getCurrentFile() == this.file &&
596		this.file.sync == this && !this.file.invalidChecksum &&
597		!this.file.redirectDialogShowing;
598};
599
600/**
601 * Adds the listener for automatically saving the diagram for local changes.
602 */
603DrawioFileSync.prototype.optimisticSync = function(retryCount)
604{
605	if (this.reloadThread == null)
606	{
607		retryCount = (retryCount != null) ? retryCount : 0;
608
609		if (retryCount < this.maxOptimisticReloadRetries)
610		{
611			this.reloadThread = window.setTimeout(mxUtils.bind(this, function()
612			{
613				this.file.getLatestVersion(mxUtils.bind(this, function(latestFile)
614				{
615					this.reloadThread = null;
616
617					if (latestFile != null)
618					{
619						var etag = latestFile.getCurrentRevisionId();
620						var current = this.file.getCurrentRevisionId();
621
622						// Retries if the file has not changed
623						if (current == etag)
624						{
625							this.optimisticSync(retryCount + 1);
626						}
627						else
628						{
629							this.file.mergeFile(latestFile, mxUtils.bind(this, function()
630							{
631								this.lastModified = this.file.getLastModifiedDate();
632								this.updateStatus();
633							}));
634						}
635					}
636				}), mxUtils.bind(this, function()
637				{
638					this.reloadThread = null;
639				}));
640			}), (retryCount + 1) * this.file.optimisticSyncDelay);
641		}
642
643		if (urlParams['test'] == '1')
644		{
645			EditorUi.debug('Sync.optimisticSync', [this], 'retryCount', retryCount);
646		}
647	}
648};
649
650/**
651 * Adds the listener for automatically saving the diagram for local changes.
652 */
653DrawioFileSync.prototype.fileChangedNotify = function(data)
654{
655	if (this.isValidState())
656	{
657		if (this.file.savingFile)
658		{
659			this.remoteFileChanged = true;
660		}
661		else
662		{
663			if (data != null && data.type == 'optimistic')
664			{
665				this.optimisticSync();
666			}
667			else
668			{
669				// It's possible that a request never returns so override
670				// existing requests and abort them when they are active
671				var thread = this.fileChanged(mxUtils.bind(this, function(err)
672				{
673					this.updateStatus();
674				}),
675					mxUtils.bind(this, function(err)
676				{
677					this.file.handleFileError(err);
678				}), mxUtils.bind(this, function()
679				{
680					return !this.file.savingFile && this.notifyThread != thread;
681				}), true);
682			}
683		}
684	}
685};
686
687/**
688 * Adds the listener for automatically saving the diagram for local changes.
689 */
690DrawioFileSync.prototype.fileChanged = function(success, error, abort, lazy)
691{
692	var thread = window.setTimeout(mxUtils.bind(this, function()
693	{
694		if (abort == null || !abort())
695		{
696			if (!this.isValidState())
697			{
698				if (error != null)
699				{
700					error();
701				}
702			}
703			else
704			{
705				this.file.loadPatchDescriptor(mxUtils.bind(this, function(desc)
706				{
707					if (abort == null || !abort())
708					{
709						if (!this.isValidState())
710						{
711							if (error != null)
712							{
713								error();
714							}
715						}
716						else
717						{
718							this.catchup(desc, success, error, abort);
719						}
720					}
721				}), error);
722			}
723		}
724	}), (lazy) ? this.cacheReadyDelay : 0);
725
726	this.notifyThread = thread;
727
728	return thread;
729};
730
731/**
732 * Adds the listener for automatically saving the diagram for local changes.
733 */
734DrawioFileSync.prototype.reloadDescriptor = function()
735{
736	this.file.loadDescriptor(mxUtils.bind(this, function(desc)
737	{
738		if (desc != null)
739		{
740			// Forces data to be updated
741			this.file.setDescriptorRevisionId(desc, this.file.getCurrentRevisionId());
742			this.updateDescriptor(desc);
743			this.fileChangedNotify();
744		}
745		else
746		{
747			this.file.inConflictState = true;
748			this.file.handleFileError();
749		}
750	}), mxUtils.bind(this, function(err)
751	{
752		this.file.inConflictState = true;
753		this.file.handleFileError(err);
754	}));
755};
756
757/**
758 * Adds the listener for automatically saving the diagram for local changes.
759 */
760DrawioFileSync.prototype.updateDescriptor = function(desc)
761{
762	this.file.setDescriptor(desc);
763	this.file.descriptorChanged();
764	this.start();
765};
766
767DrawioFileSync.prototype.p2pCatchup = function(data, from, to, id, desc, success, error, abort)
768{
769	if (desc != null && (abort == null || !abort()))
770	{
771		var etag = this.file.getDescriptorRevisionId(desc);
772		var current = this.file.getCurrentRevisionId();
773
774		if (!this.isValidState())
775		{
776			if (error != null)
777			{
778				error();
779			}
780		}
781		else
782		{
783			var secret = this.file.getDescriptorSecret(desc);
784
785			if (abort == null || !abort())
786			{
787				this.file.stats.bytesReceived += data.length;
788				var checksum = null;
789				var temp = [];
790
791				try
792				{
793					var result = [data];
794
795					if (result != null && result.length > 0)
796					{
797						for (var i = 0; i < result.length; i++)
798						{
799							var value = this.stringToObject(result[i]);
800
801							if (value.v > DrawioFileSync.PROTOCOL)
802							{
803								failed = true;
804								temp = [];
805								break;
806							}
807							else if (value.v === DrawioFileSync.PROTOCOL &&
808								value.d != null)
809							{
810								checksum = value.d.checksum;
811								temp.push(value.d.patch);
812							}
813							else
814							{
815								failed = true;
816								temp = [];
817								break;
818							}
819						}
820					}
821				}
822				catch (e)
823				{
824					temp = [];
825
826					if (window.console != null && urlParams['test'] == '1')
827					{
828						console.log(e);
829					}
830				}
831
832				try
833				{
834					if (temp.length > 0)
835					{
836						this.file.stats.cacheHits++;
837						this.merge(temp, checksum, desc, success, error, abort);
838					}
839					else
840					{
841						this.file.stats.cacheFail++;
842						this.reload(success, error, abort);
843					}
844				}
845				catch (e)
846				{
847					if (error != null)
848					{
849						error(e);
850					}
851				}
852			}
853		}
854	}
855};
856
857/**
858 * Adds the listener for automatically saving the diagram for local changes.
859 */
860DrawioFileSync.prototype.catchup = function(desc, success, error, abort)
861{
862	if (desc != null && (abort == null || !abort()))
863	{
864		var etag = this.file.getDescriptorRevisionId(desc);
865		var current = this.file.getCurrentRevisionId();
866
867		if (current == etag)
868		{
869			this.file.patchDescriptor(this.file.getDescriptor(), desc);
870
871			if (success != null)
872			{
873				success();
874			}
875		}
876		else if (!this.isValidState())
877		{
878			if (error != null)
879			{
880				error();
881			}
882		}
883		else
884		{
885			var secret = this.file.getDescriptorSecret(desc);
886
887			if (secret == null || urlParams['lockdown'] == '1')
888			{
889				this.reload(success, error, abort);
890			}
891			else
892			{
893				// Cache entry may not have been uploaded to cache before new
894				// etag is visible to client so retry once after cache miss
895				var cacheReadyRetryCount = 0;
896				var failed = false;
897
898				var doCatchup = mxUtils.bind(this, function()
899				{
900					if (abort == null || !abort())
901					{
902						// Ignores patch if shadow has changed
903						if (current != this.file.getCurrentRevisionId())
904						{
905							if (success != null)
906							{
907								success();
908							}
909						}
910						else if (!this.isValidState())
911						{
912							if (error != null)
913							{
914								error();
915							}
916						}
917						else
918						{
919							var acceptResponse = true;
920
921							var timeoutThread = window.setTimeout(mxUtils.bind(this, function()
922							{
923								acceptResponse = false;
924								this.reload(success, error, abort);
925							}), this.ui.timeout);
926
927							mxUtils.get(EditorUi.cacheUrl + '?id=' + encodeURIComponent(this.channelId) +
928								'&from=' + encodeURIComponent(current) + '&to=' + encodeURIComponent(etag) +
929								((secret != null) ? '&secret=' + encodeURIComponent(secret) : ''),
930								mxUtils.bind(this, function(req)
931							{
932								this.file.stats.bytesReceived += req.getText().length;
933								window.clearTimeout(timeoutThread);
934
935								if (acceptResponse && (abort == null || !abort()))
936								{
937									// Ignores patch if shadow has changed
938									if (current != this.file.getCurrentRevisionId())
939									{
940										if (success != null)
941										{
942											success();
943										}
944									}
945									else if (!this.isValidState())
946									{
947										if (error != null)
948										{
949											error();
950										}
951									}
952									else
953									{
954										var checksum = null;
955										var temp = [];
956
957										if (req.getStatus() >= 200 && req.getStatus() <= 299 &&
958											req.getText().length > 0)
959										{
960											try
961											{
962												var result = JSON.parse(req.getText());
963
964												if (result != null && result.length > 0)
965												{
966													for (var i = 0; i < result.length; i++)
967													{
968														var value = this.stringToObject(result[i]);
969
970														if (value.v > DrawioFileSync.PROTOCOL)
971														{
972															failed = true;
973															temp = [];
974															break;
975														}
976														else if (value.v === DrawioFileSync.PROTOCOL &&
977															value.d != null)
978														{
979															checksum = value.d.checksum;
980															temp.push(value.d.patch);
981														}
982														else
983														{
984															failed = true;
985															temp = [];
986															break;
987														}
988													}
989												}
990											}
991											catch (e)
992											{
993												temp = [];
994
995												if (window.console != null && urlParams['test'] == '1')
996												{
997													console.log(e);
998												}
999											}
1000										}
1001
1002										try
1003										{
1004											if (temp.length > 0)
1005											{
1006												this.file.stats.cacheHits++;
1007												this.merge(temp, checksum, desc, success, error, abort);
1008											}
1009											// Retries if cache entry was not yet there
1010											else if (cacheReadyRetryCount <= this.maxCacheReadyRetries - 1 &&
1011												!failed && req.getStatus() != 401 && req.getStatus() != 503)
1012											{
1013												cacheReadyRetryCount++;
1014												this.file.stats.cacheMiss++;
1015												window.setTimeout(doCatchup, (cacheReadyRetryCount + 1) *
1016													this.cacheReadyDelay);
1017											}
1018											else
1019											{
1020												this.file.stats.cacheFail++;
1021												this.reload(success, error, abort);
1022											}
1023										}
1024										catch (e)
1025										{
1026											if (error != null)
1027											{
1028												error(e);
1029											}
1030										}
1031									}
1032								}
1033							}));
1034						}
1035					}
1036				});
1037
1038				window.setTimeout(doCatchup, this.cacheReadyDelay);
1039			}
1040		}
1041	}
1042};
1043
1044/**
1045 * Adds the listener for automatically saving the diagram for local changes.
1046 */
1047DrawioFileSync.prototype.reload = function(success, error, abort, shadow)
1048{
1049	this.file.updateFile(mxUtils.bind(this, function()
1050	{
1051		this.lastModified = this.file.getLastModifiedDate();
1052		this.updateStatus();
1053		this.start();
1054
1055		if (success != null)
1056		{
1057			success();
1058		}
1059	}), mxUtils.bind(this, function(err)
1060	{
1061		if (error != null)
1062		{
1063			error(err);
1064		}
1065	}), abort, shadow);
1066};
1067
1068/**
1069 * Adds the listener for automatically saving the diagram for local changes.
1070 */
1071DrawioFileSync.prototype.merge = function(patches, checksum, desc, success, error, abort)
1072{
1073	try
1074	{
1075		this.file.stats.merged++;
1076		this.lastModified = new Date();
1077		this.file.shadowPages = (this.file.shadowPages != null) ?
1078			this.file.shadowPages : this.ui.getPagesForNode(
1079			mxUtils.parseXml(this.file.shadowData).documentElement)
1080
1081		// Creates a patch for backup if the checksum fails
1082		this.file.backupPatch = (this.file.isModified()) ?
1083			this.ui.diffPages(this.file.shadowPages,
1084			this.ui.pages) : null;
1085		var ignored = this.file.ignorePatches(patches);
1086		var etag = this.file.getDescriptorRevisionId(desc);
1087
1088		if (!ignored)
1089		{
1090			// Patches the shadow document
1091			for (var i = 0; i < patches.length; i++)
1092			{
1093				this.file.shadowPages = this.ui.patchPages(this.file.shadowPages, patches[i]);
1094			}
1095
1096			var current = (checksum != null) ? this.ui.getHashValueForPages(this.file.shadowPages) : null;
1097
1098			if (urlParams['test'] == '1')
1099			{
1100				EditorUi.debug('Sync.merge', [this],
1101					'from', this.file.getCurrentRevisionId(), 'to', etag,
1102					'etag', this.file.getDescriptorEtag(desc),
1103					'backup', this.file.backupPatch,
1104					'attempt', this.catchupRetryCount,
1105					'patches', patches,
1106					'checksum', checksum == current, checksum);
1107			}
1108
1109			// Compares the checksum
1110			if (checksum != null && checksum != current)
1111			{
1112				var from = this.ui.hashValue(this.file.getCurrentRevisionId());
1113				var to = this.ui.hashValue(etag);
1114
1115				this.file.checksumError(error, patches, 'From: ' + from + '\nTo: ' + to +
1116					'\nChecksum: ' + checksum + '\nCurrent: ' + current, etag, 'merge');
1117
1118				// Uses current state as shadow to compute diff since
1119				// shadowPages has been modified in-place above
1120				// LATER: Check if fallback to reload is possible
1121//				this.reload(success, error, abort, this.ui.pages);
1122
1123				// Abnormal termination
1124				return;
1125			}
1126			else
1127			{
1128				// Patches the current document
1129				this.file.patch(patches,
1130					(DrawioFile.LAST_WRITE_WINS) ?
1131					this.file.backupPatch : null);
1132
1133				// Logs successull patch
1134//				try
1135//				{
1136//					var user = this.file.getCurrentUser();
1137//					var uid = (user != null) ? user.id : 'unknown';
1138//
1139//					EditorUi.logEvent({category: 'PATCH-SYNC-FILE-' + this.file.getHash(),
1140//						action: uid + '-patches-' + patches.length + '-recvd-' +
1141//						this.file.stats.bytesReceived + '-msgs-' + this.file.stats.msgReceived,
1142//						label: this.clientId});
1143//				}
1144//				catch (e)
1145//				{
1146//					// ignore
1147//				}
1148			}
1149		}
1150
1151		this.file.invalidChecksum = false;
1152		this.file.inConflictState = false;
1153		this.file.patchDescriptor(this.file.getDescriptor(), desc);
1154		this.file.backupPatch = null;
1155
1156		if (success != null)
1157		{
1158			success();
1159		}
1160	}
1161	catch (e)
1162	{
1163		this.file.inConflictState = true;
1164		this.file.invalidChecksum = true;
1165		this.file.descriptorChanged();
1166
1167		if (error != null)
1168		{
1169			error(e);
1170		}
1171
1172		try
1173		{
1174			if (this.file.errorReportsEnabled)
1175			{
1176				var from = this.ui.hashValue(this.file.getCurrentRevisionId());
1177				var to = this.ui.hashValue(etag);
1178
1179				this.file.sendErrorReport('Error in merge',
1180					'From: ' + from + '\nTo: ' + to +
1181					'\nChecksum: ' + checksum +
1182					'\nPatches:\n' + this.file.compressReportData(
1183						JSON.stringify(patches, null, 2)), e);
1184			}
1185			else
1186			{
1187				var user = this.file.getCurrentUser();
1188				var uid = (user != null) ? user.id : 'unknown';
1189
1190				EditorUi.logError('Error in merge', null,
1191					this.file.getMode() + '.' +
1192					this.file.getId(), uid, e);
1193			}
1194		}
1195		catch (e2)
1196		{
1197			// ignore
1198		}
1199	}
1200};
1201
1202/**
1203 * Invokes when the file descriptor was changed.
1204 */
1205DrawioFileSync.prototype.descriptorChanged = function(etag)
1206{
1207	this.lastModified = this.file.getLastModifiedDate();
1208
1209	if (this.channelId != null)
1210	{
1211		var msg = this.objectToString(this.createMessage({a: 'desc',
1212			m: this.lastModified.getTime()}));
1213		var current = this.file.getCurrentRevisionId();
1214		var data = this.objectToString({});
1215
1216		mxUtils.post(EditorUi.cacheUrl, this.getIdParameters() +
1217			'&from=' + encodeURIComponent(etag) + '&to=' + encodeURIComponent(current) +
1218			'&msg=' + encodeURIComponent(msg) + '&data=' + encodeURIComponent(data));
1219		this.file.stats.bytesSent += data.length;
1220		this.file.stats.msgSent++;
1221	}
1222
1223	this.updateStatus();
1224};
1225
1226/**
1227 * Converts the given object to an encrypted string.
1228 */
1229DrawioFileSync.prototype.objectToString = function(obj)
1230{
1231	var data = Graph.compress(JSON.stringify(obj));
1232
1233	if (this.key != null && typeof CryptoJS !== 'undefined')
1234	{
1235		data = CryptoJS.AES.encrypt(data, this.key).toString();
1236	}
1237
1238	return data;
1239};
1240
1241/**
1242 * Converts the given encrypted string to an object.
1243 */
1244DrawioFileSync.prototype.stringToObject = function(data)
1245{
1246	if (this.key != null && typeof CryptoJS !== 'undefined')
1247	{
1248		data = CryptoJS.AES.decrypt(data, this.key).toString(CryptoJS.enc.Utf8);
1249	}
1250
1251	return JSON.parse(Graph.decompress(data));
1252};
1253
1254/**
1255 * Requests a token for the given sec
1256 */
1257DrawioFileSync.prototype.createToken = function(secret, success, error)
1258{
1259	var acceptResponse = true;
1260
1261	var timeoutThread = window.setTimeout(mxUtils.bind(this, function()
1262	{
1263		acceptResponse = false;
1264		error({code: App.ERROR_TIMEOUT, message: mxResources.get('timeout')});
1265	}), this.ui.timeout);
1266
1267	mxUtils.get(EditorUi.cacheUrl + '?id=' + encodeURIComponent(this.channelId) +
1268		'&secret=' + encodeURIComponent(secret), mxUtils.bind(this, function(req)
1269	{
1270		window.clearTimeout(timeoutThread);
1271
1272		if (acceptResponse)
1273		{
1274			if (req.getStatus() >= 200 && req.getStatus() <= 299)
1275			{
1276				success(req.getText());
1277			}
1278			else
1279			{
1280				error({code: req.getStatus(), message: 'Token Error ' + req.getStatus()});
1281			}
1282		}
1283	}));
1284};
1285
1286/**
1287 * Invoked when a save request for a file was sent regardless of the response.
1288 */
1289DrawioFileSync.prototype.fileSaving = function()
1290{
1291	var msg = this.objectToString(this.createMessage({m: new Date().getTime(), type: 'optimistic'}));
1292
1293	// Notify only
1294	mxUtils.post(EditorUi.cacheUrl, this.getIdParameters() + '&msg=' + encodeURIComponent(msg), function()
1295	{
1296		// Ignore response
1297	});
1298};
1299
1300DrawioFileSync.prototype.sendFileChanges = function(pages, lastDesc)
1301{
1302	// Computes diff and checksum
1303	this.lastModified = this.file.getLastModifiedDate();
1304	var msg = this.objectToString(this.createMessage({m: this.lastModified.getTime()}));
1305	var secret = this.file.getDescriptorSecret(this.file.getDescriptor());
1306	var etag = this.file.getDescriptorRevisionId(lastDesc);
1307	var current = this.file.getCurrentRevisionId();
1308
1309	var shadow = (this.file.shadowPages != null) ?
1310			this.file.shadowPages : this.ui.getPagesForNode(
1311			mxUtils.parseXml(this.file.shadowData).documentElement)
1312	var lastSecret = this.file.getDescriptorSecret(lastDesc);
1313	var checksum = this.ui.getHashValueForPages(pages);
1314	var diff = this.ui.diffPages(shadow, pages);
1315
1316	// Data is stored in cache and message is sent to all listeners
1317	var data = this.objectToString(this.createMessage({patch: diff, checksum: checksum}));
1318
1319	this.file.p2pCollab.sendMessage('diff', {
1320		id: this.channelId,
1321		from: etag, to: current,
1322		msg: msg, secret: secret,
1323		lastSecret: lastSecret,
1324		data: data
1325	});
1326};
1327
1328/**
1329 * Invoked after a file was saved to add cache entry (which in turn notifies
1330 * collaborators).
1331 */
1332DrawioFileSync.prototype.fileSaved = function(pages, lastDesc, success, error, token)
1333{
1334	this.lastModified = this.file.getLastModifiedDate();
1335	this.resetUpdateStatusThread();
1336	this.catchupRetryCount = 0;
1337
1338	if (!this.ui.isOffline(true) && !this.file.inConflictState && !this.file.redirectDialogShowing)
1339	{
1340		this.start();
1341
1342		if (this.channelId != null)
1343		{
1344			// Computes diff and checksum
1345			var msg = this.objectToString(this.createMessage({m: this.lastModified.getTime()}));
1346			var secret = this.file.getDescriptorSecret(this.file.getDescriptor());
1347			var etag = this.file.getDescriptorRevisionId(lastDesc);
1348			var current = this.file.getCurrentRevisionId();
1349
1350			if (secret == null || urlParams['lockdown'] == '1')
1351			{
1352				this.file.stats.msgSent++;
1353
1354				// Notify only
1355				mxUtils.post(EditorUi.cacheUrl, this.getIdParameters() +
1356					'&msg=' + encodeURIComponent(msg), function()
1357				{
1358					// Ignore response
1359				});
1360
1361				if (success != null)
1362				{
1363					success();
1364				}
1365
1366				if (urlParams['test'] == '1')
1367				{
1368					EditorUi.debug('Sync.fileSaved', [this], 'from', etag, 'to', current,
1369						'etag', this.file.getCurrentEtag(), 'notify');
1370				}
1371			}
1372			else
1373			{
1374				var shadow = (this.file.shadowPages != null) ?
1375					this.file.shadowPages : this.ui.getPagesForNode(
1376					mxUtils.parseXml(this.file.shadowData).documentElement)
1377				var lastSecret = this.file.getDescriptorSecret(lastDesc);
1378				var checksum = this.ui.getHashValueForPages(pages);
1379				var diff = this.ui.diffPages(shadow, pages);
1380
1381				// Data is stored in cache and message is sent to all listeners
1382				var data = this.objectToString(this.createMessage({patch: diff, checksum: checksum}));
1383				this.file.stats.bytesSent += data.length;
1384				this.file.stats.msgSent++;
1385
1386				var acceptResponse = true;
1387
1388				var timeoutThread = window.setTimeout(mxUtils.bind(this, function()
1389				{
1390					acceptResponse = false;
1391					error({code: App.ERROR_TIMEOUT, message: mxResources.get('timeout')});
1392				}), this.ui.timeout);
1393
1394				mxUtils.post(EditorUi.cacheUrl, this.getIdParameters() +
1395					'&from=' + encodeURIComponent(etag) + '&to=' + encodeURIComponent(current) +
1396					'&msg=' + encodeURIComponent(msg) + ((secret != null) ? '&secret=' + encodeURIComponent(secret) : '') +
1397					((lastSecret != null) ? '&last-secret=' + encodeURIComponent(lastSecret) : '') +
1398					((data.length < this.maxCacheEntrySize) ? '&data=' + encodeURIComponent(data) : '') +
1399					((token != null) ? '&token=' + encodeURIComponent(token) : ''),
1400					mxUtils.bind(this, function(req)
1401				{
1402					window.clearTimeout(timeoutThread);
1403
1404					if (acceptResponse)
1405					{
1406						if (req.getStatus() >= 200 && req.getStatus() <= 299)
1407						{
1408							if (success != null)
1409							{
1410								success();
1411							}
1412						}
1413						else
1414						{
1415							error({code: req.getStatus(), message: req.getStatus()});
1416						}
1417					}
1418				}));
1419
1420				if (urlParams['test'] == '1')
1421				{
1422					EditorUi.debug('Sync.fileSaved', [this],
1423						'from', etag, 'to', current, 'etag', this.file.getCurrentEtag(),
1424						data.length, 'bytes', 'diff', diff, 'checksum', checksum);
1425				}
1426			}
1427
1428			// Logs successull diff
1429//			try
1430//			{
1431//				var user = this.file.getCurrentUser();
1432//				var uid = (user != null) ? user.id : 'unknown';
1433//
1434//				EditorUi.logEvent({category: 'DIFF-SYNC-FILE-' + this.file.getHash(),
1435//					action: uid + '-diff-' + data.length + '-sent-' +
1436//					this.file.stats.bytesSent + '-msgs-' +
1437//					this.file.stats.msgSent, label: this.clientId});
1438//			}
1439//			catch (e)
1440//			{
1441//				// ignore
1442//			}
1443		}
1444	}
1445
1446	// Ignores cache response as clients
1447	// load file if cache entry failed
1448	this.file.shadowPages = pages;
1449};
1450
1451/**
1452 * Creates the properties for the file descriptor.
1453 */
1454DrawioFileSync.prototype.getIdParameters = function()
1455{
1456	var result = 'id=' + this.channelId;
1457
1458	if (this.pusher != null && this.pusher.connection != null &&
1459		this.pusher.connection.socket_id != null)
1460	{
1461		result += '&sid=' + this.pusher.connection.socket_id;
1462	}
1463
1464	return result;
1465};
1466
1467/**
1468 * Creates the properties for the file descriptor.
1469 */
1470DrawioFileSync.prototype.createMessage = function(data)
1471{
1472	return {v: DrawioFileSync.PROTOCOL, d: data, c: this.clientId};
1473};
1474
1475/**
1476 * Creates the properties for the file descriptor.
1477 */
1478DrawioFileSync.prototype.fileConflict = function(desc, success, error)
1479{
1480	this.catchupRetryCount++;
1481
1482	if (this.catchupRetryCount < this.maxCatchupRetries)
1483	{
1484		this.file.stats.conflicts++;
1485
1486		if (desc != null)
1487		{
1488			this.catchup(desc, success, error);
1489		}
1490		else
1491		{
1492			this.fileChanged(success, error);
1493		}
1494	}
1495	else
1496	{
1497		this.file.stats.timeouts++;
1498		this.catchupRetryCount = 0;
1499
1500		if (error != null)
1501		{
1502			error({code: App.ERROR_TIMEOUT, message: mxResources.get('timeout')});
1503		}
1504	}
1505};
1506
1507/**
1508 * Adds the listener for automatically saving the diagram for local changes.
1509 */
1510DrawioFileSync.prototype.stop = function()
1511{
1512	if (this.pusher != null)
1513	{
1514		EditorUi.debug('Sync.stop', [this]);
1515
1516		if (this.pusher.connection != null)
1517		{
1518			this.pusher.connection.unbind('state_change', this.connectionListener);
1519			this.pusher.connection.unbind('error', this.pusherErrorListener);
1520		}
1521
1522		if (this.channel != null)
1523		{
1524			this.channel.unbind('changed', this.changeListener);
1525
1526			// See https://github.com/pusher/pusher-js/issues/75
1527			// this.pusher.unsubscribe(this.channelId);
1528			this.channel = null;
1529		}
1530
1531		this.pusher.disconnect();
1532		this.pusher = null;
1533	}
1534
1535	this.updateOnlineState();
1536	this.updateStatus();
1537};
1538
1539/**
1540 * Adds the listener for automatically saving the diagram for local changes.
1541 */
1542DrawioFileSync.prototype.destroy = function()
1543{
1544	if (this.channelId != null)
1545	{
1546		var user = this.file.getCurrentUser();
1547		var leave = {a: 'leave'};
1548
1549		if (user != null)
1550		{
1551			leave.name = encodeURIComponent(user.displayName);
1552			leave.uid = user.id;
1553		}
1554
1555		mxUtils.post(EditorUi.cacheUrl, this.getIdParameters() +
1556			'&msg=' + encodeURIComponent(this.objectToString(
1557			this.createMessage(leave))));
1558		this.file.stats.msgSent++;
1559	}
1560
1561	this.stop();
1562
1563	if (this.updateStatusThread != null)
1564	{
1565		window.clearInterval(this.updateStatusThread);
1566		this.updateStatusThread = null;
1567	}
1568
1569	if (this.onlineListener != null)
1570	{
1571		mxEvent.removeListener(window, 'online', this.onlineListener);
1572		this.onlineListener = null;
1573	}
1574
1575	if (this.visibleListener != null)
1576	{
1577		mxEvent.removeListener(document, 'visibilitychange', this.visibleListener);
1578		this.visibleListener = null;
1579	}
1580
1581	if (this.activityListener != null)
1582	{
1583		mxEvent.removeListener(document, (mxClient.IS_POINTER) ? 'pointermove' : 'mousemove', this.activityListener);
1584		mxEvent.removeListener(document, 'keypress', this.activityListener);
1585		mxEvent.removeListener(window, 'focus', this.activityListener);
1586
1587		if (!mxClient.IS_POINTER && mxClient.IS_TOUCH)
1588		{
1589			mxEvent.removeListener(document, 'touchstart', this.activityListener);
1590			mxEvent.removeListener(document, 'touchmove', this.activityListener);
1591		}
1592
1593		this.activityListener = null;
1594	}
1595
1596	if (this.collaboratorsElement != null)
1597	{
1598		this.collaboratorsElement.parentNode.removeChild(this.collaboratorsElement);
1599		this.collaboratorsElement = null;
1600	}
1601};
1602