offline-editor-js/lib/edit/offlineFeaturesManager.js

858 lines
37 KiB
JavaScript

"use strict";
define([
"edit/editsStore",
"edit/attachmentsStore",
"dojo/Evented",
"dojo/_base/Deferred",
"dojo/promise/all",
"dojo/_base/declare",
"dojo/_base/lang",
"dojo/_base/array",
"dojo/dom-attr",
"dojo/dom-style",
"esri/layers/GraphicsLayer",
"esri/graphic",
"esri/symbols/SimpleMarkerSymbol",
"esri/symbols/SimpleLineSymbol",
"esri/symbols/SimpleFillSymbol",
"esri/request"],
function(editsStore, AttachmentsStore,
Evented,Deferred,all,declare,lang,array,domAttr,domStyle,
GraphicsLayer,Graphic,SimpleMarkerSymbol,SimpleLineSymbol,SimpleFillSymbol,
esriRequest)
{
return declare([Evented],
{
_onlineStatus: "online",
_featureLayers: {},
ONLINE: "online", // all edits will directly go to the server
OFFLINE: "offline", // edits will be enqueued
RECONNECTING: "reconnecting", // sending stored edits to the server
// manager emits event when...
events: {
EDITS_SENT: 'edits-sent', // ...whenever any edit is actually sent to the server
EDITS_ENQUEUED: 'edits-enqueued', // ...when an edit is enqueued (and not sent to the server)
ALL_EDITS_SENT: 'all-edits-sent', // ...after going online and there are no pending edits in the queue
ATTACHMENT_ENQUEUED: 'attachment-enqueued',
ATTACHMENTS_SENT: 'attachments-sent'
},
_checkFileAPIs: function()
{
if( window.File && window.FileReader && window.FileList && window.Blob )
{
console.log("All APIs supported!");
if(!XMLHttpRequest.prototype.sendAsBinary )
{
// https://code.google.com/p/chromium/issues/detail?id=35705#c40
XMLHttpRequest.prototype.sendAsBinary = function(datastr)
{
function byteValue(x) {
return x.charCodeAt(0) & 0xff;
}
var ords = Array.prototype.map.call(datastr, byteValue);
var ui8a = new Uint8Array(ords);
this.send(ui8a.buffer);
}
console.log("extending XMLHttpRequest");
}
return true;
}
else
{
alert('The File APIs are not fully supported in this browser.');
return false;
}
},
/**
* Overrides a feature layer.
* @param layer
* @returns deferred
*/
extend: function(layer, callback)
{
var self = this;
// we keep track of the FeatureLayer object
this._featureLayers[ layer.url ] = layer;
// replace the applyEdits() method
layer._applyEdits = layer.applyEdits;
// attachments
layer._addAttachment = layer.addAttachment;
layer._queryAttachmentInfos = layer.queryAttachmentInfos;
layer._deleteAttachments = layer.deleteAttachments;
if( !this._checkFileAPIs())
{
return callback(false, "File APIs not supported");
}
try
{
layer.attachmentsStore = new AttachmentsStore();
if( /*false &&*/ layer.attachmentsStore.isSupported() )
layer.attachmentsStore.init(callback);
else
return callback(false, "indexedDB not supported");
}
catch(err)
{
console.log("problem! " + err.toString())
}
/*
operations supported offline:
1. add a new attachment to an existing feature
2. add a new attachment to a new feature
3. remove an attachment that is already in the server... how do we know about it?
4. remove an attachment that is not in the server yet
5. update an existing attachment to an existing feature, how do we know about it?
6. update a new attachment
concerns:
- manage the relationship between offline features and attachments: what if the user wants to add
an attachment to a feature that is still offline? we need to keep track of objectids so that when
the feature is sent to the server and receives a final objectid we replace the temporary negative id
by its final objectid
- what if the user deletes an offline feature that had offline attachments? we need to discard the attachment
pending tasks:
- delete attachment
- send attachments to server when reconnecting
- check for hasAttachments attribute in the FeatureLayer
*/
//
// attachments
//
layer.queryAttachmentInfos = function(objectId,callback,errback){
if( self.getOnlineStatus() == self.ONLINE)
{
var def = this._queryAttachmentInfos(objectId,
function()
{
console.log(arguments);
self.emit(self.events.ATTACHMENTS_INFO,arguments);
callback && callback.apply(this,arguments);
},
errback);
return def;
}
else
{
// will only return LOCAL attachments
var deferred = new Deferred();
this.attachmentsStore.getAttachmentsByFeatureId(this.url, objectId, function(attachments)
{
callback && callback(attachments);
deferred.resolve(attachments);
});
return deferred;
}
}
layer.addAttachment = function(objectId,formNode,callback,errback){
if( self.getOnlineStatus() == self.ONLINE)
{
return this._addAttachment(objectId,formNode,
function(){
console.log(arguments);
self.emit(self.events.ATTACHMENTS_SENT,arguments);
callback && callback.apply(this,arguments);
},
function(err){
console.log("addAttachment: " + err)
errback && errback.apply(this,arguments);
}
)
}
else
{
var deferred = new Deferred();
console.assert(objectId);
// get input node(s)
// read file from input into variable
this._readFilesFromForm(formNode, function(files)
{
console.log(files);
console.assert(files.length == 1, "we don't support multiple files (yet?)");
files.forEach(function(file)
{
// store the attachment
var attachmentId = this.getNextTempId();
this.attachmentsStore.store(this.url,attachmentId, objectId, file, function(success)
{
var returnValue = { attachmentId: attachmentId, objectId: objectId, success: success };
if( success )
{
self.emit(self.events.ATTACHMENT_ENQUEUED,returnValue);
callback && callback(returnValue);
deferred.resolve(returnValue);
// PROBLEM HERE: theoretically, the form can have multiple input[file] controls
// and each input can contain multiple files BUT here we call the callback and
// resolve the deferred with the FIRST of the files
// In practice, popups only have one input, and it doesn't allow multiple files
}
else
{
returnValue.error = "can't store attachment";
errback && errback(returnValue);
deferred.reject(returnValue);
}
});
},this);
}.bind(this));
return deferred;
}
}
layer._readFilesFromForm = function(formNode, callback)
{
var inputNodes = array.filter(formNode.elements, function(node) { return node.type == "file"});
console.assert(inputNodes.length <= 1, "we don't support multiple input nodes in one form (yet?)");
inputNodes.forEach(function(inputNode)
{
var files = [];
var pendingFiles = inputNode.files.length
var i, n = inputNode.files.length;
if( pendingFiles == 0)
return callback(files);
for(i=0;i<n; i++)
{
var file = inputNode.files[i];
console.log("reading file", file.name);
var reader = new FileReader();
// closure to keep 'file' value
reader.onload = (function(theFile){
return function(evt) {
console.log(evt);
files.push({
name: theFile.name,
type: theFile.type,
content: evt.target.result,
size: theFile.size
});
pendingFiles--;
if( pendingFiles == 0)
return callback(files);
}
}(file));
reader.readAsBinaryString(file);
};
});
}
layer.deleteAttachments = function(objectId,attachmentsIds,callback,errback)
{
if( self.getOnlineStatus() == self.ONLINE)
{
var def = this._deleteAttachments(objectId,attachmentsIds,
function()
{
self.emit(self.events.ATTACHMENTS_DELETED,arguments);
callback && callback.apply(this,arguments);
},
function(err){
console.log("deleteAttachments: " + err)
errback && errback.apply(this,arguments);
});
return def;
}
else
{
// TODO
console.assert(false, "not implemented");
// case 1.- it is a new attachment
// case 2.- it is an already existing attachment
}
}
layer.replaceFeatureIds = function(tempObjectIds,newObjectIds,callback)
{
console.log("replacing ids of attachments",tempObjectIds, newObjectIds);
console.assert( tempObjectIds.length == newObjectIds.length, "arrays must be the same length");
var i, n = tempObjectIds.length;
var count = n;
var successCount = 0;
for(i=0; i<n; i++)
{
this.attachmentsStore.replaceFeatureId(this.url, tempObjectIds[i], newObjectIds[i], function(success)
{
--count;
successCount += (success? 1:0);
if( count == 0)
{
callback(successCount);
}
}.bind(this));
}
}
layer.sendStoredAttachments = function(callback)
{
console.log("sending attachments for layer", this.url);
this.attachmentsStore.getAttachmentsByFeatureLayer(this.url,function(attachments)
{
console.log("we have",attachments.length,"attachments to upload");
var promises = []
attachments.forEach(function(attachment)
{
console.log("sending attachment", attachment.id, "to feature", attachment.featureId);
var uploadCompleted = self._uploadAttachment(attachment);
var deleteCompleted = new Deferred();
uploadCompleted.then(function(attachmentId, deleteCompleted) // closure to keep attachmentId and deleteCompleted value
{
return function(result)
{
console.log("upload complete",result,attachmentId);
this.attachmentsStore.delete(attachmentId, function(success)
{
console.log("delete complete", result, success);
deleteCompleted.resolve(result);
});
}.bind(this);
}.bind(this)(attachment.id, deleteCompleted)
);
promises.push( deleteCompleted );
}.bind(this));
console.log("promises", promises.length);
var allPromises = new all(promises);
allPromises.then(function(results)
{
console.log(results);
callback && callback(true);
},
function(err)
{
console.log("error!",err);
callback && callback(false);
});
}.bind(this))
}
//
// other functions
//
/**
* Overrides the ArcGIS API for JavaSccript applyEdits() method.
* @param adds Creates a new edit entry.
* @param updates Updates an existing entry.
* @param deletes Deletes an existing entry.
* @param callback Called when the operation is complete.
* @param errback An error object is returned if an error occurs
* @returns {*} deferred
*/
layer.applyEdits = function(adds,updates,deletes,callback,errback)
{
// inside this method, 'this' will be the FeatureLayer
// and 'self' will be the offlineFeatureLayer object
if( self.getOnlineStatus() == self.ONLINE)
{
var def = this._applyEdits(adds,updates,deletes,
function()
{
self.emit(self.events.EDITS_SENT,arguments);
callback && callback.apply(this,arguments);
},
errback);
return def;
}
else
{
var deferred = new Deferred();
var results = { addResults:[],updateResults:[], deleteResults:[] };
var updatesMap = {};
this.onBeforeApplyEdits(adds, updates, deletes);
adds = adds || [];
adds.forEach(function(addEdit)
{
var objectId = this.getNextTempId();
addEdit.attributes[ this.objectIdField ] = objectId;
var result = editsStore.pushEdit(editsStore.ADD, this.url, addEdit);
results.addResults.push({ success:result.success, error: result.error, objectId: objectId});
if(result.success)
{
var phantomAdd = new Graphic(
addEdit.geometry,
self._getPhantomSymbol(addEdit.geometry, editsStore.ADD),
{
objectId: objectId
});
this._phantomLayer.add(phantomAdd);
domAttr.set(phantomAdd.getNode(),"stroke-dasharray","10,4");
domStyle.set(phantomAdd.getNode(), "pointer-events","none");
}
},this);
updates = updates || [];
updates.forEach(function(updateEdit)
{
var objectId = updateEdit.attributes[ this.objectIdField ];
var result = editsStore.pushEdit(editsStore.UPDATE, this.url, updateEdit);
results.updateResults.push({success:result.success, error: result.error, objectId: objectId});
updatesMap[ objectId ] = updateEdit;
if(result.success)
{
var phantomUpdate = new Graphic(
updateEdit.geometry,
self._getPhantomSymbol(updateEdit.geometry, editsStore.UPDATE),
{
objectId: objectId
});
this._phantomLayer.add(phantomUpdate);
domAttr.set(phantomUpdate.getNode(),"stroke-dasharray","5,2");
domStyle.set(phantomUpdate.getNode(), "pointer-events","none");
}
},this);
deletes = deletes || [];
deletes.forEach(function(deleteEdit)
{
var objectId = deleteEdit.attributes[ this.objectIdField ];
var result = editsStore.pushEdit(editsStore.DELETE, this.url, deleteEdit);
results.deleteResults.push({success:result.success, error: result.error, objectId: objectId});
if(result.success)
{
var phantomDelete = new Graphic(
deleteEdit.geometry,
self._getPhantomSymbol(deleteEdit.geometry, editsStore.DELETE),
{
objectId: objectId
});
this._phantomLayer.add(phantomDelete);
domAttr.set(phantomDelete.getNode(),"stroke-dasharray","4,4");
domStyle.set(phantomDelete.getNode(), "pointer-events","none");
}
},this);
/* we already pushed the edits into the local store, now we let the FeatureLayer to do the local updating of the layer graphics */
setTimeout(function()
{
this._editHandler(results, adds, updatesMap, callback, errback, deferred);
self.emit(self.events.EDITS_ENQUEUED, results);
}.bind(this),0);
return deferred;
}
}; // layer.applyEdits()
// we need to identify ADDs before sending them to the server
// we assign temporary ids (using negative numbers to distinguish them from real ids)
layer._nextTempId = -1;
layer.getNextTempId = function()
{
return this._nextTempId--;
};
function _initPhantomLayer()
{
try
{
layer._phantomLayer = new GraphicsLayer({opacity:0.8});
layer._map.addLayer(layer._phantomLayer);
}
catch(err)
{
console.log("Unable to init PhantomLayer");
}
}
_initPhantomLayer();
}, // extend
_phantomSymbols: [],
_getPhantomSymbol: function(geometry, operation)
{
if( this._phantomSymbols.length == 0)
{
var color = [0,255,0,255];
var width = 1.5;
this._phantomSymbols['point'] = [];
this._phantomSymbols['point'][editsStore.ADD] = new SimpleMarkerSymbol({
"type": "esriSMS", "style": "esriSMSCross",
"xoffset": 10, "yoffset": 10,
"color": [255,255,255,0], "size": 15,
"outline": { "color": color, "width": width, "type": "esriSLS", "style": "esriSLSSolid" }
});
this._phantomSymbols['point'][editsStore.UPDATE] = new SimpleMarkerSymbol({
"type": "esriSMS", "style": "esriSMSCircle",
"xoffset": 0, "yoffset": 0,
"color": [255,255,255,0], "size": 15,
"outline": { "color": color, "width": width, "type": "esriSLS", "style": "esriSLSSolid" }
});
this._phantomSymbols['point'][editsStore.DELETE] = new SimpleMarkerSymbol({
"type": "esriSMS", "style": "esriSMSX",
"xoffset": 0, "yoffset": 0,
"color": [255,255,255,0], "size": 15,
"outline": { "color": color, "width": width, "type": "esriSLS", "style": "esriSLSSolid" }
});
this._phantomSymbols['multipoint'] = null;
this._phantomSymbols['polyline'] = [];
this._phantomSymbols['polyline'][editsStore.ADD] = new SimpleLineSymbol({
"type": "esriSLS", "style": "esriSLSSolid",
"color": color,"width": width
});
this._phantomSymbols['polyline'][editsStore.UPDATE] = new SimpleLineSymbol({
"type": "esriSLS", "style": "esriSLSSolid",
"color": color,"width": width
});
this._phantomSymbols['polyline'][editsStore.DELETE] = new SimpleLineSymbol({
"type": "esriSLS", "style": "esriSLSSolid",
"color": color,"width": width
});
this._phantomSymbols['polygon'] = [];
this._phantomSymbols['polygon'][editsStore.ADD] = new SimpleFillSymbol({
"type": "esriSFS",
"style": "esriSFSSolid",
"color": [255,255,255,0],
"outline": { "type": "esriSLS", "style": "esriSLSSolid", "color": color, "width": width }
});
this._phantomSymbols['polygon'][editsStore.UPDATE] = new SimpleFillSymbol({
"type": "esriSFS",
"style": "esriSFSSolid",
"color": [255,255,255,0],
"outline": { "type": "esriSLS", "style": "esriSLSDash", "color": color, "width": width }
});
this._phantomSymbols['polygon'][editsStore.DELETE] = new SimpleFillSymbol({
"type": "esriSFS",
"style": "esriSFSSolid",
"color": [255,255,255,0],
"outline": { "type": "esriSLS", "style": "esriSLSDot", "color": color, "width": width }
});
}
return this._phantomSymbols[ geometry.type ][ operation ];
},
/**
* Forces library into an offline state. Any edits applied during this condition will be stored locally
*/
goOffline: function()
{
console.log('going offline');
this._onlineStatus = this.OFFLINE;
},
/**
* Forces library to return to an online state. If there are pending edits,
* an attempt will be made to sync them with the remote feature server
* @param callback callback( boolean, errors )
*/
goOnline: function(callback)
{
console.log('going online');
this._onlineStatus = this.RECONNECTING;
this._replayStoredEdits(function()
{
this._onlineStatus = this.ONLINE;
callback && callback.apply(this,arguments);
}.bind(this));
//this.refresh();
},
/**
* Determines if offline or online condition exists
* @returns {string} ONLINE or OFFLINE
*/
getOnlineStatus: function()
{
return this._onlineStatus;
},
/**
* A string value representing human readable information on pending edits
* @param edit
* @returns {string}
*/
getReadableEdit: function(edit)
{
var layer = this._featureLayers[ edit.layer ];
var graphic = editsStore._deserialize(edit.graphic);
var readableGraphic = graphic.geometry.type;
var layerId = edit.layer.substring(edit.layer.lastIndexOf('/')+1);
if(layer)
readableGraphic += " [id=" + graphic.attributes[layer.objectIdField] + "]";
return "o:" + edit.operation + ", l:" + layerId + ", g:" + readableGraphic;
},
/* internal methods */
_optimizeEditsQueue: function()
{
// TODO: take care of attachments
// console.log("edits:", editsStore._retrieveEditsQueue().map(function(e){return this.getReadableEdit(e)},this));
var optimizedEdits = {};
var editCount = editsStore.pendingEditsCount();
var optimizedCount = 0;
while( editsStore.hasPendingEdits() )
{
var edit = editsStore.popFirstEdit();
var layer = this._featureLayers[ edit.layer ];
if( ! (edit.layer in optimizedEdits) )
optimizedEdits[edit.layer] = {}
var layerEdits = optimizedEdits[edit.layer];
var objectId = edit.graphic.attributes[ layer.objectIdField ];
if( !( objectId in layerEdits) )
{
// first edit we see of this feature, no optimization to apply
layerEdits[ objectId ] = edit;
optimizedCount += 1;
}
else
{
// we already have seen one edit for this same feature... we can merge the two edits in a single operation
switch( edit.operation )
{
case editsStore.ADD:
/* impossible!! */
throw("can't add the same feature twice!");
break;
case editsStore.UPDATE:
layerEdits[ objectId ].graphic = edit.graphic;
break;
case editsStore.DELETE:
if(objectId < 0)
{
delete layerEdits[ objectId ];
optimizedCount -= 1;
}
else
layerEdits[objectId].operation = editsStore.DELETE;
break;
}
}
if( Object.keys(layerEdits).length == 0 )
{
delete optimizedEdits[edit.layer]
}
}
// console.log("optimized:",optimizedEdits);
console.log("optimized", editCount, "edits into", optimizedCount,"edits of", Object.keys(optimizedEdits).length ,"layers");
return optimizedEdits;
},
_replayStoredEdits: function(callback)
{
// TODO: take care of attachments
if( editsStore.hasPendingEdits() )
{
//
// flatten the queue into unique edits for each feature, grouped by FeatureLayer
//
var optimizedEdits = this._optimizeEditsQueue();
var promises = {};
if( Object.keys(optimizedEdits).length == 0 )
{
this.emit(this.events.ALL_EDITS_SENT);
callback && callback(true, {});
return;
}
//
// send edits for each of the layers
//
for(var layerUrl in optimizedEdits)
{
if(!optimizedEdits.hasOwnProperty(layerUrl))
continue;
var layer = this._featureLayers[ layerUrl ];
var layerEdits = optimizedEdits[layerUrl];
console.assert(Object.keys(layerEdits).length != 0)
layer.__onEditsComplete = layer["onEditsComplete"];
layer["onEditsComplete"] = function() { console.log("intercepting events onEditsComplete");}
layer.__onBeforeApplyEdits = layer["onBeforeApplyEdits"];
layer["onBeforeApplyEdits"] = function() { console.log("intercepting events onBeforeApplyEdits");}
var adds = [], updates = [], deletes = [];
var tempObjectIds = [];
for(var objectId in layerEdits)
{
if(!layerEdits.hasOwnProperty(objectId))
continue;
var edit = layerEdits[objectId];
switch(edit.operation)
{
case editsStore.ADD:
for(var i=0; i<layer.graphics.length; i++)
{
var g = layer.graphics[i];
if( g.attributes[layer.objectIdField] == edit.graphic.attributes[layer.objectIdField] )
{
layer.remove(g);
break;
}
};
tempObjectIds.push(edit.graphic.attributes[ layer.objectIdField ]);
delete edit.graphic.attributes[ layer.objectIdField ];
adds.push(edit.graphic);
break;
case editsStore.UPDATE:
updates.push(edit.graphic);
break;
case editsStore.DELETE:
deletes.push(edit.graphic)
break;
}
}
// closure to keep layer and tempObjectIds values
promises[layerUrl] = function(layer,tempObjectIds)
{
// unfortunately we can't use the promise that is returned from layer._applyEdits()
// because it returns 3 result parameters (addResults,updateResults,deleteResults)
// and when we combine all promises in the dojo/promise/all() method below this only
// supports promises that return one value
var dfd = new Deferred();
layer._applyEdits(adds,updates,deletes,
function(addResults,updateResults,deleteResults)
{
layer._phantomLayer.clear();
layer["onEditsComplete"] = layer.__onEditsComplete; delete layer.__onEditsComplete;
layer["onBeforeApplyEdits"] = layer.__onBeforeApplyEdits; delete layer.__onBeforeApplyEdits;
var newObjectIds = addResults.map(function(r){ return r.objectId; });
if( layer.hasAttachments )
{
layer.replaceFeatureIds(tempObjectIds,newObjectIds,function(success)
{
if( success )
{
layer.sendStoredAttachments(function(success)
{
dfd.resolve({addResults:addResults,updateResults:updateResults,deleteResults:deleteResults}); // wrap three arguments in a single object
})
}
});
}
else
{
dfd.resolve({addResults:addResults,updateResults:updateResults,deleteResults:deleteResults}); // wrap three arguments in a single object
}
},
function(error)
{
layer["onEditsComplete"] = layer.__onEditsComplete; delete layer.__onEditsComplete;
layer["onBeforeApplyEdits"] = layer.__onBeforeApplyEdits; delete layer.__onBeforeApplyEdits;
dfd.reject(error);
}
);
return dfd;
}(layer,tempObjectIds);
}
//
// wait for all requests to finish
//
var allPromises = new all(promises);
allPromises.then(
function(responses)
{
console.log("all responses are back");
this.emit(this.events.EDITS_SENT);
this.emit(this.events.ALL_EDITS_SENT);
callback && callback(true,responses);
}.bind(this),
function(errors)
{
console.log("ERROR!!");
console.log(errors);
callback && callback(false,errors);
}.bind(this));
} // hasPendingEdits()
else
{
this.emit(this.events.ALL_EDITS_SENT);
callback && callback(true, {});
}
},
//
// methods to handle attachment uploads
//
_fieldSegment: function(name,value)
{
return "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n";
},
_fileSegment: function(fieldName,fileName,fileType,fileContent)
{
return "Content-Disposition: form-data; name=\"" + fieldName +
"\"; filename=\""+ fileName +
"\"\r\nContent-Type: " + fileType + "\r\n\r\n" +
fileContent + "\r\n";
},
_uploadAttachment: function(attachment)
{
var dfd = new Deferred();
var segments = [];
segments.push( this._fieldSegment("f","json") );
segments.push( this._fileSegment("attachment", attachment.name,attachment.contentType,attachment.content ));
var oAjaxReq = new XMLHttpRequest();
oAjaxReq.onload = function(result)
{
dfd.resolve(JSON.parse(result.target.response));
};
oAjaxReq.onerror = function(err)
{
dfd.reject(err);
}
oAjaxReq.open("post", attachment.featureId + "/addAttachment", true);
var sBoundary = "---------------------------" + Date.now().toString(16);
oAjaxReq.setRequestHeader("Content-Type", "multipart\/form-data; boundary=" + sBoundary);
oAjaxReq.sendAsBinary("--" + sBoundary + "\r\n" + segments.join("--" + sBoundary + "\r\n") + "--" + sBoundary + "--\r\n");
return dfd;
},
}); // declare
}); // define