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