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