diff --git a/README.md b/README.md index ff6a8ab..70b36b8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ offline-editor-js ================= -Experimental JavaScript library that auto-detects an offline condition and stores FeatureLayer edit activities until a connection is reestablished. No longer will offline edit be the sole domain of native SDKs! +Experimental JavaScript library that auto-detects an offline condition and stores FeatureLayer edit activities until a connection is reestablished. Works with adds, updates and deletes. + +Includes several libraries: + +- OfflineStore - overrides applyEdits() method +- OfflineTileStore - stores tiles for offline pan and zoom. +- OfflineFeatureStore - **TBD** (manages features for offline usage) ##How to use? +The easiest approach is to simply use the library to override applyEdits(): + **Step 1.** The library provides a constructor that can simply be used in place of the traditional applyEdit() method. It does all the rest of the work for you: var offlineStore = new OfflineStore(map); @@ -20,25 +28,27 @@ While the library works in Chrome, Firefox and Safari with the internet turned o ##Features +* Override the applyEdits() method. +* Can store base map tiles for offline pan and zoom. * Automatic offline/online detection. Once an offline condition exists the library starts storing the edits. And, as soon as it reconnects it will submit the updates. * Can store dozens or hundreds of edits. * Currently works with Points, Polylines and Polygons. * Indexes edits for successful/unsuccessful update validation as well as for more advanced workflows. * Monitors available storage and is configured by default to stop edits at a maximum threshold and alert that the threshold has been reached. This is intended to help prevent data loss. -##API +##OfflineStore Library ####OfflineStore(/\* Map \*/ map) * Constructor. Requires a reference to an ArcGIS API for JavaScript Map. ####applyEdits(/\* Graphic \*/ graphic,/\* FeatureLayer \*/ layer, /\* String \*/ enumValue) -* Method. +* Method. Overrides FeatureLayer.applyEdits(). ####getStore() -* Returns an array of Graphics. +* Returns an array of Graphics from localStorage. ####getLocalStoreIndex() -* Returns the index as an array of JSON objects. The objects are constructor like this: +* Returns the index as an array of JSON objects. An internal index is used to keep track of adds, deletes and updates. The objects are constructed like this: {"id": object610,"type":"add","success":"true"} @@ -68,7 +78,21 @@ While the library works in Chrome, Firefox and Safari with the internet turned o } +##OfflineTileStore Library +####OfflineTileStore() +* Constructor. Stores tiles for offline panning and zoom. + + +####storeLayer() +* Stores tiled in either localStorage or IndexedDB if it is available. Storage process is initiated by forcing a refresh on the basemap layer. + +####useIndexedDB +* Property. Manually sets whether library used localStorage or IndexedDB. Default is false. + + +####getLocalStorageUsed() +* Returns amount of storage used by the calling domain. Typical browser limit is 5MBs. ##Testing Run Jasmine's SpecRunner.html in a browser. You can find it in the /test directory. diff --git a/tiles/index.html b/tiles/index.html new file mode 100644 index 0000000..1d4aa56 --- /dev/null +++ b/tiles/index.html @@ -0,0 +1,89 @@ + + + + + + + Store images in localStorage + + + + + + + + + + + + + + + + + +
+
+ + diff --git a/tiles/proxy.php b/tiles/proxy.php new file mode 100755 index 0000000..5e3d43b --- /dev/null +++ b/tiles/proxy.php @@ -0,0 +1,137 @@ +? + * [2] http://? (with POST body) + * [3] http://??token=ABCDEFGH + * + * note: [3] is used when fetching tiles from a secured service and the + * JavaScript app sends the token instead of being set in this proxy + * + * REQUIREMENTS + * - cURL extension for PHP must be installed and loaded. To load it, + * add the following lines to your php.ini file: + * extension_dir = "/ext" + * extension = php_curl.dll + * + * - Turn OFF magic quotes for incoming GET/POST data: add/modify the + * following line to your php.ini file: + * magic_quotes_gpc = Off + * + ***************************************************************************/ + + /*************************************************************************** + * to only proxy to the sites listed in '$serverUrls' + * to proxy to any site (are you sure you want to do this?) + */ + $mustMatch = true; + + /*************************************************************************** + * ArcGIS Server services this proxy will forward requests to + * + * 'url' = location of the ArcGIS Server, either specific URL or stem + * 'matchAll' = to forward any request beginning with the URL + * to forward only the request that exactly matches the url + * 'token' = token to include for secured service, if any, otherwise leave it + * empty + */ + $serverUrls = array( + array( 'url' => 'http://sampleserver1.arcgisonline.com/ArcGIS/rest/services/', 'matchAll' => true, 'token' => '' ), + array( 'url' => 'http://services.arcgisonline.com/ArcGIS/rest/services/', 'matchAll' => true, 'token' => '' ), + array( 'url' => 'http://sampleserver2.arcgisonline.com/ArcGIS/rest/services/', 'matchAll' => true, 'token' => '' ), + array( 'url' => 'http://sampleserver1a.arcgisonline.com/arcgisoutput/', 'matchAll' => true, 'token' => '' ), + array( 'url' => 'http://sampleserver1b.arcgisonline.com/arcgisoutput/', 'matchAll' => true, 'token' => '' ), + array( 'url' => 'http://sampleserver1c.arcgisonline.com/arcgisoutput/', 'matchAll' => true, 'token' => '' ) + ); + /***************************************************************************/ + + function is_url_allowed($allowedServers, $url) { + $isOk = false; + $url = trim($url, "\/"); + for ($i = 0, $len = count($allowedServers); $i < $len; $i++) { + $value = $allowedServers[$i]; + $allowedUrl = trim($value['url'], "\/"); + if ($value['matchAll']) { + if (stripos($url, $allowedUrl) === 0) { + $isOk = $i; // array index that matched + break; + } + } + else { + if ((strcasecmp($url, $allowedUrl) == 0)) { + $isOk = $i; // array index that matched + break; + } + } + } + return $isOk; + } + + // check if the curl extension is loaded + if (!extension_loaded("curl")) { + header('Status: 500', true, 500); + echo 'cURL extension for PHP is not loaded!
Add the following lines to your php.ini file:
extension_dir = "<your-php-install-location>/ext"
extension = php_curl.dll'; + return; + } + + $targetUrl = $_SERVER['QUERY_STRING']; + if (!$targetUrl) { + header('Status: 400', true, 400); // Bad Request + echo 'Target URL is not specified!
Usage:
http://<this-proxy-url>?<target-url>'; + return; + } + + $parts = preg_split("/\?/", $targetUrl); + $targetPath = $parts[0]; + + // check if the request URL matches any of the allowed URLs + if ($mustMatch) { + $pos = is_url_allowed($serverUrls, $targetPath); + if ($pos === false) { + header('Status: 403', true, 403); // Forbidden + echo 'Target URL is not allowed!
Consult the documentation for this proxy to add the target URL to its Whitelist.'; + return; + } + } + + // add token (if any) to the url + $token = $serverUrls[$pos]['token']; + if ($token) { + $targetUrl .= (stripos($targetUrl, "?") !== false ? '&' : '?').'token='.$token; + } + + // open the curl session + $session = curl_init(); + + // set the appropriate options for this request + $options = array( + CURLOPT_URL => $targetUrl, + CURLOPT_HEADER => false, + CURLOPT_HTTPHEADER => array( + 'Content-Type: ' . $_SERVER['CONTENT_TYPE'], + 'Referer: ' . $_SERVER['HTTP_REFERER'] + ), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true + ); + + // put the POST data in the request body + $postData = file_get_contents("php://input"); + if (strlen($postData) > 0) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = $postData; + } + curl_setopt_array($session, $options); + + // make the call + $response = curl_exec($session); + $code = curl_getinfo($session, CURLINFO_HTTP_CODE); + $type = curl_getinfo($session, CURLINFO_CONTENT_TYPE); + curl_close($session); + + // set the proper Content-Type + header("Status: ".$code, true, $code); + header("Content-Type: ".$type); + + echo $response; +?> diff --git a/tiles/src/OfflineTileStore.js b/tiles/src/OfflineTileStore.js new file mode 100644 index 0000000..75b7a72 --- /dev/null +++ b/tiles/src/OfflineTileStore.js @@ -0,0 +1,203 @@ +/** + * Library for handling the storage of map tiles. + * Can use localStorage or IndexedDB. Local storage is supported in + * more browsers (caniuse.com) however it is significantly more + * limited in size. + * NOTE: Uses localStorage by default. Override with useIndexedDB property. + * NOTE: if you use IndexedDB be sure to verify if its available for use. + * @param map + * @constructor + * + * Author: Andy Gup (@agup) + */ +var OfflineTileStore = function(/* Map */ map) { + + this.ioWorker = null; + this.extend = null; + this.storage = 0; + this.map = map; + this.dbStore = null //indexedDB + this.useIndexedDB = false; + + /** + * Provides control over allow/disallow values to be + * written to storage. Can be used for testing as well. + * @type {boolean} + */ + this.allowCache = true; + + /** + * Private Local ENUMs (Constants) + * Contains required configuration info. + * @type {Object} + * @returns {*} + * @private + */ + this._localEnum = (function(){ + var values = { + TIMEOUT : 20, /* Seconds to wait for all tile requests to complete */ + LOCAL_STORAGE_MAX_LIMIT : 4.75, /* MB */ /* Most browsers offer default storage of ~5MB */ + LS_TILE_COUNT : "tile_count", + WORKER_URL : "./src/ioWorker.js" /* child process for gathering tiles */ + } + + return values; + }); + + /** + * 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; + } + + /** + * Refreshes base map and stores tiles. + * If they are already in database they are ignored. + */ + this.storeLayer = function(){ + this.tileCount = 0; + this.extendLayer(function(/* boolean */ evt){ + var ids = map.layerIds; + var layer = map.getLayer(ids[0]); + layer.refresh(); + }.bind(this)); + } + + this.extendLayer = function(callback){ + if(this.extend == null){ + var count = 0; + + var allow = this.allowCache; + var worker = this.ioWorker; + var db = database; + var indexDB = this._useIndexedDB; + + this.extend = dojo.extend(esri.layers.ArcGISTiledMapServiceLayer, { //extend ArcGISTiledMapServiceLayer to use localStorage if available, else use worker to request tile and store in local storage. + + getTileUrl : function(level, row, col) { + this.tileCount++; + count++; //count number of tiles + console.log("Count " + count); + localStorage.setItem("tile_count",count); + + var url = this._url.path + "/tile/" + level + "/" + row + "/" + col; + + if(indexDB == true){ + database.get(url,function(event,result){ + console.log("img: " + result.img + ", event.url: " + result.url); + if(event == true){ + console.log("in indexed db storage"); + return "data:image;base64," + result.img; + } + else{ + console.log("not in indexed db storage, pass url and load tile", url); + worker.postMessage([url]); + return url; + } + }.bind(this)) + } + + else{ + if(localStorage.getItem(url) !== null) { + console.log("in local storage"); + return "data:image;base64," + localStorage.getItem(url); + } + else if(allow == true) { + console.log("not in local storage, pass url and load tile", url); + worker.postMessage([url]); + return url; + } + } + }}); + callback(true); + } + else{ + callback(false); + } + } + + /** + * Load src + * 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.initLocalStorage = function() { + var tempArray = []; + var tempCount = 0; + this.dbStore = new dbStore(); + this.ioWorker = new Worker(this._localEnum().WORKER_URL); + this.ioWorker.onmessage = function(evt) { + + this.storage = this.getlocalStorageUsed(); + console.log("Worker to Parent: ", evt.data[0]); + console.log("localStorage used: " + this.getlocalStorageUsed()); + + try { + localStorage.setItem(evt.data[0], evt.data[1]); + tempCount++; + tempArray.push({url:evt.data[0],img: evt.data[1]}); + } catch(error) { + console.log('Problem adding tile to local storage. Storage might be full'); + } + + var count = parseFloat(localStorage.getItem(this._localEnum().LS_TILE_COUNT)); + if(tempCount == count){ + localStorage.setItem(this._localEnum().LS_TILE_COUNT,0); + database.add(tempArray,function(evt,err){ + evt == true ? console.log("Done") : console.log("init " + err); + }); + } + }.bind(this); + } + + this._init = function(){ + this.initLocalStorage(); + }.bind(this)() + +} diff --git a/tiles/src/_base.js b/tiles/src/_base.js new file mode 100644 index 0000000..c4f3184 --- /dev/null +++ b/tiles/src/_base.js @@ -0,0 +1,73 @@ +var Base64Utils = (function(){ + + var d={}; + d.outputTypes={ + // summary: + // Enumeration for input and output encodings. + Base64:0, Hex:1, String:2, Raw:3 + }; + + // word-based addition + d.addWords=function(/* word */a, /* word */b){ + // summary: + // add a pair of words together with rollover + var l=(a&0xFFFF)+(b&0xFFFF); + var m=(a>>16)+(b>>16)+(l>>16); + return (m<<16)|(l&0xFFFF); // word + }; + + // word-based conversion method, for efficiency sake; + // most digests operate on words, and this should be faster + // than the encoding version (which works on bytes). + var chrsz=8; // 16 for Unicode + var mask=(1<>5]|=(s.charCodeAt(i/chrsz)&mask)<<(i%32); + } + return wa; // word[] + }; + + d.wordToString=function(/* word[] */wa){ + // summary: + // convert an array of words to a string + var s=[]; + for(var i=0, l=wa.length*32; i>5]>>>(i%32))&mask)); + } + return s.join(""); // string + } + d.wordToHex=function(/* word[] */wa){ + // summary: + // convert an array of words to a hex tab + var h="0123456789abcdef", s=[]; + for(var i=0, l=wa.length*4; i>2]>>((i%4)*8+4))&0xF)+h.charAt((wa[i>>2]>>((i%4)*8))&0xF)); + } + return s.join(""); // string + } + d.wordToBase64=function(/* word[] */wa){ + // summary: + // convert an array of words to base64 encoding, should be more efficient + // than using dojox.encoding.base64 + var p="=", tab="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", s=[]; + for(var i=0, l=wa.length*4; i>2]>>8*(i%4))&0xFF)<<16)|(((wa[i+1>>2]>>8*((i+1)%4))&0xFF)<<8)|((wa[i+2>>2]>>8*((i+2)%4))&0xFF); + for(var j=0; j<4; j++){ + if(i*8+j*6>wa.length*32){ + s.push(p); + } else { + s.push(tab.charAt((t>>6*(3-j))&0x3F)); + } + } + } + return s.join(""); // string + }; + + return d; +})(); \ No newline at end of file diff --git a/tiles/src/dbStore2.js b/tiles/src/dbStore2.js new file mode 100644 index 0000000..5b3e3b5 --- /dev/null +++ b/tiles/src/dbStore2.js @@ -0,0 +1,205 @@ +/** + * Library for handling the storing of map tiles in IndexedDB. + * + * Author: Andy Gup (@agup) + */ +var dbStore = function(){ + + /** + * Internal reference to the local database + * @type {null} + * @private + */ + this._db = null; + + /** + * Private Local ENUMs (Constants) + * Contains required configuration info. + * @type {Object} + * @returns {*} + * @private + */ + this._localEnum = (function(){ + var values = { + DB_NAME : "offline_tile_store" /* Seconds to wait for all tile requests to complete */ + } + + return values; + }); + + /** + * Determines if indexedDB is supported + * @returns {boolean} + */ + this.isSupported = function(){ + window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB; + + if(!window.indexedDB){ + return false; + } + + return true; + } + + /** + * Adds an object to the database + * @param urlData + * @param callback callback(boolean, err) + */ + this.add = function(/* Array */ urlData,callback){ + try{ + var transaction = this._db.transaction(["tilepath"],"readwrite"); + + transaction.oncomplete = function(event) { + callback(true); + }; + + transaction.onerror = function(event) { + callback(false,event.target.error.message) + }; + + var objectStore = transaction.objectStore("tilepath"); + for (var i in urlData) { + var request = objectStore.add(urlData[i]); + request.onsuccess = function(event) { + // event.target.result == customerData[i].ssn; + console.log("item added to db " + event.target.result); + }; + } + + } + catch(err){ + console.log("dbstore: " + err.stack); + callback(false,err.stack); + } + } + + /** + * Retrieve a record. + * @param url + * @param callback + */ + this.get = function(/* String */ url,callback){ + if(this._db != null){ + + var index = this._db.transaction(["tilepath"]).objectStore("tilepath").index("url"); + index.get(url).onsuccess = function(event){ + var result = event.target.result; + if(result == null){ + callback(false,"not found"); + } + else{ + callback(true,result); + } + } + } + } + + /** + * Deletes entire database + * @param callback callback(boolean, err) + */ + this.deleteAll = function(callback){ + if(this._db != null){ + var transaction = this._db.transaction(["tilepath"],"readwrite").objectStore("tilepath"); + transaction.clear(); + transaction.onsuccess = function(event){ + callback(true); + } + transaction.onerror = function(err){ + callback(false,err); + } + } + else{ + callback(false,null); + } + } + + /** + * Delete an individual entry + * @param url + * @param callback callback(boolean, err) + */ + this.delete = function(/* String */ url,callback){ + if(this._db != null){ + var transaction = this._db.transaction(["tilepath"],"readwrite") + .objectStore("tilepath") + .delete(url); + transaction.onsuccess = function(event){ + callback(true); + } + transaction.onerror = function(err){ + callback(false,err); + } + } + else{ + callback(false,null); + } + } + + /** + * Provides a rough, approximate size of database in MBs. + * @param callback callback(size, null) or callback(null, error) + */ + this.size = function(callback){ + if(this._db != null){ + var size = 0; + + var transaction = this._db.transaction(["tilepath"]) + .objectStore("tilepath") + .openCursor(); + transaction.onsuccess = function(event){ + var cursor = event.target.result; + if(cursor){ + var url = cursor.value; + var json = JSON.stringify(url); + size += this.stringBytes(json); + cursor.continue(); + } + else{ + size = Math.round(((size * 2)/1024/1024) * 100)/100; + callback(size,null); + } + }.bind(this); + transaction.onerror = function(err){ + callback(null,err); + } + } + else{ + callback(null,null); + } + } + + this.stringBytes = function(str) { + var b = str.match(/[^\x00-\xff]/g); + return (str.length + (!b ? 0: b.length)); + } + + this.init = function(callback){ + + var request = indexedDB.open(this._localEnum().DB_NAME, 2); + + request.onerror = function(event) { + console.log("indexedDB error: " + event.target.errorCode); + callback(false,event.target.errorCode); + }; + request.onupgradeneeded = (function(event) { + var db = event.target.result; + + // Create an objectStore to hold information about our map tiles. + var objectStore = db.createObjectStore("tilepath", { + autoIncrement: true + }); + + // Create an index to search urls. We may have duplicates + // so we can't use a unique index. + objectStore.createIndex("url", "url", { unique: false }); + }.bind(this)) + + request.onsuccess = (function(event){ + this._db = event.target.result; + console.log("database opened successfully"); + callback(true); + }.bind(this)) + } +} \ No newline at end of file diff --git a/tiles/src/ioWorker.js b/tiles/src/ioWorker.js new file mode 100644 index 0000000..3194be3 --- /dev/null +++ b/tiles/src/ioWorker.js @@ -0,0 +1,32 @@ +// Base64 conversion functions +importScripts("_base.js"); + +// Parent to worker +onmessage = function(evt) { + getImages(evt.data); +}; + +function getImages(urls) { + for (var i = 0; i < urls.length; i++) { + var imgBytes = getImage(urls[i]); + if (imgBytes) { + var encoded = Base64Utils.wordToBase64(Base64Utils.stringToWord(imgBytes)); + postMessage([ urls[i], encoded ]); + } + } // loop +} + +function getImage(url) { + url = "../proxy.php?" + url; + + var req = new XMLHttpRequest(); + req.open("GET", url, false); + req.overrideMimeType("text/plain; charset=x-user-defined"); + req.send(null); + + if (req.status != 200) { + return ""; + } + + return req.responseText; +} \ No newline at end of file