move track upload dialogs into drawer and make upload work with new backend
[lantea.git] / js / ui.js
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 // Get the best-available objects for indexedDB and requestAnimationFrame.
6 window.indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB;
7 window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
8
9 var mainDB;
10 var gAppInitDone = false;
11 var gUIHideCountdown = 0;
12 var gWaitCounter = 0;
13 var gTrackUpdateInterval;
14 var gAction, gActionLabel;
15 var gBackendURL = "https://backend.lantea.kairo.at";
16 var gAuthClientID = "lantea";
17 var gOSMAPIURL = "https://api.openstreetmap.org/";
18 var gOSMOAuthData = {
19     oauth_consumer_key: "6jjWwlbhGqyYeCdlFE1lTGG6IRGOv1yKpFxkcq2z",
20     oauth_secret: "A21gUeDM6mdoQgbA9uF7zJ13sbUQrNG7QQ4oSrKA",
21     url: "https://www.openstreetmap.org",
22     landing: "auth-done.html",
23 }
24
25 window.onload = function() {
26   if (/\/login\.html/.test(window.location)) {
27     // If we are in the login window, call a function to complete the process and don't do anything else here.
28     completeLoginWindow();
29     return;
30   }
31   gAction = document.getElementById("action");
32   gActionLabel = document.getElementById("actionlabel");
33
34   var mSel = document.getElementById("mapSelector");
35   for (var mapStyle in gMapStyles) {
36     var opt = document.createElement("option");
37     opt.value = mapStyle;
38     opt.text = gMapStyles[mapStyle].name;
39     mSel.add(opt, null);
40   }
41
42   var areas = document.getElementsByClassName("overlayArea");
43   for (var i = 0; i <= areas.length - 1; i++) {
44     areas[i].addEventListener("mouseup", uiEvHandler, false);
45     areas[i].addEventListener("mousemove", uiEvHandler, false);
46     areas[i].addEventListener("mousedown", uiEvHandler, false);
47     areas[i].addEventListener("mouseout", uiEvHandler, false);
48
49     areas[i].addEventListener("touchstart", uiEvHandler, false);
50     areas[i].addEventListener("touchmove", uiEvHandler, false);
51     areas[i].addEventListener("touchend", uiEvHandler, false);
52     areas[i].addEventListener("touchcancel", uiEvHandler, false);
53     areas[i].addEventListener("touchleave", uiEvHandler, false);
54   }
55
56   document.getElementById("body").addEventListener("keydown", uiEvHandler, false);
57
58   if (navigator.platform.length == "") {
59     // For Firefox OS, don't display the "save" button.
60     // Do this by setting the debugHide class for testing in debug mode.
61     document.getElementById("saveTrackButton").classList.add("debugHide");
62   }
63
64   // Set backend URL in a way that it works for testing on localhost as well as
65   // both the lantea.kairo.at and lantea-dev.kairo.at deployments.
66   if (window.location.host == "localhost") {
67     gBackendURL = window.location.protocol + '//' + window.location.host + "/lantea-backend/";
68   }
69   else {
70     gBackendURL = window.location.protocol + '//' + "backend." + window.location.host + "/";
71   }
72   // Make sure to use a different login client ID for the -dev setup.
73   if (/\-dev\./.test(window.location.host)) {
74     gAuthClientID += "-dev";
75   }
76
77   // Set up the login area.
78   document.getElementById("loginbtn").onclick = startLogin;
79   document.getElementById("logoutbtn").onclick = doLogout;
80   prepareLoginButton(function() {
81     // Anything that needs the backend should only be triggered from in here.
82     // That makes sure that the first call the the backend is oauth_state and no other is running in parallel.
83     // If we call multiple backend methods at once and no session is open, we create multiple sessions, which calls for confusion later on.
84
85     // Call any UI preparation that needs the backend.
86   });
87
88   if (gDebug) {
89     // Note that GPX upload returns an error 500 on the dev API right now.
90     gOSMAPIURL = "http://api06.dev.openstreetmap.org/";
91   }
92
93   gAction.addEventListener("dbinit-done", initMap, false);
94   gAction.addEventListener("mapinit-done", postInit, false);
95   console.log("starting DB init...");
96   initDB();
97 }
98
99 function postInit(aEvent) {
100   gAction.removeEventListener(aEvent.type, postInit, false);
101   console.log("init done, draw map.");
102   gMapPrefsLoaded = true;
103   gAppInitDone = true;
104   //gMap.resizeAndDraw();  <-- HACK: This triggers bug 1001853, work around with a delay.
105   window.setTimeout(gMap.resizeAndDraw, 100);
106   gActionLabel.textContent = "";
107   gAction.style.display = "none";
108   setTracking(document.getElementById("trackCheckbox"));
109   gPrefs.get(gDebug ? "osm_dev_user" : "osm_user", function(aValue) {
110     if (aValue) {
111       document.getElementById("uploadUser").value = aValue;
112       document.getElementById("uploadTrackButton").disabled = false;
113     }
114   });
115   gPrefs.get(gDebug ? "osm_dev_pwd" : "osm_pwd", function(aValue) {
116     var upwd = document.getElementById("uploadPwd");
117     if (aValue)
118       document.getElementById("uploadPwd").value = aValue;
119   });
120 }
121
122 window.onresize = function() {
123   gMap.resizeAndDraw();
124 }
125
126 function startLogin() {
127   var authURL = authData["url"] + "authorize?response_type=code&client_id=" + gAuthClientID + "&scope=email" +
128                 "&state=" + authData["state"] + "&redirect_uri=" + encodeURIComponent(getRedirectURI());
129   if (window.open(authURL, "KaiRoAuth", 'height=450,width=600')) {
130     console.log("Sign In window open.");
131   }
132   else {
133     console.log("Opening Sign In window failed.");
134   }
135 }
136
137 function getRedirectURI() {
138   return window.location.protocol + '//' + window.location.host + window.location.pathname.replace("index.html", "") + "login.html";
139 }
140
141 function doLogout() {
142   fetchBackend("logout", "GET", null,
143      function(aResult, aStatus) {
144         if (aStatus < 400) {
145           prepareLoginButton();
146         }
147         else {
148           console.log("Backend issue trying to log out.");
149         }
150       },
151       {}
152   );
153 }
154
155 function prepareLoginButton(aCallback) {
156   fetchBackend("oauth_state", "GET", null,
157       function(aResult, aStatus) {
158         if (aStatus == 200) {
159           if (aResult["logged_in"]) {
160             userData = {
161               "email": aResult["email"],
162               "permissions": aResult["permissions"],
163             };
164             authData = null;
165             displayLogin();
166           }
167           else {
168             authData = {"state": aResult["state"], "url": aResult["url"]};
169             userData = null;
170             displayLogout();
171           }
172         }
173         else {
174           console.log("Backend error " + aStatus + " fetching OAuth state: " + aResult["message"]);
175         }
176         if (aCallback) { aCallback(); }
177       },
178       {}
179   );
180 }
181
182 function completeLoginWindow() {
183   if (window.opener) {
184     window.opener.finishLogin(getParameterByName("code"), getParameterByName("state"));
185     window.close();
186   }
187   else {
188     document.getElementById("logininfo").textContent = "You have called this document outside of the login flow, which is not supported.";
189   }
190 }
191
192 function finishLogin(aCode, aState) {
193   if (aState == authData["state"]) {
194     fetchBackend("login?code=" + aCode + "&state=" + aState + "&redirect_uri=" + encodeURIComponent(getRedirectURI()), "GET", null,
195         function(aResult, aStatus) {
196           if (aStatus == 200) {
197             userData = {
198               "email": aResult["email"],
199               "permissions": aResult["permissions"],
200             };
201             displayLogin();
202           }
203           else {
204             console.log("Login error " + aStatus + ": " + aResult["message"]);
205             prepareLoginButton();
206           }
207         },
208         {}
209     );
210   }
211   else {
212     console.log("Login state did not match, not continuing with login.");
213   }
214 }
215
216 function displayLogin() {
217   document.getElementById("loginbtn").classList.add("hidden");
218   document.getElementById("logindesc").classList.add("hidden");
219   document.getElementById("username").classList.remove("hidden");
220   document.getElementById("username").textContent = userData.email;
221   document.getElementById("uploadTrackButton").disabled = false;
222   document.getElementById("logoutbtn").classList.remove("hidden");
223 }
224
225 function displayLogout() {
226   document.getElementById("logoutbtn").classList.add("hidden");
227   document.getElementById("username").classList.add("hidden");
228   document.getElementById("username").textContent = "";
229   document.getElementById("uploadTrackButton").disabled = true;
230   document.getElementById("loginbtn").classList.remove("hidden");
231   document.getElementById("logindesc").classList.remove("hidden");
232 }
233
234 function initDB(aEvent) {
235   // Open DB.
236   if (aEvent)
237     gAction.removeEventListener(aEvent.type, initDB, false);
238   var request = window.indexedDB.open("MainDB-lantea", 2);
239   request.onerror = function(event) {
240     // Errors can be handled here. Error codes explain in:
241     // https://developer.mozilla.org/en/IndexedDB/IDBDatabaseException#Constants
242     if (gDebug)
243       console.log("error opening mainDB: " + event.target.errorCode);
244   };
245   request.onsuccess = function(event) {
246     mainDB = request.result;
247     var throwEv = new CustomEvent("dbinit-done");
248     gAction.dispatchEvent(throwEv);
249   };
250   request.onupgradeneeded = function(event) {
251     mainDB = request.result;
252     var ver = mainDB.version || 0; // version is empty string for a new DB
253     if (gDebug)
254       console.log("mainDB has version " + ver + ", upgrade needed.");
255     if (!mainDB.objectStoreNames.contains("prefs")) {
256       // Create a "prefs" objectStore.
257       var prefsStore = mainDB.createObjectStore("prefs");
258     }
259     if (!mainDB.objectStoreNames.contains("track")) {
260       // Create a "track" objectStore.
261       var trackStore = mainDB.createObjectStore("track", {autoIncrement: true});
262     }
263     if (!mainDB.objectStoreNames.contains("tilecache")) {
264       // Create a "tilecache" objectStore.
265       var tilecacheStore = mainDB.createObjectStore("tilecache");
266     }
267     mainDB.onversionchange = function(event) {
268       mainDB.close();
269       mainDB = undefined;
270       initDB();
271     };
272   };
273 }
274
275 function showUI() {
276   if (gUIHideCountdown <= 0) {
277     var areas = document.getElementsByClassName('overlayArea');
278     for (var i = 0; i <= areas.length - 1; i++) {
279       areas[i].classList.remove("hidden");
280     }
281     setTimeout(maybeHideUI, 1000);
282   }
283   gUIHideCountdown = 5;
284 }
285
286 function maybeHideUI() {
287   gUIHideCountdown--;
288   if (gUIHideCountdown <= 0) {
289     var areas = document.getElementsByClassName('overlayArea');
290     for (var i = 0; i <= areas.length - 1; i++) {
291       areas[i].classList.add("hidden");
292     }
293   }
294   else {
295     setTimeout(maybeHideUI, 1000);
296   }
297 }
298
299 function updateTrackInfo() {
300   document.getElementById("trackLengthNum").textContent = calcTrackLength().toFixed(1);
301   var duration = calcTrackDuration();
302   var durationM = Math.round(duration/60);
303   var durationH = Math.floor(durationM/60); durationM = durationM - durationH * 60;
304   document.getElementById("trackDurationH").style.display = durationH ? "inline" : "none";
305   document.getElementById("trackDurationHNum").textContent = durationH;
306   document.getElementById("trackDurationMNum").textContent = durationM;
307 }
308
309 function toggleTrackArea() {
310   var fs = document.getElementById("trackArea");
311   if (fs.classList.contains("hidden")) {
312     fs.classList.remove("hidden");
313     showUI();
314     gTrackUpdateInterval = setInterval(updateTrackInfo, 1000);
315   }
316   else {
317     clearInterval(gTrackUpdateInterval);
318     fs.classList.add("hidden");
319   }
320 }
321
322 function toggleSettings() {
323   var fs = document.getElementById("settingsArea");
324   if (fs.classList.contains("hidden")) {
325     fs.classList.remove("hidden");
326     showUI();
327   }
328   else {
329     fs.classList.add("hidden");
330   }
331 }
332
333 function toggleFullscreen() {
334   if ((document.fullScreenElement && document.fullScreenElement !== null) ||
335       (document.mozFullScreenElement && document.mozFullScreenElement !== null) ||
336       (document.webkitFullScreenElement && document.webkitFullScreenElement !== null)) {
337     if (document.cancelFullScreen) {
338       document.cancelFullScreen();
339     } else if (document.mozCancelFullScreen) {
340       document.mozCancelFullScreen();
341     } else if (document.webkitCancelFullScreen) {
342       document.webkitCancelFullScreen();
343     }
344   }
345   else {
346     var elem = document.getElementById("body");
347     if (elem.requestFullScreen) {
348       elem.requestFullScreen();
349     } else if (elem.mozRequestFullScreen) {
350       elem.mozRequestFullScreen();
351     } else if (elem.webkitRequestFullScreen) {
352       elem.webkitRequestFullScreen();
353     }
354   }
355 }
356
357 function showUploadDialog() {
358   var dia = document.getElementById("trackDialogArea");
359   var areas = dia.children;
360   for (var i = 0; i <= areas.length - 1; i++) {
361     areas[i].style.display = "none";
362   }
363   document.getElementById("uploadDialog").style.display = "block";
364   document.getElementById("uploadTrackButton").disabled = true;
365   dia.classList.remove("hidden");
366 }
367
368 function showGLWarningDialog() {
369   var dia = document.getElementById("dialogArea");
370   var areas = dia.children;
371   for (var i = 0; i <= areas.length - 1; i++) {
372     areas[i].style.display = "none";
373   }
374   document.getElementById("noGLwarning").style.display = "block";
375   dia.classList.remove("hidden");
376 }
377
378 function cancelTrackDialog() {
379   document.getElementById("trackDialogArea").classList.add("hidden");
380   document.getElementById("uploadTrackButton").disabled = false;
381 }
382
383 var uiEvHandler = {
384   handleEvent: function(aEvent) {
385     var touchEvent = aEvent.type.indexOf('touch') != -1;
386
387     switch (aEvent.type) {
388       case "mousedown":
389       case "touchstart":
390       case "mousemove":
391       case "touchmove":
392       case "mouseup":
393       case "touchend":
394       case "keydown":
395         showUI();
396         break;
397     }
398   }
399 };
400
401 function setUploadField(aField) {
402   switch (aField.id) {
403     case "uploadUser":
404       gPrefs.set(gDebug ? "osm_dev_user" : "osm_user", aField.value);
405       document.getElementById("uploadTrackButton").disabled = !aField.value.length;
406       break;
407     case "uploadPwd":
408       gPrefs.set(gDebug ? "osm_dev_pwd" : "osm_pwd", aField.value);
409       break;
410   }
411 }
412
413 function makeISOString(aTimestamp) {
414   // ISO time format is YYYY-MM-DDTHH:mm:ssZ
415   var tsDate = new Date(aTimestamp);
416   // Note that .getUTCMonth() returns a number between 0 and 11 (0 for January)!
417   return tsDate.getUTCFullYear() + "-" +
418          (tsDate.getUTCMonth() < 9 ? "0" : "") + (tsDate.getUTCMonth() + 1 ) + "-" +
419          (tsDate.getUTCDate() < 10 ? "0" : "") + tsDate.getUTCDate() + "T" +
420          (tsDate.getUTCHours() < 10 ? "0" : "") + tsDate.getUTCHours() + ":" +
421          (tsDate.getUTCMinutes() < 10 ? "0" : "") + tsDate.getUTCMinutes() + ":" +
422          (tsDate.getUTCSeconds() < 10 ? "0" : "") + tsDate.getUTCSeconds() + "Z";
423 }
424
425 function convertTrack(aTargetFormat) {
426   var out = "";
427   switch (aTargetFormat) {
428     case "gpx":
429       out += '<?xml version="1.0" encoding="UTF-8" ?>' + "\n\n";
430       out += '<gpx version="1.0" creator="Lantea" xmlns="http://www.topografix.com/GPX/1/0">' + "\n";
431       if (gTrack.length) {
432         out += '  <trk>' + "\n";
433         out += '    <trkseg>' + "\n";
434         for (var i = 0; i < gTrack.length; i++) {
435           if (gTrack[i].beginSegment && i > 0) {
436             out += '    </trkseg>' + "\n";
437             out += '    <trkseg>' + "\n";
438           }
439           out += '      <trkpt lat="' + gTrack[i].coords.latitude + '" lon="' +
440                                         gTrack[i].coords.longitude + '">' + "\n";
441           if (gTrack[i].coords.altitude) {
442             out += '        <ele>' + gTrack[i].coords.altitude + '</ele>' + "\n";
443           }
444           out += '        <time>' + makeISOString(gTrack[i].time) + '</time>' + "\n";
445           out += '      </trkpt>' + "\n";
446         }
447         out += '    </trkseg>' + "\n";
448         out += '  </trk>' + "\n";
449       }
450       out += '</gpx>' + "\n";
451       break;
452     case "json":
453       out = JSON.stringify(gTrack);
454       break;
455     default:
456       break;
457   }
458   return out;
459 }
460
461 function saveTrack() {
462   if (gTrack.length) {
463     var outDataURI = "data:application/gpx+xml," +
464                      encodeURIComponent(convertTrack("gpx"));
465     window.open(outDataURI, 'GPX Track');
466   }
467 }
468
469 function saveTrackDump() {
470   if (gTrack.length) {
471     var outDataURI = "data:application/json," +
472                      encodeURIComponent(convertTrack("json"));
473     window.open(outDataURI, 'JSON dump');
474   }
475 }
476
477 function uploadTrack() {
478   // Hide all areas in the dialog.
479   var dia = document.getElementById("trackDialogArea");
480   var areas = dia.children;
481   for (var i = 0; i <= areas.length - 1; i++) {
482     areas[i].style.display = "none";
483   }
484   // Reset all the fields in the status area.
485   document.getElementById("uploadStatusCloseButton").disabled = true;
486   document.getElementById("uploadInProgress").style.display = "block";
487   document.getElementById("uploadSuccess").style.display = "none";
488   document.getElementById("uploadFailed").style.display = "none";
489   document.getElementById("uploadError").style.display = "none";
490   document.getElementById("uploadErrorMsg").textContent = "";
491   // Now show the status area.
492   document.getElementById("uploadStatus").style.display = "block";
493
494   // Assemble field to post to the backend.
495   var formData = new FormData();
496   formData.append("jsondata", convertTrack("json"));
497   var desc = document.getElementById("uploadDesc").value;
498   formData.append("comment",
499                   desc.length ? desc : "Track recorded via Lantea Maps");
500   //formData.append("devicename", "");
501   formData.append("public",
502                   document.getElementById("uploadPublic").value);
503
504   fetchBackend("save_track", "POST", formData,
505     function(aResult, aStatusCode) {
506       if (aStatusCode >= 400) {
507         reportUploadStatus(false, aResult);
508       }
509       else {
510         reportUploadStatus(true);
511       }
512     }
513   );
514 }
515
516 function reportUploadStatus(aSuccess, aMessage) {
517   document.getElementById("uploadStatusCloseButton").disabled = false;
518   document.getElementById("uploadInProgress").style.display = "none";
519   if (aSuccess) {
520     document.getElementById("uploadSuccess").style.display = "block";
521   }
522   else if (aMessage) {
523     document.getElementById("uploadErrorMsg").textContent = aMessage;
524     document.getElementById("uploadError").style.display = "block";
525   }
526   else {
527     document.getElementById("uploadFailed").style.display = "block";
528   }
529 }
530
531 var gPrefs = {
532   objStore: "prefs",
533
534   get: function(aKey, aCallback) {
535     if (!mainDB)
536       return;
537     var transaction = mainDB.transaction([this.objStore]);
538     var request = transaction.objectStore(this.objStore).get(aKey);
539     request.onsuccess = function(event) {
540       aCallback(request.result, event);
541     };
542     request.onerror = function(event) {
543       // Errors can be handled here.
544       aCallback(undefined, event);
545     };
546   },
547
548   set: function(aKey, aValue, aCallback) {
549     if (!mainDB)
550       return;
551     var success = false;
552     var transaction = mainDB.transaction([this.objStore], "readwrite");
553     var objStore = transaction.objectStore(this.objStore);
554     var request = objStore.put(aValue, aKey);
555     request.onsuccess = function(event) {
556       success = true;
557       if (aCallback)
558         aCallback(success, event);
559     };
560     request.onerror = function(event) {
561       // Errors can be handled here.
562       if (aCallback)
563         aCallback(success, event);
564     };
565   },
566
567   unset: function(aKey, aCallback) {
568     if (!mainDB)
569       return;
570     var success = false;
571     var transaction = mainDB.transaction([this.objStore], "readwrite");
572     var request = transaction.objectStore(this.objStore).delete(aKey);
573     request.onsuccess = function(event) {
574       success = true;
575       if (aCallback)
576         aCallback(success, event);
577     };
578     request.onerror = function(event) {
579       // Errors can be handled here.
580       if (aCallback)
581         aCallback(success, event);
582     }
583   }
584 };
585
586 var gTrackStore = {
587   objStore: "track",
588
589   getList: function(aCallback) {
590     if (!mainDB)
591       return;
592     var transaction = mainDB.transaction([this.objStore]);
593     var objStore = transaction.objectStore(this.objStore);
594     if (objStore.getAll) { // currently Mozilla-specific
595       objStore.getAll().onsuccess = function(event) {
596         aCallback(event.target.result);
597       };
598     }
599     else { // Use cursor (standard method).
600       var tPoints = [];
601       objStore.openCursor().onsuccess = function(event) {
602         var cursor = event.target.result;
603         if (cursor) {
604           tPoints.push(cursor.value);
605           cursor.continue();
606         }
607         else {
608           aCallback(tPoints);
609         }
610       };
611     }
612   },
613
614   getListStepped: function(aCallback) {
615     if (!mainDB)
616       return;
617     var transaction = mainDB.transaction([this.objStore]);
618     var objStore = transaction.objectStore(this.objStore);
619     // Use cursor in reverse direction (so we get the most recent position first)
620     objStore.openCursor(null, "prev").onsuccess = function(event) {
621       var cursor = event.target.result;
622       if (cursor) {
623         aCallback(cursor.value);
624         cursor.continue();
625       }
626       else {
627         aCallback(null);
628       }
629     };
630   },
631
632   push: function(aValue, aCallback) {
633     if (!mainDB)
634       return;
635     var transaction = mainDB.transaction([this.objStore], "readwrite");
636     var objStore = transaction.objectStore(this.objStore);
637     var request = objStore.add(aValue);
638     request.onsuccess = function(event) {
639       if (aCallback)
640         aCallback(request.result, event);
641     };
642     request.onerror = function(event) {
643       // Errors can be handled here.
644       if (aCallback)
645         aCallback(false, event);
646     };
647   },
648
649   clear: function(aCallback) {
650     if (!mainDB)
651       return;
652     var success = false;
653     var transaction = mainDB.transaction([this.objStore], "readwrite");
654     var request = transaction.objectStore(this.objStore).clear();
655     request.onsuccess = function(event) {
656       success = true;
657       if (aCallback)
658         aCallback(success, event);
659     };
660     request.onerror = function(event) {
661       // Errors can be handled here.
662       if (aCallback)
663         aCallback(success, event);
664     }
665   }
666 };
667
668 function fetchBackend(aEndpoint, aMethod, aSendData, aCallback, aCallbackForwards) {
669   var XHR = new XMLHttpRequest();
670   XHR.onreadystatechange = function() {
671     if (XHR.readyState == 4) {
672       // State says we are fully loaded.
673       var result = {};
674       if (XHR.getResponseHeader("Content-Type") == "application/json") {
675         // Got a JSON object, see if we have success.
676         result = JSON.parse(XHR.responseText);
677       }
678       else {
679         result = XHR.responseText;
680       }
681       aCallback(result, XHR.status, aCallbackForwards);
682     }
683   };
684   XHR.open(aMethod, gBackendURL + aEndpoint, true);
685   XHR.withCredentials = "true";
686   //XHR.setRequestHeader("Accept", "application/json");
687   try {
688     XHR.send(aSendData); // Send actual form data.
689   }
690   catch (e) {
691     aCallback(e, 500, aCallbackForwards);
692   }
693 }
694
695 function getParameterByName(aName) {
696   // from http://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
697   name = aName.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
698   var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
699       results = regex.exec(location.search);
700   return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
701 }