/** * Offline library for storing graphics related to an Edit Task. * Currently works with Points, Polylines and Polygons. Also provides online/offline validation. * * Automatically attempts to reconnect. As soon as a connection is made updates are submitted * and the localStorage is deleted upon successful update. * * NOTE: Hooks for listeners that updates were successful/unsuccessful should be added in * _handleRestablishedInternet() * * Dependencies: ArcGIS JavaScript API and Hydrate.js: https://github.com/nanodeath/HydrateJS * Limitations: does not currently store infoTemplate and symbol properties * More info: http://www.w3.org/TR/webstorage/ * @version 0.1 * @author Andy Gup (@agup) * @param layersAddResult the layers-add-result. Example: map.on("layers-add-result", someFunction); * @type {*|{}} */ var OfflineStore = function(/* Map */ map) { this.backgroundTimerWorker = null; this.isTimer = null; this.layers = []; //An array of all feature layers this.map = map; this.map.offlineStore = this; /** * Public ENUMs (Constants) * @type {Object} * @returns {String} * @private */ this.enum = (function(){ var values = { ADD : "add", UPDATE : "update", DELETE : "delete" } return values; }); /** * Private Local ENUMs (Constants) * Contains required configuration info. * @type {Object} * @returns {*} * @private */ this._localEnum = (function(){ var values = { VALIDATION_URL : "http://localhost/offline/test.html", /* Change this to a remote server for testing! */ TIMER_URL : "./scripts/Timer.js", /* For use within a child process only */ STORAGE_KEY : "___EsriOfflineStore___", /* Unique key for setting/retrieving values from localStorage */ INDEX_KEY : "___EsriOfflineIndex___", /* Index for tracking each action (add, delete, update) in local store */ VALIDATION_TIMEOUT : 10 * 1000, /* HTTP timeout when trying to validate internet on/off */ LOCAL_STORAGE_MAX_LIMIT : 4.75 /* MB */, /* Most browsers offer default storage of ~5MB */ TOKEN : "|||", /* A unique token for tokenizing stringified localStorage values */ REQUIRED_LIBS : [ "./scripts/Hydrate.js", "./scripts/Poller.js" ] } return values; }); /** * Model for handle vertices editing * @param graphic * @param layer */ this.verticesObject = function(/* Graphic */ graphic, /* FeatureLayer */ layer){ this.graphic = graphic; this.layer = layer; } ////////////////////////// /// /// PUBLIC methods /// ////////////////////////// /** * Conditionally attempts to send an edit request to ArcGIS Server. * @param graphic Required * @param layer Required * @param enumValue Required */ this.applyEdits = function(/* Graphic */ graphic,/* FeatureLayer */ layer, /* String */ enumValue){ var internet = this._checkInternet(); //TODO Need to add code to determine size of incoming graphic var mb = this.getlocalStorageUsed(); console.log("getlocalStorageUsed = " + mb + " MBs"); if(mb > this._localEnum().LOCAL_STORAGE_MAX_LIMIT /* MB */){ alert("You are almost over the local storage limit. No more data can be added.") return; } if(internet === false){ this._addToLocalStore(graphic,layer,enumValue); if(this.isTimer == null){ this._startTimer(function(err){ throw ("unable to start background timer. Offline edits won't work. " + err.stack); }); } } else if(internet == null || typeof internet == "undefined"){ console.log("applyEdits: possible error."); } else{ this._layerEditManager(graphic,layer,enumValue,this.enum(),null,true,null); } } /** * Public method for retrieving all items in the localStore. * @returns {Array} Graphics */ this.getStore = function(){ var graphicsArr = null; var data = localStorage.getItem(this._localEnum().STORAGE_KEY); if(data != null){ graphicsArr = []; var split = data.split(this._localEnum().TOKEN); for(var property in split){ var item = split[property]; if(typeof item !== "undefined" && item.length > 0 && item !== null){ var graphic = this._deserializeGraphic(item); graphicsArr.push( graphic ); } } } return graphicsArr; } /** * Provides a list of all localStorage items that have been either * added, deleted or updated. * @returns {Array} */ this.getLocalStoreIndex = function(){ var localStore = localStorage.getItem(this._localEnum().INDEX_KEY); return localStore != null ? localStore.split(this._localEnum().TOKEN) : null; } /** * Determines total storage used for this domain. * @returns Number MB's */ this.getlocalStorageUsed = function(){ var mb = 0; //IE hack if(window.localStorage.hasOwnProperty("remainingspace")){ //http://msdn.microsoft.com/en-us/library/ie/cc197016(v=vs.85).aspx mb = window.localStorage.remainingSpace/1024/1024; } else{ for(var x in localStorage){ //Uncomment out console.log to see *all* items in local storage //console.log(x+"="+((localStorage[x].length * 2)/1024/1024).toFixed(2)+" MB"); mb += localStorage[x].length } } return Math.round(((mb * 2)/1024/1024) * 100)/100; } ////////////////////////// /// /// PRIVATE methods /// ////////////////////////// this._layerEditManager = function( /* Graphic */ graphic, /* FeatureLayer */ layer, /* String */ value, /* Object */ localEnum, /* Number */ count, /* Object */ mCallback){ switch(value){ case localEnum.DELETE: layer.applyEdits(null,null,[graphic],function(addResult,updateResult,deleteResult){ console.log("deleteResult ObjectId: " + deleteResult[0].objectId + ", Success: " + deleteResult[0].success); if(mCallback != null && count != null) { mCallback(count,deleteResult[0].success); } else{ this._addItemLocalStoreIndex(deleteResult[0].objectId,value,true); } }.bind(this), function(error){ console.log("_layer: " + error.stack); mCallback(count,false); this._addItemLocalStoreIndex(deleteResult[0].objectId,value,false); }.bind(this) ); break; case localEnum.ADD: layer.applyEdits([graphic],null,null,function(addResult,updateResult,deleteResult){ console.log("addResult ObjectId: " + addResult[0].objectId + ", Success: " + addResult[0].success); if(mCallback != null && count != null) { mCallback(count,deleteResult[0].success); } else{ this._addItemLocalStoreIndex(addResult[0].objectId,value,true); } }.bind(this), function(error){ console.log("_layer: " + error.stack); mCallback(count,false); this._addItemLocalStoreIndex(addResult[0].objectId,value,false); }.bind(this) ); break; case localEnum.UPDATE: layer.applyEdits(null,[graphic],null,function(addResult,updateResult,deleteResult){ console.log("updateResult ObjectId: " + updateResult[0].objectId + ", Success: " + updateResult[0].success); if(mCallback != null && count != null) { mCallback(count,deleteResult[0].success); } else{ this._addItemLocalStoreIndex(updateResult[0].objectId,value,true); } }.bind(this), function(error){ console.log("_layer: " + error.stack); mCallback(count,false) this._addItemLocalStoreIndex(updateResult[0].objectId,value,false); }.bind(this) ); break; } } this._layerCallbackHandler = function(callback,count,objectid){ } /** * Takes a serialized geometry and adds it to localStorage * @param geom * @private */ this._updateExistingLocalStore = function(/* Geometry */ geom){ var localStore = this._getLocalStorage(); var split = localStore.split(this._localEnum().TOKEN); console.log(localStore.toString()); var dupeFlag = false; for(var property in split){ var item = split[property]; if(typeof item !== "undefined" && item.length > 0 && item !== null){ var sub = geom.substring(0,geom.length - 3); //This is not the sturdiest way to verify if two geometries are equal if(sub === item){ console.log("updateExistingLocalStore: duplicate item skipped."); dupeFlag = true; break; } } } if(dupeFlag == false) this._setItemInLocalStore(localStore + geom); } this._addToLocalStore = function(/* Graphic */ graphic, /* FeatureLayer */ layer, /* String */ enumValue){ var arr = this._getLocalStorage(); var geom = this._serializeGraphic(graphic,layer,enumValue); //If localStorage does NOT exist if(arr === null){ this._setItemInLocalStore(geom); } else{ this._updateExistingLocalStore(geom); } layer.add(graphic); } this._startTimer = function(callback){ var onlineFLAG = false; if(this.backgroundTimerWorker == null && this.isTimer == null){ console.log("Starting timer..."); try{ this.backgroundTimerWorker = new Worker(this._localEnum().TIMER_URL); this.backgroundTimerWorker.addEventListener('message', function(msg) { if(msg.data.hasOwnProperty("msg")){ console.log("_startTimer: " + msg.data.msg) } if(msg.data.hasOwnProperty("alive")){ console.log("Timer heartbeat."); this.isTimer = msg.data.alive; } if(msg.data.hasOwnProperty("err")){ console.log("_startTimer error: " + msg.data.err); } //Handle reestablishing an internet connection if(msg.data.hasOwnProperty("net")){ if(msg.data.net == false){ console.log("Internet status: " + msg.data.net); if(onlineFLAG != false)onlineFLAG = false; } else if(msg.data.net == true){ var arr = this._getLocalStorage(); if(onlineFLAG == false){ onlineFLAG = true; } if(arr != null){ this._handleRestablishedInternet(function(){ this._stopTimer(); this._deleteStore(); }.bind(this)); } } } }.bind(this), false); this.backgroundTimerWorker.postMessage({start:true,interval:10000}); } catch(err){ callback(err); } } } this._stopTimer = function(){ if(this.backgroundTimerWorker != null){ this.backgroundTimerWorker.terminate(); this.backgroundTimerWorker.postMessage({kill:true}); this.backgroundTimerWorker = null; this.isTimer = null; console.log("Timer stopped...") } else{ console.log("Timer may already be stopped..."); } } this._handleRestablishedInternet = function(callback){ var graphicsArr = this.getStore(); if(graphicsArr != null && this.layers != null){ var check = []; var errCnt = 0; for(var i in graphicsArr){ var obj1 = graphicsArr[i]; var layer = this._getGraphicsLayerById(obj1.layer); this._layerEditManager(obj1.graphic,layer,obj1.enumValue,this.enum(),i,function(/* Number */ num, /* boolean */ success){ check.push(num); var id = obj1.graphic.attributes.objectid; if(success == true && check.length == graphicsArr.length){ if(errCnt == 0){ this._addItemLocalStoreIndex(id,obj1.enumValue,true); callback(); } else{ console.log("_handleRestablishedInternet: there were errors. LocalStore still available."); this._stopTimer(); } } else if(success == true && check.length < graphicsArr.length){ this._addItemLocalStoreIndex(id,obj1.enumValue,true); } else if(success == false && check.length == graphicsArr.length){ this._addItemLocalStoreIndex(id,obj1.enumValue,false); console.log("_handleRestablishedInternet: error sending edit on " + id); this._stopTimer(); } else if(success == false && check.length < graphicsArr.length){ this._addItemLocalStoreIndex(id,obj1.enumValue,false); errCnt++; console.log("_handleRestablishedInternet: error sending edit on " + id); } }.bind(this)); } } } this._getGraphicsLayerById = function(/* String */ id){ for(var layer in this.layers) { if(id == this.layers[layer].layerId){ return this.layers[layer]; break; } } } /** * Delete all items stored by this library using its unique key. * Does NOT delete anything else from localStorage. */ this._deleteStore = function(){ console.log("deleting localStore"); localStorage.removeItem(this._localEnum().STORAGE_KEY); } /** * Returns the raw local storage object. * @returns {*} * @private */ this._getLocalStorage = function(){ return localStorage.getItem(this._localEnum().STORAGE_KEY); } /** * Sets the localStorage * @param item * @returns {boolean} returns true if success, else false. Writes * error stack to console. */ this._setItemInLocalStore = function(item){ var success = false; try{ localStorage.setItem(this._localEnum().STORAGE_KEY,item); success = true; } catch(err){ console.log("_setItemInLocalStore(): " + err.stack); success = false; } return success; } this._deleteLocalStoreIndex = function(){ console.log("deleting localStoreIndex"); localStorage.removeItem(this._localEnum().INDEX_KEY); } /** * Validates if an item has been deleted. * @param objectId * @returns {boolean} * @private */ this._getItemLocalStoreIndex = function(/* String */ objectId){ var localStore = this._getLocalStorageIndex(); var split = localStore.split(this._localEnum().TOKEN); for(var property in split){ var item = JSON.parse(split[property]); if(typeof item !== "undefined" || item.length > 0 || item != null){ if(item.hasOwnProperty("id") && item.id == objectId){ return true; } } } return false; } /** * Add item to index *if* if was successfully deleted. * @param objectId * @param type enum * @param success * @returns {boolean} * @private */ this._addItemLocalStoreIndex = function(/* String */ objectId, /* String */ type, /* boolean */ success){ var index = new this._indexObject(objectId,type,success) ; var mIndex = JSON.stringify(index); var localStore = this.getLocalStoreIndex(); try{ if(localStore == null || typeof localStore == "undefined"){ localStorage.setItem(this._localEnum().INDEX_KEY,mIndex + this._localEnum().TOKEN); } else{ localStorage.setItem(this._localEnum().INDEX_KEY,localStore + mIndex + this._localEnum().TOKEN); } success = true; } catch(err){ console.log("_addItemLocalStoreIndex(): " + err.stack); success = false; } return success; } this._checkInternet = function(){ var result = null; var poller = Poller.httpGet( this._localEnum().VALIDATION_URL, this._localEnum().VALIDATION_TIMEOUT, function(msg){ result = msg; } ); return result; } this._deserializeGraphic = function(/* Graphic */ item){ var jsonItem = JSON.parse(item); var geometry = JSON.parse(jsonItem.geometry); var attributes = JSON.parse(jsonItem.attributes); var enumValue = jsonItem.enumValue; var layer = JSON.parse(jsonItem.layer); var finalGeom = null; switch(geometry.type){ case "polyline": finalGeom = new esri.geometry.Polyline(new esri.SpatialReference(geometry.spatialReference.wkid)); for(var path in geometry.paths){ finalGeom.addPath(geometry.paths[path]); } break case "point": finalGeom = new esri.geometry.Point(geometry.x,geometry.y,new esri.SpatialReference(geometry.spatialReference.wkid)); break; case "polygon": finalGeom = new esri.geometry.Polygon(new esri.SpatialReference(geometry.spatialReference.wkid)); for(var ring in geometry.rings){ finalGeom.addRing(geometry.rings[ring]); } break; } var graphic = new esri.Graphic(finalGeom, null, attributes, null); return {"graphic":graphic,"layer":layer,"enumValue":enumValue}; } /** * Rebuilds Geometry in a way that can be serialized/deserialized * @param Graphic * @returns {string} * @private */ this._serializeGraphic = function(/* Graphic */ object, layer, enumValue){ var json = new this._jsonObject(); json.layer = layer.layerId; json.enumValue = enumValue; json.geometry = JSON.stringify(object.geometry) if(object.hasOwnProperty("attributes")){ if(object.attributes != null){ var hydrate = new Hydrate(); var q = hydrate.stringify(object.attributes); json.attributes = q; } } return JSON.stringify(json) + this._localEnum().TOKEN; } ////////////////////////// /// /// INTERNAL Models /// ////////////////////////// /** * Model for storing serialized graphics * @private */ this._jsonObject = function(){ this.layer = null; this.enumValue = null; this.geometry = null; this.attributes = null; } /** * Model for storing serialized index info. * @private */ this._indexObject = function(/* String */ id, /* String */ type, /* boolean */ success){ this.id = id; this.type = type; this.success = success; } ////////////////////////// /// /// INITIALISE /// ////////////////////////// /** * Load scripts * TO-DO: Needs to be made AMD compliant! * @param urlArray * @param callback * @private */ this._loadScripts = function(/* Array */ urlArray, callback) { count = 0; for(var i in urlArray){ try{ var head = document.getElementsByTagName('head')[0]; var script = document.createElement('script'); script.type = 'text/javascript'; script.src = urlArray[i]; script.onreadystatechange = function(){ count++; console.log("Script loaded. " + this.src); if(count == urlArray.length) callback(); }; script.onload = function(){ count++; console.log("Script loaded. " + this.src); if(count == urlArray.length) callback(); }; head.appendChild(script); } catch(err){ console.log("_loadScripts: " + err.stack); } } } this._parseFeatureLayers = function(/* Event */ map){ var layerIds = map.graphicsLayerIds; try{ for (var i in layerIds){ var layer = map.getLayer(layerIds[i]); if(layer.hasOwnProperty("type") && layer.type.toLowerCase() == "feature layer"){ if(layer.isEditable() == true){ this.layers.push(layer); } } else{ throw ("Layer not editable: " + layer.url ); } } } catch(err){ console.log("_parseFeatureLayer: " + err.stack); } } /** * Initializes the OfflineStore library. Loads required scripts. Kicks off timer if * localStore is not empty. * @see Required script sare set in _localEnum. * @type {*} * @private */ this._init = function(){ this._loadScripts(this._localEnum().REQUIRED_LIBS,function(){ console.log("OfflineStore is ready.") this._parseFeatureLayers(this.map); if(typeof Poller == "object"){ var internet = this._checkInternet(); var arr = this._getLocalStorage(); if(this.isTimer != true && internet == false && arr != null){ this._startTimer(function(err){ throw ("unable to start background timer. Offline edits won't work. " + err.stack); }); } else if(internet == null || typeof internet == "undefined"){ console.log("applyEdits: possible error."); } // else{ // var arr = this._getLocalStorage(); // if(arr != null){ // this._handleRestablishedInternet(function(){ // this._stopTimer(); // this._deleteStore(); // }.bind(this)); // } // } } }.bind(this)); }.bind(this)() /** * Attempt to stop timer and reduce chances of corrupting or duplicating data. * TO-DO some errors like those in callbacks may not be trapped by this! * @param msg * @param url * @param line * @returns {boolean} */ window.onerror = function (msg,url,line){ console.log(msg + ", " + url + ":" + line); this.map.offlineStore._stopTimer(); return true; } };