/*\ title: $:/core/modules/syncer.js type: application/javascript module-type: global The syncer tracks changes to the store and synchronises them to a remote data store represented as a "sync adaptor" \*/ (function(){ /*jslint node: true, browser: true */ /*global $tw: false */ "use strict"; /* Defaults */ Syncer.prototype.titleIsLoggedIn = "$:/status/IsLoggedIn"; Syncer.prototype.titleIsAnonymous = "$:/status/IsAnonymous"; Syncer.prototype.titleIsReadOnly = "$:/status/IsReadOnly"; Syncer.prototype.titleUserName = "$:/status/UserName"; Syncer.prototype.titleSyncFilter = "$:/config/SyncFilter"; Syncer.prototype.titleSyncPollingInterval = "$:/config/SyncPollingInterval"; Syncer.prototype.titleSyncDisableLazyLoading = "$:/config/SyncDisableLazyLoading"; Syncer.prototype.titleSavedNotification = "$:/language/Notifications/Save/Done"; Syncer.prototype.titleSyncThrottleInterval = "$:/config/SyncThrottleInterval"; Syncer.prototype.taskTimerInterval = 0.25 * 1000; // Interval for sync timer Syncer.prototype.throttleInterval = 1 * 1000; // Defer saving tiddlers if they've changed in the last 1s... Syncer.prototype.errorRetryInterval = 5 * 1000; // Interval to retry after an error Syncer.prototype.fallbackInterval = 10 * 1000; // Unless the task is older than 10s Syncer.prototype.pollTimerInterval = 60 * 1000; // Interval for polling for changes from the adaptor /* Instantiate the syncer with the following options: syncadaptor: reference to syncadaptor to be used wiki: wiki to be synced */ function Syncer(options) { var self = this; this.wiki = options.wiki; // Save parameters this.syncadaptor = options.syncadaptor; this.disableUI = !!options.disableUI; this.titleIsLoggedIn = options.titleIsLoggedIn || this.titleIsLoggedIn; this.titleUserName = options.titleUserName || this.titleUserName; this.titleSyncFilter = options.titleSyncFilter || this.titleSyncFilter; this.titleSavedNotification = options.titleSavedNotification || this.titleSavedNotification; this.taskTimerInterval = options.taskTimerInterval || this.taskTimerInterval; this.throttleInterval = options.throttleInterval || parseInt(this.wiki.getTiddlerText(this.titleSyncThrottleInterval,""),10) || this.throttleInterval; this.errorRetryInterval = options.errorRetryInterval || this.errorRetryInterval; this.fallbackInterval = options.fallbackInterval || this.fallbackInterval; this.pollTimerInterval = options.pollTimerInterval || parseInt(this.wiki.getTiddlerText(this.titleSyncPollingInterval,""),10) || this.pollTimerInterval; this.logging = "logging" in options ? options.logging : true; // Make a logger this.logger = new $tw.utils.Logger("syncer" + ($tw.browser ? "-browser" : "") + ($tw.node ? "-server" : "") + (this.syncadaptor.name ? ("-" + this.syncadaptor.name) : ""),{ colour: "cyan", enable: this.logging, saveHistory: true }); // Make another logger for connection errors this.loggerConnection = new $tw.utils.Logger("syncer" + ($tw.browser ? "-browser" : "") + ($tw.node ? "-server" : "") + (this.syncadaptor.name ? ("-" + this.syncadaptor.name) : "") + "-connection",{ colour: "cyan", enable: this.logging }); // Ask the syncadaptor to use the main logger if(this.syncadaptor.setLoggerSaveBuffer) { this.syncadaptor.setLoggerSaveBuffer(this.logger); } // Compile the dirty tiddler filter this.filterFn = this.wiki.compileFilter(this.wiki.getTiddlerText(this.titleSyncFilter)); // Record information for known tiddlers this.readTiddlerInfo(); this.titlesToBeLoaded = {}; // Hashmap of titles of tiddlers that need loading from the server this.titlesHaveBeenLazyLoaded = {}; // Hashmap of titles of tiddlers that have already been lazily loaded from the server // Timers this.taskTimerId = null; // Timer for task dispatch // Number of outstanding requests this.numTasksInProgress = 0; // True when we want to force an immediate sync from the server this.forceSyncFromServer = false; this.timestampLastSyncFromServer = new Date(); // Listen out for changes to tiddlers this.wiki.addEventListener("change",function(changes) { // Filter the changes to just include ones that are being synced var filteredChanges = self.getSyncedTiddlers(function(callback) { $tw.utils.each(changes,function(change,title) { var tiddler = self.wiki.tiddlerExists(title) && self.wiki.getTiddler(title); callback(tiddler,title); }); }); if(filteredChanges.length > 0) { self.processTaskQueue(); } else { // Look for deletions of tiddlers we're already syncing var outstandingDeletion = false $tw.utils.each(changes,function(change,title,object) { if(change.deleted && $tw.utils.hop(self.tiddlerInfo,title)) { outstandingDeletion = true; } }); if(outstandingDeletion) { self.processTaskQueue(); } } }); // Browser event handlers if($tw.browser && !this.disableUI) { // Set up our beforeunload handler $tw.addUnloadTask(function(event) { var confirmationMessage; if(self.isDirty()) { confirmationMessage = $tw.language.getString("UnsavedChangesWarning"); event.returnValue = confirmationMessage; // Gecko } return confirmationMessage; }); // Listen out for login/logout/refresh events in the browser $tw.rootWidget.addEventListener("tm-login",function(event) { var username = event && event.paramObject && event.paramObject.username, password = event && event.paramObject && event.paramObject.password; if(username && password) { // Login with username and password self.login(username,password,function() {}); } else { // No username and password, so we display a prompt self.handleLoginEvent(); } }); $tw.rootWidget.addEventListener("tm-logout",function() { self.handleLogoutEvent(); }); $tw.rootWidget.addEventListener("tm-server-refresh",function() { self.handleRefreshEvent(); }); $tw.rootWidget.addEventListener("tm-copy-syncer-logs-to-clipboard",function() { $tw.utils.copyToClipboard($tw.utils.getSystemInfo() + "\n\nLog:\n" + self.logger.getBuffer()); }); } // Listen out for lazyLoad events if(!this.disableUI && this.wiki.getTiddlerText(this.titleSyncDisableLazyLoading) !== "yes") { this.wiki.addEventListener("lazyLoad",function(title) { self.handleLazyLoadEvent(title); }); } // Get the login status this.getStatus(function(err,isLoggedIn) { // Do a sync from the server self.syncFromServer(); }); } /* Show a generic network error alert */ Syncer.prototype.displayError = function(msg,err) { if(err === ($tw.language.getString("Error/XMLHttpRequest") + ": 0")) { this.loggerConnection.alert($tw.language.getString("Error/NetworkErrorAlert")); this.logger.log(msg + ":",err); } else { this.logger.alert(msg + ":",err); } }; /* Return an array of the tiddler titles that are subjected to syncing */ Syncer.prototype.getSyncedTiddlers = function(source) { return this.filterFn.call(this.wiki,source); }; /* Return an array of the tiddler titles that are subjected to syncing */ Syncer.prototype.getTiddlerRevision = function(title) { if(this.syncadaptor && this.syncadaptor.getTiddlerRevision) { return this.syncadaptor.getTiddlerRevision(title); } else { return this.wiki.getTiddler(title).fields.revision; } }; /* Read (or re-read) the latest tiddler info from the store */ Syncer.prototype.readTiddlerInfo = function() { // Hashmap by title of {revision:,changeCount:,adaptorInfo:} // "revision" is the revision of the tiddler last seen on the server, and "changecount" is the corresponding local changecount this.tiddlerInfo = {}; // Record information for known tiddlers var self = this, tiddlers = this.getSyncedTiddlers(); this.logger.log("Initialising tiddlerInfo for " + tiddlers.length + " tiddlers"); $tw.utils.each(tiddlers,function(title) { var tiddler = self.wiki.getTiddler(title); if(tiddler) { self.tiddlerInfo[title] = { revision: self.getTiddlerRevision(title), adaptorInfo: self.syncadaptor && self.syncadaptor.getTiddlerInfo(tiddler), changeCount: self.wiki.getChangeCount(title) }; } }); }; /* Checks whether the wiki is dirty (ie the window shouldn't be closed) */ Syncer.prototype.isDirty = function() { var self = this; function checkIsDirty() { // Check tiddlers that are in the store and included in the filter function var titles = self.getSyncedTiddlers(); for(var index=0; index tiddlerInfo.changeCount) { return true; } } else { // If the tiddler isn't known on the server then it needs to be saved to the server return true; } } } // Check tiddlers that are known from the server but not currently in the store titles = Object.keys(self.tiddlerInfo); for(index=0; index tiddlerInfo.changeCount, isReadyToSave = !tiddlerInfo || !tiddlerInfo.timestampLastSaved || tiddlerInfo.timestampLastSaved < thresholdLastSaved; if(hasChanged) { if(isReadyToSave) { return new SaveTiddlerTask(this,title); } else { havePending = true; } } } } // Second we check for an outstanding sync from server if(this.forceSyncFromServer || (this.timestampLastSyncFromServer && (now.valueOf() >= (this.timestampLastSyncFromServer.valueOf() + this.pollTimerInterval)))) { return new SyncFromServerTask(this); } // Third, we check tiddlers that are known from the server but not currently in the store, and so need deleting on the server titles = Object.keys(this.tiddlerInfo); for(index=0; index