/**
* 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;
}
};