diff --git a/.tests/tests/InternalAPITest.php b/.tests/tests/InternalAPITest.php
index b56c497..322884e 100644
--- a/.tests/tests/InternalAPITest.php
+++ b/.tests/tests/InternalAPITest.php
@@ -716,8 +716,8 @@ class InternalAPITest extends UloggerAPITestCase {
$this->assertEquals(200, $response->getStatusCode(), "Unexpected status code");
$json = json_decode($response->getBody());
$this->assertNotNull($json, "JSON object is null");
- $this->assertEquals((int) $json->error, 1, "Wrong error status");
- $this->assertEquals((string) $json->message, $lang["servererror"], "Wrong error message");
+ $this->assertEquals(1, (int) $json->error, "Wrong error status");
+ $this->assertEquals($lang["notauthorized"], (string) $json->message, "Wrong error message");
}
public function testHandleTrackUpdate() {
diff --git a/index.php b/index.php
index 6812991..cbf0743 100644
--- a/index.php
+++ b/index.php
@@ -63,11 +63,11 @@
= htmlspecialchars($auth->user->login) ?>
-
diff --git a/js/src/mainviewmodel.js b/js/src/mainviewmodel.js
index 270151c..73689c7 100644
--- a/js/src/mainviewmodel.js
+++ b/js/src/mainviewmodel.js
@@ -18,6 +18,8 @@
*/
import ViewModel from './viewmodel.js';
+import { config } from './initializer.js';
+import uUtils from './utils.js';
const hiddenClass = 'menu-hidden';
@@ -29,11 +31,15 @@ export default class MainViewModel extends ViewModel {
constructor(state) {
super({
onMenuToggle: null,
- onShowUserMenu: null
+ onShowUserMenu: null,
+ onLogin: null,
+ onLogout: null
});
this.state = state;
this.model.onMenuToggle = () => this.toggleSideMenu();
this.model.onShowUserMenu = () => this.toggleUserMenu();
+ this.model.onLogin = () => MainViewModel.login();
+ this.model.onLogout = () => MainViewModel.logout();
this.hideUserMenuCallback = (e) => this.hideUserMenu(e);
this.menuEl = document.querySelector('#menu');
this.userMenuEl = document.querySelector('#user-menu');
@@ -80,4 +86,16 @@ export default class MainViewModel extends ViewModel {
}
}
+ static login() {
+ uUtils.openUrl(`login.php${window.location.hash}`);
+ }
+
+ static logout() {
+ let url = 'utils/logout.php';
+ if (!config.requireAuth) {
+ url += `?hash=${window.location.hash.replace('#', '')}`;
+ }
+ uUtils.openUrl(url);
+ }
+
}
diff --git a/js/src/mapapi/api_gmaps.js b/js/src/mapapi/api_gmaps.js
index 100a1c5..c4dd038 100644
--- a/js/src/mapapi/api_gmaps.js
+++ b/js/src/mapapi/api_gmaps.js
@@ -110,6 +110,9 @@ export default class GoogleMapsApi {
this.popup.addListener('closeclick', () => {
this.popupClose();
});
+ this.saveState = () => {
+ this.viewModel.state.mapParams = this.getState();
+ };
}
/**
@@ -135,9 +138,12 @@ export default class GoogleMapsApi {
if (!track || !track.hasPositions) {
return Promise.resolve();
}
+ google.maps.event.clearListeners(this.map, 'idle');
const promise = new Promise((resolve) => {
google.maps.event.addListenerOnce(this.map, 'tilesloaded', () => {
console.log('tilesloaded');
+ this.saveState();
+ this.map.addListener('idle', this.saveState);
resolve();
})
});
@@ -368,6 +374,32 @@ export default class GoogleMapsApi {
return 10000;
}
+ /**
+ * Set map state
+ * Note: ignores rotation
+ * @param {MapParams} state
+ */
+ updateState(state) {
+ this.map.setCenter({ lat: state.center[0], lng: state.center[1] });
+ this.map.setZoom(state.zoom);
+ }
+
+ /**
+ * Get map state
+ * Note: ignores rotation
+ * @return {MapParams|null}
+ */
+ getState() {
+ if (this.map) {
+ const center = this.map.getCenter();
+ return {
+ center: [ center.lat(), center.lng() ],
+ zoom: this.map.getZoom(),
+ rotation: 0
+ };
+ }
+ return null;
+ }
}
/** @type {boolean} */
diff --git a/js/src/mapapi/api_openlayers.js b/js/src/mapapi/api_openlayers.js
index 10eb31a..5793c29 100644
--- a/js/src/mapapi/api_openlayers.js
+++ b/js/src/mapapi/api_openlayers.js
@@ -142,6 +142,10 @@ export default class OpenLayersApi {
this.viewModel.model.markerOver = null;
}
});
+
+ this.saveState = () => {
+ this.viewModel.state.mapParams = this.getState();
+ };
}
/**
@@ -430,9 +434,12 @@ export default class OpenLayersApi {
if (!track || !track.hasPositions) {
return Promise.resolve();
}
+ this.map.un('moveend', this.saveState);
const promise = new Promise((resolve) => {
this.map.once('rendercomplete', () => {
console.log('rendercomplete');
+ this.saveState();
+ this.map.on('moveend', this.saveState);
resolve();
});
});
@@ -484,15 +491,13 @@ export default class OpenLayersApi {
}
/**
- * Fit to extent, zoom out if needed
+ * Fit to extent, respect max zoom
* @param {Array.
} extent
* @return {Array.}
*/
fitToExtent(extent) {
- this.map.getView().fit(extent, { padding: [ 40, 10, 10, 10 ] });
- const zoom = this.map.getView().getZoom();
- if (zoom > OpenLayersApi.ZOOM_MAX) {
- this.map.getView().setZoom(OpenLayersApi.ZOOM_MAX);
+ this.map.getView().fit(extent, { padding: [ 40, 10, 10, 10 ], maxZoom: OpenLayersApi.ZOOM_MAX });
+ if (this.map.getView().getZoom() === OpenLayersApi.ZOOM_MAX) {
extent = this.map.getView().calculateExtent(this.map.getSize());
}
return extent;
@@ -597,10 +602,10 @@ export default class OpenLayersApi {
}
/**
- * Zoom to track extent
+ * Zoom to track extent, respect max zoom
*/
zoomToExtent() {
- this.map.getView().fit(this.layerMarkers.getSource().getExtent());
+ this.map.getView().fit(this.layerMarkers.getSource().getExtent(), { maxZoom: OpenLayersApi.ZOOM_MAX });
}
/**
@@ -630,5 +635,32 @@ export default class OpenLayersApi {
extentImg.style.width = '60%';
return extentImg;
}
+
+ /**
+ * Set map state
+ * @param {MapParams} state
+ */
+ updateState(state) {
+ this.map.getView().setCenter(state.center);
+ this.map.getView().setZoom(state.zoom);
+ this.map.getView().setRotation(state.rotation);
+ }
+
+ /**
+ * Get map state
+ * @return {MapParams|null}
+ */
+ getState() {
+ const view = this.map ? this.map.getView() : null;
+ if (view) {
+ return {
+ center: view.getCenter(),
+ zoom: view.getZoom(),
+ rotation: view.getRotation()
+ };
+ }
+ return null;
+ }
}
+/** @type {number} */
OpenLayersApi.ZOOM_MAX = 20;
diff --git a/js/src/mapviewmodel.js b/js/src/mapviewmodel.js
index fe33509..a8e3e22 100644
--- a/js/src/mapviewmodel.js
+++ b/js/src/mapviewmodel.js
@@ -41,6 +41,15 @@ import uUtils from './utils.js';
* @property {function} zoomToExtent
* @property {function} zoomToBounds
* @property {function} updateSize
+ * @property {function} updateState
+ */
+
+
+/**
+ * @typedef {Object} MapParams
+ * @property {number[]} center
+ * @property {number} zoom
+ * @property {number} rotation
*/
/**
@@ -116,11 +125,13 @@ export default class MapViewModel extends ViewModel {
}
onReady() {
- if (this.savedBounds) {
- this.api.zoomToBounds(this.savedBounds);
- }
if (this.state.currentTrack) {
- this.displayTrack(this.state.currentTrack, this.savedBounds === null);
+ let update = true;
+ if (this.savedBounds) {
+ this.api.zoomToBounds(this.savedBounds);
+ update = false;
+ }
+ this.displayTrack(this.state.currentTrack, update);
}
}
@@ -141,10 +152,34 @@ export default class MapViewModel extends ViewModel {
this.displayTrack(track, true);
}
});
+ this.state.onChanged('history', () => {
+ const history = this.state.history;
+ if (this.api && history && !history.trackId) {
+ if (history.mapApi) {
+ config.mapApi = history.mapApi;
+ } else {
+ if (history.mapParams) {
+ this.api.updateState(history.mapParams);
+ } else {
+ this.api.zoomToExtent();
+ }
+ this.state.history = null;
+ }
+ }
+ });
}
+ /**
+ * @param {uTrack} track Track to display
+ * @param {boolean} update Should update map view
+ */
displayTrack(track, update) {
this.state.jobStart();
+ if (update && this.state.history && this.state.history.mapParams) {
+ this.api.updateState(this.state.history.mapParams);
+ update = false;
+ }
+ this.state.history = null;
this.api.displayTrack(track, update)
.finally(() => this.state.jobStop());
}
diff --git a/js/src/permalink.js b/js/src/permalink.js
new file mode 100644
index 0000000..2a3d932
--- /dev/null
+++ b/js/src/permalink.js
@@ -0,0 +1,165 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2020 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import { lang as $, config } from './initializer.js';
+import uTrack from './track.js';
+import uUtils from './utils.js';
+
+/**
+ * @typedef {Object} PermalinkState
+ * @property {string} title
+ * @property {number|null} userId
+ * @property {number|null} trackId
+ * @property {string|null} mapApi
+ * @property {MapParams|null} mapParams
+ */
+
+export default class uPermalink {
+
+ /**
+ * @param {uState} state
+ */
+ constructor(state) {
+ this.state = state;
+ this.skipPush = false;
+ }
+
+ /**
+ * @return {uPermalink}
+ */
+ init() {
+ this.state.onChanged('mapParams', () => this.pushState());
+ window.addEventListener('popstate', (event) => {
+ if (event.state === null) {
+ return;
+ }
+ const track = this.state.currentTrack;
+ const user = this.state.currentUser;
+ // remove elements that won't be updated
+ const state = {
+ title: event.state.title,
+ userId: (user && user.id === event.state.userId) ? null : event.state.userId,
+ trackId: (track && track.id === event.state.trackId) ? null : event.state.trackId,
+ mapApi: config.mapApi === event.state.mapApi ? null : event.state.mapApi,
+ mapParams: event.state.mapParams
+ }
+ this.onPop(state);
+ this.skipPush = true;
+ });
+ return this;
+ }
+
+ /**
+ * @return {Promise}
+ */
+ static parseHash() {
+ return uPermalink.parse(window.location.hash);
+ }
+
+ /**
+ * Parse URL hash string
+ * @param {string} hash
+ * @return {Promise} Permalink state or null if not parsable
+ */
+ static parse(hash) {
+ const parts = hash.replace('#', '').split('/');
+ parts.reverse();
+ const trackId = parseInt(parts.pop());
+ if (!isNaN(trackId)) {
+ let mapApi = 'openlayers';
+ if (parts.pop() === 'g') {
+ mapApi = 'gmaps';
+ }
+ let mapParams = null;
+ if (parts.length >= 4) {
+ mapParams = {};
+ mapParams.center = [ parseFloat(parts.pop()), parseFloat(parts.pop()) ];
+ mapParams.zoom = parseFloat(parts.pop());
+ mapParams.rotation = parseFloat(parts.pop());
+ }
+ return uTrack.getMeta(trackId)
+ .then((meta) => {
+ const userId = meta.userId;
+ const title = meta.name;
+ return { title, userId, trackId, mapApi, mapParams };
+ })
+ .catch((e) => {
+ console.log(`Ignoring unknown track ${trackId} ${e}`);
+ return null;
+ });
+ }
+ return Promise.resolve(null);
+ }
+
+ /**
+ * @param {?PermalinkState} state
+ */
+ onPop(state) {
+ console.log('popState: #' + (state ? `${state.trackId}/${state.mapApi}/${state.mapParams}` : ''));
+ this.state.history = state;
+ if (state) {
+ document.title = `${$._('title')} ${state.title}`;
+ }
+ }
+
+ /**
+ * Push state into browser history
+ */
+ pushState() {
+ if (this.skipPush) {
+ this.skipPush = false;
+ return;
+ }
+ if (this.state.currentUser === null || this.state.currentTrack === null) {
+ return;
+ }
+ const state = this.getState();
+ const prevState = window.history.state;
+ if (!prevState || !uUtils.isDeepEqual(prevState, state)) {
+ const hash = uPermalink.getHash(state);
+ console.log(`pushState: ${hash} => ${state}`);
+ window.history.pushState(state, state.title, hash);
+ document.title = `${$._('title')} ${state.title}`;
+ }
+ }
+
+ getState() {
+ return {
+ title: this.state.currentTrack.name,
+ userId: this.state.currentUser.id,
+ trackId: this.state.currentTrack.id,
+ mapApi: config.mapApi,
+ mapParams: this.state.mapParams
+ };
+ }
+
+ /**
+ * Get link hash
+ * @param {PermalinkState} state
+ * @return {string}
+ */
+ static getHash(state) {
+ let hash = `#${state.trackId}/${state.mapApi.charAt(0)}`;
+ if (state.mapParams) {
+ hash += `/${state.mapParams.center[0]}/${state.mapParams.center[1]}`;
+ hash += `/${state.mapParams.zoom}/${state.mapParams.rotation}`;
+ }
+ return hash;
+ }
+}
diff --git a/js/src/state.js b/js/src/state.js
index ca7fe79..5574012 100644
--- a/js/src/state.js
+++ b/js/src/state.js
@@ -26,6 +26,8 @@ import uObserve from './observe.js';
* @property {boolean} showLatest
* @property {boolean} showAllUsers
* @property {number} activeJobs
+ * @property {?MapParams} mapParams
+ * @property {?PermalinkState} history
*/
export default class uState {
@@ -35,6 +37,8 @@ export default class uState {
this.showLatest = false;
this.showAllUsers = false;
this.activeJobs = 0;
+ this.mapParams = null;
+ this.history = null;
}
jobStart() {
diff --git a/js/src/track.js b/js/src/track.js
index 1b20830..d8ea507 100644
--- a/js/src/track.js
+++ b/js/src/track.js
@@ -228,6 +228,17 @@ export default class uTrack extends uPositionSet {
});
}
+ /**
+ * @param {number} id
+ * @return {Promise<{id: number, name: string, userId: number, comment: string|null}, Error>}
+ */
+ static getMeta(id) {
+ return uTrack.update({
+ action: 'getmeta',
+ trackid: id
+ });
+ }
+
/**
* Save track data
* @param {Object} data
diff --git a/js/src/trackviewmodel.js b/js/src/trackviewmodel.js
index e05d310..7318546 100644
--- a/js/src/trackviewmodel.js
+++ b/js/src/trackviewmodel.js
@@ -124,6 +124,11 @@ export default class TrackViewModel extends ViewModel {
this.startAutoReload();
}
});
+ this.state.onChanged('history', (history) => {
+ if (history && !history.userId && history.trackId) {
+ this.model.currentTrackId = history.trackId.toString();
+ }
+ });
}
setClickHandlers() {
@@ -268,8 +273,9 @@ export default class TrackViewModel extends ViewModel {
if (_tracks.length) {
if (this.state.showLatest) {
this.onUserLastPosition();
+ } else if (this.state.history) {
+ this.model.currentTrackId = this.state.history.trackId.toString();
} else {
- // autoload first track in list
this.model.currentTrackId = _tracks[0].listValue;
}
} else {
diff --git a/js/src/ulogger.js b/js/src/ulogger.js
index d7cb797..cecf714 100644
--- a/js/src/ulogger.js
+++ b/js/src/ulogger.js
@@ -25,21 +25,26 @@ import MapViewModel from './mapviewmodel.js';
import TrackViewModel from './trackviewmodel.js';
import UserViewModel from './userviewmodel.js';
import uAlert from './alert.js';
+import uPermalink from './permalink.js';
import uSpinner from './spinner.js';
import uState from './state.js';
const domReady = uInitializer.waitForDom();
const initReady = initializer.initialize();
+const initLink = uPermalink.parseHash();
-Promise.all([ domReady, initReady ])
- .then(() => {
- start();
+Promise.all([ domReady, initReady, initLink ])
+ .then((result) => {
+ start(result[2]);
})
.catch((msg) => uAlert.error(`${$._('actionfailure')}\n${msg}`));
-
-function start() {
+/**
+ * @param {?Object} linkState
+ */
+function start(linkState) {
const state = new uState();
+ const permalink = new uPermalink(state);
const spinner = new uSpinner(state);
const mainVM = new MainViewModel(state);
const userVM = new UserViewModel(state);
@@ -47,6 +52,7 @@ function start() {
const mapVM = new MapViewModel(state);
const chartVM = new ChartViewModel(state);
const configVM = new ConfigViewModel(state);
+ permalink.init().onPop(linkState);
spinner.init();
mainVM.init();
userVM.init();
diff --git a/js/src/userviewmodel.js b/js/src/userviewmodel.js
index 9c7e710..f9c87bc 100644
--- a/js/src/userviewmodel.js
+++ b/js/src/userviewmodel.js
@@ -71,7 +71,9 @@ export default class UserViewModel extends ViewModel {
this.model.userList = _users;
if (_users.length) {
let userId = _users[0].listValue;
- if (auth.isAuthenticated) {
+ if (this.state.history) {
+ userId = this.state.history.userId.toString();
+ } else if (auth.isAuthenticated) {
const user = this.model.userList.find((_user) => _user.listValue === auth.user.listValue);
if (user) {
userId = user.listValue;
@@ -103,6 +105,11 @@ export default class UserViewModel extends ViewModel {
this.select.hideAllOption();
}
});
+ state.onChanged('history', (history) => {
+ if (history && history.userId) {
+ this.model.currentUserId = history.userId.toString();
+ }
+ });
}
showDialog(action) {
diff --git a/js/src/utils.js b/js/src/utils.js
index 591d3e6..694469a 100644
--- a/js/src/utils.js
+++ b/js/src/utils.js
@@ -288,4 +288,20 @@ export default class uUtils {
static deg2rad(degrees) {
return degrees * Math.PI / 180;
}
+
+ /**
+ * Recursively compare properties of two objects with same keys
+ * @param {Object} obj1
+ * @param {Object} obj2
+ * @return {boolean} True if properties are equal
+ */
+ static isDeepEqual(obj1, obj2) {
+ return Object.keys(obj1).every((key) => {
+ if (typeof obj1[key] === 'object' && obj1[key] !== null &&
+ typeof obj2[key] === 'object' && obj2[key] !== null) {
+ return this.isDeepEqual(obj1[key], obj2[key]);
+ }
+ return obj1[key] === obj2[key];
+ })
+ }
}
diff --git a/js/test/api_openlayers.test.js b/js/test/api_openlayers.test.js
index 549d95f..ef2ac0a 100644
--- a/js/test/api_openlayers.test.js
+++ b/js/test/api_openlayers.test.js
@@ -366,14 +366,13 @@ describe('Openlayers map API tests', () => {
const zoomedExtent = [ 3, 2, 1, 0 ];
api.map = mockMap;
spyOn(ol.View.prototype, 'fit');
- spyOn(ol.View.prototype, 'getZoom').and.returnValue(OpenlayersApi.ZOOM_MAX + 1);
+ spyOn(ol.View.prototype, 'getZoom').and.returnValue(OpenlayersApi.ZOOM_MAX);
spyOn(ol.View.prototype, 'setZoom');
spyOn(ol.View.prototype, 'calculateExtent').and.returnValue(zoomedExtent);
// when
const result = api.fitToExtent(extent);
// then
expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent, jasmine.any(Object));
- expect(ol.View.prototype.setZoom).toHaveBeenCalledWith(OpenlayersApi.ZOOM_MAX);
expect(result).toEqual(zoomedExtent);
});
@@ -498,7 +497,7 @@ describe('Openlayers map API tests', () => {
// when
api.zoomToExtent();
// then
- expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent);
+ expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent, { maxZoom: OpenlayersApi.ZOOM_MAX });
});
it('should get map bounds and convert to WGS84 (EPSG:4326)', () => {
diff --git a/js/test/helpers/googlemaps.stub.js b/js/test/helpers/googlemaps.stub.js
index 80a3679..2086e2b 100644
--- a/js/test/helpers/googlemaps.stub.js
+++ b/js/test/helpers/googlemaps.stub.js
@@ -23,7 +23,7 @@ const stubFn = function() {/* ignore */};
const stubFnObj = function() { return stubObj; };
export const setupGmapsStub = () => {
- // noinspection JSUnresolvedVariable
+ // noinspection JSUnresolvedVariable,JSConstantReassignment
window.google = {
maps: {
Animation: {
@@ -33,7 +33,8 @@ export const setupGmapsStub = () => {
event: {
addListener: stubFn,
addListenerOnce: stubFn,
- removeListener: stubFn
+ removeListener: stubFn,
+ clearListeners: stubFn
},
Icon: stubFn,
InfoWindow: stubFn,
@@ -94,6 +95,6 @@ export const applyPrototypes = () => {
};
export const clear = () => {
- // noinspection JSAnnotator,JSUnresolvedVariable
+ // noinspection JSAnnotator,JSUnresolvedVariable,JSConstantReassignment
delete window.google;
};
diff --git a/js/test/mapviewmodel.test.js b/js/test/mapviewmodel.test.js
index 108778e..bba25fa 100644
--- a/js/test/mapviewmodel.test.js
+++ b/js/test/mapviewmodel.test.js
@@ -144,6 +144,7 @@ describe('MapViewModel tests', () => {
// given
vm.api = mockApi;
vm.savedBounds = bounds;
+ state.currentTrack = track;
// when
vm.onReady();
// then
diff --git a/js/test/permalink.test.js b/js/test/permalink.test.js
new file mode 100644
index 0000000..198be56
--- /dev/null
+++ b/js/test/permalink.test.js
@@ -0,0 +1,324 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2020 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import { config, lang } from '../src/initializer.js';
+import Fixture from './helpers/fixture.js';
+import MapViewModel from '../src/mapviewmodel.js';
+import TrackViewModel from '../src/trackviewmodel.js';
+import UserViewModel from '../src/userviewmodel.js';
+import uObserve from '../src/observe.js';
+import uPermalink from '../src/permalink.js';
+import uState from '../src/state.js';
+import uTrack from '../src/track.js';
+import uUser from '../src/user.js';
+
+describe('Permalink tests', () => {
+
+ let permalink;
+ let state;
+ const trackId = 123;
+ const trackName = 'test track';
+ const userId = 456;
+ const mapApi = 'testApi';
+ const lat = -267220.5357759836;
+ const lng = 4514512.219090612;
+ const zoom = 7.7081991502812075;
+ const rotation = 20.21;
+ const mapParams = {
+ center: [ lat, lng ],
+ zoom: zoom,
+ rotation: rotation
+ };
+ let spy;
+ let tm, um, mm;
+ let mockApi;
+
+ beforeEach((done) => {
+ Fixture.load('main.html')
+ .then(() => done())
+ .catch((e) => done.fail(e));
+ });
+
+ beforeEach(() => {
+ config.reinitialize();
+ lang.init(config);
+ spyOn(lang, '_').and.callFake((arg) => arg);
+ mockApi = jasmine.createSpyObj('mockApi', {
+ 'init': Promise.resolve(),
+ 'getBounds': { /* ignored */ },
+ 'cleanup': { /* ignored */ },
+ 'zoomToBounds': { /* ignored */ },
+ 'zoomToExtent': { /* ignored */ },
+ 'displayTrack': Promise.resolve(),
+ 'clearMap': { /* ignored */ },
+ 'updateSize': { /* ignored */ },
+ 'updateState': { /* ignored */ }
+ });
+ state = new uState();
+ tm = new TrackViewModel(state);
+ um = new UserViewModel(state);
+ mm = new MapViewModel(state);
+ mm.api = mockApi;
+ spyOn(tm, 'onTrackSelect');
+ spyOn(tm, 'loadTrackList');
+ permalink = new uPermalink(state);
+ spy = spyOn(uTrack, 'getMeta').and.callFake((_trackId) => Promise.resolve({
+ id: _trackId,
+ name: trackName,
+ userId: userId,
+ comment: null
+ }));
+ });
+
+ afterEach(() => {
+ Fixture.clear();
+ uObserve.unobserveAll(lang);
+ });
+
+ it('should create instance', () => {
+ expect(permalink).toBeInstanceOf(uPermalink);
+ expect(permalink.state).toBe(state);
+ expect(permalink.skipPush).toBeFalse();
+ });
+
+ let testHashes = [
+ { hash: `#${trackId}`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } },
+ { hash: `#${trackId}/`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } },
+ { hash: `#${trackId}/o`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } },
+ { hash: `#${trackId}/x`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } },
+ { hash: `#${trackId}/g`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'gmaps', mapParams: null } },
+ { hash: `#${trackId}/o/`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } },
+ { hash: `#${trackId}/o/${lat}/${lng}`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } },
+ { hash: `#${trackId}/o/${lat}/${lng}/${zoom}/${rotation}`,
+ state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: mapParams } }
+ ];
+ testHashes.forEach((test) => {
+ it(`should parse link hash "${test.hash}"`, (done) => {
+ uPermalink.parse(test.hash).then((result) => {
+ expect(result).toEqual(test.state);
+ done();
+ }).catch((e) => done.fail(e));
+ });
+ });
+ testHashes = [ '#', '', '#hash' ];
+ testHashes.forEach((test) => {
+ it(`should parse link and return null for corrupt hash "${test}"`, (done) => {
+ uPermalink.parse(test).then((result) => {
+ expect(result).toBeNull();
+ done();
+ }).catch((e) => done.fail(e));
+ });
+ });
+
+ it('should parse link and return null for unknown track id', (done) => {
+ spy.and.returnValue(Promise.reject(new Error('error')));
+ uPermalink.parse('#21').then((result) => {
+ expect(result).toBeNull();
+ done();
+ }).catch((e) => done.fail(e));
+ });
+
+ it('should create hash from app state', () => {
+ // given
+ const permalinkState = {
+ userId: userId,
+ trackId: trackId,
+ mapApi: mapApi,
+ mapParams: null
+ };
+ // when
+ const hash = uPermalink.getHash(permalinkState);
+ // then
+ expect(hash).toBe(`#${trackId}/${mapApi.charAt(0)}`);
+ });
+
+ it('should create hash from app state and map parameters', () => {
+ // given
+ const permalinkState = { userId, trackId, mapApi, mapParams };
+ // when
+ const hash = uPermalink.getHash(permalinkState);
+ // then
+ expect(hash).toBe(`#${trackId}/${mapApi.charAt(0)}/${mapParams.center[0]}/${mapParams.center[1]}/${mapParams.zoom}/${mapParams.rotation}`);
+ });
+
+ it('should return permalink state from application state', () => {
+ // given
+ state.currentUser = new uUser(userId, 'test');
+ state.currentTrack = new uTrack(trackId, trackName, state.currentUser);
+ state.mapParams = mapParams;
+ config.mapApi = mapApi;
+ const title = trackName;
+ // when
+ const permalinkState = permalink.getState();
+ // then
+ expect(permalinkState).toEqual({ title, userId, trackId, mapApi, mapParams });
+ });
+
+ it('should restore state and switch user', (done) => {
+ // given
+ const newUserId = userId + 1;
+ const newUser = new uUser(newUserId, 'new');
+ const newTrackId = trackId + 1;
+ spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ newUser ]));
+ state.currentUser = new uUser(userId, 'test');
+ state.currentTrack = new uTrack(trackId, trackName, state.currentUser);
+ tm.init();
+ um.init();
+ mm.init();
+ permalink.init();
+ const historyState = {
+ title: 'title',
+ userId: newUserId,
+ trackId: newTrackId,
+ mapApi: mapApi,
+ mapParams: mapParams
+ };
+ const event = new PopStateEvent('popstate', { state: historyState });
+ setTimeout(() => {
+ // when
+ dispatchEvent(event);
+ // then
+ setTimeout(() => {
+ expect(tm.loadTrackList).toHaveBeenCalledTimes(1);
+ expect(tm.onTrackSelect).toHaveBeenCalledTimes(1);
+ expect(mockApi.updateState).not.toHaveBeenCalled();
+ expect(state.currentUser).toBe(newUser);
+ expect(tm.model.currentTrackId).toBe(newTrackId.toString());
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should restore state and load track for same user', (done) => {
+ // given
+ const user = new uUser(userId, 'test');
+ const newTrackId = trackId + 1;
+ const track = new uTrack(trackId, trackName, user);
+ state.currentUser = user;
+ state.currentTrack = track;
+ spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ user ]));
+ tm.init();
+ um.init();
+ mm.init();
+ permalink.init();
+ const historyState = {
+ title: 'title',
+ userId: userId,
+ trackId: newTrackId,
+ mapApi: mapApi,
+ mapParams: mapParams
+ };
+ const event = new PopStateEvent('popstate', { state: historyState });
+ setTimeout(() => {
+ // when
+ dispatchEvent(event);
+ // then
+ setTimeout(() => {
+ expect(tm.loadTrackList).not.toHaveBeenCalled();
+ expect(tm.onTrackSelect).toHaveBeenCalledTimes(1);
+ expect(mockApi.updateState).not.toHaveBeenCalled();
+ expect(state.currentUser).toBe(user);
+ expect(tm.model.currentTrackId).toBe(newTrackId.toString());
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should restore state without user and track update, with map api change', (done) => {
+ // given
+ config.mapApi = 'oldApi';
+ const user = new uUser(userId, 'test');
+ const track = new uTrack(trackId, trackName, user);
+ state.currentUser = user;
+ state.currentTrack = track;
+ spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ user ]));
+ tm.model.currentTrackId = trackId.toString();
+ tm.init();
+ um.init();
+ mm.init();
+ permalink.init();
+ const historyState = {
+ title: 'title',
+ userId: userId,
+ trackId: trackId,
+ mapApi: mapApi,
+ mapParams: mapParams
+ };
+ const event = new PopStateEvent('popstate', { state: historyState });
+ setTimeout(() => {
+ // when
+ dispatchEvent(event);
+ // then
+ setTimeout(() => {
+ expect(tm.loadTrackList).not.toHaveBeenCalled();
+ expect(tm.onTrackSelect).not.toHaveBeenCalled();
+ expect(mockApi.updateState).not.toHaveBeenCalled();
+ expect(config.mapApi).toBe(mapApi);
+ expect(state.currentUser).toBe(user);
+ expect(tm.model.currentTrackId).toBe(trackId.toString());
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should restore state without user, track and map api update', (done) => {
+ // given
+ config.mapApi = mapApi;
+ const user = new uUser(userId, 'test');
+ const track = new uTrack(trackId, trackName, user);
+ state.currentUser = user;
+ state.currentTrack = track;
+ spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ user ]));
+ tm.model.currentTrackId = trackId.toString();
+ tm.init();
+ um.init();
+ mm.init();
+ permalink.init();
+ const historyState = {
+ title: 'title',
+ userId: userId,
+ trackId: trackId,
+ mapApi: mapApi,
+ mapParams: mapParams
+ };
+ const event = new PopStateEvent('popstate', { state: historyState });
+ setTimeout(() => {
+ // when
+ dispatchEvent(event);
+ // then
+ setTimeout(() => {
+ expect(tm.loadTrackList).not.toHaveBeenCalled();
+ expect(tm.onTrackSelect).not.toHaveBeenCalled();
+ expect(mockApi.updateState).toHaveBeenCalledTimes(1);
+ expect(config.mapApi).toBe(mapApi);
+ expect(state.currentUser).toBe(user);
+ expect(tm.model.currentTrackId).toBe(trackId.toString());
+ done();
+ }, 100);
+ }, 100);
+ });
+
+});
diff --git a/js/test/utils.test.js b/js/test/utils.test.js
index e10f8f4..1fea76c 100644
--- a/js/test/utils.test.js
+++ b/js/test/utils.test.js
@@ -267,4 +267,103 @@ describe('Utils tests', () => {
expect(uUtils.deg2rad(1)).toBeCloseTo(0.0174533, 7);
});
+ it('should confirm two objects are equal', () => {
+ // given
+ const obj1 = {
+ property1: true,
+ property2: null,
+ property3: 'string',
+ property4: 4,
+ property5: {
+ sub1: 4,
+ sub2: 'sub'
+ }
+ }
+ const obj2 = JSON.parse(JSON.stringify(obj1));
+ // when
+ const result = uUtils.isDeepEqual(obj1, obj2);
+ // then
+ expect(result).toBeTrue();
+ });
+
+ it('should confirm two objects are not equal', () => {
+ // given
+ const obj1 = {
+ property1: true,
+ property2: null,
+ property3: 'string',
+ property4: 4,
+ property5: {
+ sub1: 4,
+ sub2: 'sub'
+ }
+ }
+ const obj2 = JSON.parse(JSON.stringify(obj1));
+ obj2.property1 = false;
+ // when
+ const result = uUtils.isDeepEqual(obj1, obj2);
+ // then
+ expect(result).toBeFalse();
+ });
+
+ it('should confirm two objects are not equal on deeper level', () => {
+ // given
+ const obj1 = {
+ property1: true,
+ property2: null,
+ property3: 'string',
+ property4: 4,
+ property5: {
+ sub1: 4,
+ sub2: 'sub'
+ }
+ }
+ const obj2 = JSON.parse(JSON.stringify(obj1));
+ obj2.property5.sub1 = 5;
+ // when
+ const result = uUtils.isDeepEqual(obj1, obj2);
+ // then
+ expect(result).toBeFalse();
+ });
+
+ it('should confirm two objects are not equal when object property is null on obj2', () => {
+ // given
+ const obj1 = {
+ property1: true,
+ property2: null,
+ property3: 'string',
+ property4: 4,
+ property5: {
+ sub1: 4,
+ sub2: 'sub'
+ }
+ }
+ const obj2 = JSON.parse(JSON.stringify(obj1));
+ obj2.property5 = null;
+ // when
+ const result = uUtils.isDeepEqual(obj1, obj2);
+ // then
+ expect(result).toBeFalse();
+ });
+
+ it('should confirm two objects are not equal when object property is null on obj1', () => {
+ // given
+ const obj1 = {
+ property1: true,
+ property2: null,
+ property3: 'string',
+ property4: 4,
+ property5: {
+ sub1: 4,
+ sub2: 'sub'
+ }
+ }
+ const obj2 = JSON.parse(JSON.stringify(obj1));
+ obj2.property2 = { sub1: 1 };
+ // when
+ const result = uUtils.isDeepEqual(obj1, obj2);
+ // then
+ expect(result).toBeFalse();
+ });
+
});
diff --git a/login.php b/login.php
index 92072e5..31a3dd4 100644
--- a/login.php
+++ b/login.php
@@ -33,12 +33,19 @@
= $lang["title"] ?>
-
+
= $lang["title"] ?>
= $lang["private"] ?>
diff --git a/utils/handletrack.php b/utils/handletrack.php
index b92c764..41eaf5c 100644
--- a/utils/handletrack.php
+++ b/utils/handletrack.php
@@ -25,9 +25,9 @@ require_once(ROOT_DIR . "/helpers/config.php");
$auth = new uAuth();
-$action = uUtils::postString('action');
-$trackId = uUtils::postInt('trackid');
-$trackName = uUtils::postString('trackname');
+$action = uUtils::postString("action");
+$trackId = uUtils::postInt("trackid");
+$trackName = uUtils::postString("trackname");
$config = uConfig::getInstance();
$lang = (new uLang($config))->getStrings();
@@ -36,30 +36,44 @@ if (empty($action) || empty($trackId)) {
uUtils::exitWithError($lang["servererror"]);
}
$track = new uTrack($trackId);
-if (!$track->isValid ||
- (!$auth->isAuthenticated() || (!$auth->isAdmin() && $auth->user->id !== $track->userId))) {
+if (!$track->isValid) {
uUtils::exitWithError($lang["servererror"]);
}
+if (($action === "getmeta" && !$auth->hasReadAccess($track->userId)) ||
+ ($action !== "getmeta" && !$auth->hasReadWriteAccess($track->userId))) {
+ uUtils::exitWithError($lang["notauthorized"]);
+}
+
+$result = null;
switch ($action) {
- case 'update':
+ case "update":
if (empty($trackName) || $track->update($trackName) === false) {
uUtils::exitWithError($lang["servererror"]);
}
break;
- case 'delete':
+ case "delete":
if ($track->delete() === false) {
uUtils::exitWithError($lang["servererror"]);
}
break;
+ case "getmeta":
+ $result = [
+ "id" => $track->id,
+ "name" => $track->name,
+ "userId" => $track->userId,
+ "comment" => $track->comment
+ ];
+ break;
+
default:
uUtils::exitWithError($lang["servererror"]);
break;
}
-uUtils::exitWithSuccess();
+uUtils::exitWithSuccess($result);
?>
\ No newline at end of file
diff --git a/utils/logout.php b/utils/logout.php
index c758fc3..4093cc5 100644
--- a/utils/logout.php
+++ b/utils/logout.php
@@ -19,7 +19,11 @@
include_once(dirname(__DIR__) . "/helpers/auth.php");
+$hash = uUtils::getString("hash", "");
+if (!empty($hash)) {
+ $hash = "#{$hash}";
+}
$auth = new uAuth();
-$auth->logOutWithRedirect();
+$auth->logOutWithRedirect($hash);
?>
\ No newline at end of file