Add basic position editing

This commit is contained in:
Bartek Fabiszewski 2020-01-13 22:57:13 +01:00
parent 260d576fc2
commit 9417d75632
12 changed files with 410 additions and 24 deletions

View File

@ -289,6 +289,17 @@ label[for=user] {
color: #f0f8ff; color: #f0f8ff;
} }
#pfooter div:first-child {
width: 40%;
float: left;
}
#pfooter div:last-child {
width: 40%;
float: right;
text-align: right;
}
#bottom { #bottom {
position: absolute; position: absolute;
z-index: 10000; z-index: 10000;

View File

@ -71,7 +71,7 @@ require_once(ROOT_DIR . "/helpers/upload.php");
FROM " . self::db()->table('positions') . " p FROM " . self::db()->table('positions') . " p
LEFT JOIN " . self::db()->table('users') . " u ON (p.user_id = u.id) LEFT JOIN " . self::db()->table('users') . " u ON (p.user_id = u.id)
LEFT JOIN " . self::db()->table('tracks') . " t ON (p.track_id = t.id) LEFT JOIN " . self::db()->table('tracks') . " t ON (p.track_id = t.id)
WHERE id = ? LIMIT 1"; WHERE p.id = ? LIMIT 1";
$params = [ $positionId ]; $params = [ $positionId ];
try { try {
$this->loadWithQuery($query, $params); $this->loadWithQuery($query, $params);
@ -91,6 +91,15 @@ require_once(ROOT_DIR . "/helpers/upload.php");
return uDb::getInstance(); return uDb::getInstance();
} }
/**
* Has image
*
* @return bool True if has image
*/
public function hasImage() {
return !empty($this->image);
}
/** /**
* Add position * Add position
* *
@ -135,6 +144,70 @@ require_once(ROOT_DIR . "/helpers/upload.php");
return $positionId; return $positionId;
} }
/**
* Save position to database
*
* @return bool True if success, false otherwise
*/
public function update() {
$ret = false;
if ($this->isValid) {
try {
$query = "UPDATE " . self::db()->table('positions') . " SET
time = " . self::db()->from_unixtime('?') . ", user_id = ?, track_id = ?, latitude = ?, longitude = ?, altitude = ?,
speed = ?, bearing = ?, accuracy = ?, provider = ?, comment = ?, image = ? WHERE id = ?";
$stmt = self::db()->prepare($query);
$params = [
$this->timestamp,
$this->userId,
$this->trackId,
$this->latitude,
$this->longitude,
$this->altitude,
$this->speed,
$this->bearing,
$this->accuracy,
$this->provider,
$this->comment,
$this->image,
$this->id
];
$stmt->execute($params);
$ret = true;
} catch (PDOException $e) {
// TODO: handle exception
syslog(LOG_ERR, $e->getMessage());
}
}
return $ret;
}
/**
* Delete positions
*
* @return bool True if success, false otherwise
*/
public function delete() {
$ret = false;
if ($this->isValid) {
try {
$query = "DELETE FROM " . self::db()->table('positions') . " WHERE id = ?";
$stmt = self::db()->prepare($query);
$stmt->execute([ $this->id ]);
if ($this->hasImage()) {
uUpload::delete($this->image);
}
$ret = true;
$this->id = NULL;
$this->isValid = false;
} catch (PDOException $e) {
// TODO: handle exception
syslog(LOG_ERR, $e->getMessage());
}
}
return $ret;
}
/** /**
* Delete all user's positions, optionally limit to given track * Delete all user's positions, optionally limit to given track
* *

View File

@ -309,9 +309,11 @@ export default class OpenLayersApi {
* Close popup * Close popup
*/ */
popupClose() { popupClose() {
// eslint-disable-next-line no-undefined if (this.popup) {
this.popup.setPosition(undefined); // eslint-disable-next-line no-undefined
this.popup.getElement().firstElementChild.innerHTML = ''; this.popup.setPosition(undefined);
this.popup.getElement().firstElementChild.innerHTML = '';
}
this.viewModel.model.markerSelect = null; this.viewModel.model.markerSelect = null;
} }
@ -492,6 +494,7 @@ export default class OpenLayersApi {
* Clear map * Clear map
*/ */
clearMap() { clearMap() {
this.popupClose();
if (this.layerTrack) { if (this.layerTrack) {
this.layerTrack.getSource().clear(); this.layerTrack.getSource().clear();
} }

View File

@ -17,9 +17,10 @@
* along with this program; if not, see <http://www.gnu.org/licenses/>. * along with this program; if not, see <http://www.gnu.org/licenses/>.
*/ */
import { lang as $, config } from './initializer.js'; import { lang as $, auth, config } from './initializer.js';
import GoogleMapsApi from './mapapi/api_gmaps.js'; import GoogleMapsApi from './mapapi/api_gmaps.js';
import OpenLayersApi from './mapapi/api_openlayers.js'; import OpenLayersApi from './mapapi/api_openlayers.js';
import PositionDialogModel from './positiondialogmodel.js';
import ViewModel from './viewmodel.js'; import ViewModel from './viewmodel.js';
import uDialog from './dialog.js'; import uDialog from './dialog.js';
import uObserve from './observe.js'; import uObserve from './observe.js';
@ -138,12 +139,14 @@ export default class MapViewModel extends ViewModel {
/** /**
* Get popup html * Get popup html
* @param {number} id Position ID * @param {number} id Position index
* @returns {HTMLDivElement} * @returns {HTMLDivElement}
*/ */
getPopupElement(id) { getPopupElement(id) {
const pos = this.state.currentTrack.positions[id]; const pos = this.state.currentTrack.positions[id];
const count = this.state.currentTrack.length; const count = this.state.currentTrack.length;
const user = this.state.currentTrack.user;
const isEditable = auth.user && (auth.isAdmin || auth.user === user);
let date = ''; let date = '';
let time = ''; let time = '';
if (pos.timestamp > 0) { if (pos.timestamp > 0) {
@ -157,6 +160,10 @@ export default class MapViewModel extends ViewModel {
} else if (pos.provider === 'network') { } else if (pos.provider === 'network') {
provider = ` <img class="icon" alt="${$._('network')}" title="${$._('network')}" src="images/network_dark.svg">`; provider = ` <img class="icon" alt="${$._('network')}" title="${$._('network')}" src="images/network_dark.svg">`;
} }
let editLink = '';
if (isEditable) {
editLink = `<a id="editposition" class="menu-link" data-bind="onUserAdd">${$._('editposition')}</a>`;
}
let stats = ''; let stats = '';
if (!this.state.showLatest) { if (!this.state.showLatest) {
stats = stats =
@ -182,7 +189,7 @@ export default class MapViewModel extends ViewModel {
${(pos.altitude !== null) ? `<img class="icon" alt="${$._('altitude')}" title="${$._('altitude')}" src="images/altitude_dark.svg">${$.getLocaleAltitude(pos.altitude, true)}<br>` : ''} ${(pos.altitude !== null) ? `<img class="icon" alt="${$._('altitude')}" title="${$._('altitude')}" src="images/altitude_dark.svg">${$.getLocaleAltitude(pos.altitude, true)}<br>` : ''}
${(pos.accuracy !== null) ? `<img class="icon" alt="${$._('accuracy')}" title="${$._('accuracy')}" src="images/accuracy_dark.svg">${$.getLocaleAccuracy(pos.accuracy, true)}${provider}<br>` : ''} ${(pos.accuracy !== null) ? `<img class="icon" alt="${$._('accuracy')}" title="${$._('accuracy')}" src="images/accuracy_dark.svg">${$.getLocaleAccuracy(pos.accuracy, true)}${provider}<br>` : ''}
</div>${stats}</div> </div>${stats}</div>
<div id="pfooter">${$._('pointof', id + 1, count)}</div>`; <div id="pfooter"><div>${$._('pointof', id + 1, count)}</div><div>${editLink}</div></div>`;
const node = document.createElement('div'); const node = document.createElement('div');
node.setAttribute('id', 'popup'); node.setAttribute('id', 'popup');
node.innerHTML = html; node.innerHTML = html;
@ -196,6 +203,13 @@ export default class MapViewModel extends ViewModel {
modal.show(); modal.show();
} }
} }
if (isEditable) {
const edit = node.querySelector('#editposition');
edit.onclick = () => {
const vm = new PositionDialogModel(this.state, id);
vm.init();
}
}
return node; return node;
} }

View File

@ -61,6 +61,17 @@ export default class uObserve {
} }
} }
/**
* Trigger notify of property observers
* @param {Object} obj
* @param {string} property
*/
static forceUpdate(obj, property) {
const value = obj._values[property];
const observers = obj._observers[property];
this.notify(observers, value);
}
/** /**
* Check if object property is observed; * Check if object property is observed;
* Optionally check if it is observed by given observer * Optionally check if it is observed by given observer

View File

@ -17,6 +17,7 @@
* along with this program; if not, see <http://www.gnu.org/licenses/>. * along with this program; if not, see <http://www.gnu.org/licenses/>.
*/ */
import uAjax from './ajax.js';
import uUtils from './utils.js'; import uUtils from './utils.js';
/** /**
@ -91,4 +92,60 @@ export default class uPosition {
get totalSpeed() { get totalSpeed() {
return this.totalSeconds ? this.totalMeters / this.totalSeconds : 0; return this.totalSeconds ? this.totalMeters / this.totalSeconds : 0;
} }
/**
* @return {Promise<void, Error>}
*/
delete() {
return uPosition.update({
action: 'delete',
posid: this.id
});
}
/**
* @return {Promise<void, Error>}
*/
save() {
return uPosition.update({
action: 'update',
posid: this.id,
comment: this.comment
});
}
/**
* Save track data
* @param {Object} data
* @return {Promise<void, Error>}
*/
static update(data) {
return uAjax.post('utils/handleposition.php', data);
}
/**
* Calculate distance to target point using haversine formula
* @param {uPosition} target
* @return {number} Distance in meters
*/
distanceTo(target) {
const lat1 = uUtils.deg2rad(this.latitude);
const lon1 = uUtils.deg2rad(this.longitude);
const lat2 = uUtils.deg2rad(target.latitude);
const lon2 = uUtils.deg2rad(target.longitude);
const latD = lat2 - lat1;
const lonD = lon2 - lon1;
const bearing = 2 * Math.asin(Math.sqrt((Math.sin(latD / 2) ** 2) + Math.cos(lat1) * Math.cos(lat2) * (Math.sin(lonD / 2) ** 2)));
return bearing * 6371000;
}
/**
* Calculate time elapsed since target point
* @param {uPosition} target
* @return {number} Number of seconds
*/
secondsTo(target) {
return this.timestamp - target.timestamp;
}
} }

View File

@ -0,0 +1,114 @@
/*
* μ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 <http://www.gnu.org/licenses/>.
*/
import { lang as $ } from './initializer.js';
import ViewModel from './viewmodel.js';
import uDialog from './dialog.js';
import uObserve from './observe.js';
import uUtils from './utils.js';
/**
* @class PositionDialogModel
*/
export default class PositionDialogModel extends ViewModel {
/**
* @param {uState} state
* @param {number} positionIndex
*/
constructor(state, positionIndex) {
super({
onPositionDelete: null,
onPositionUpdate: null,
onCancel: null,
comment: ''
});
this.state = state;
this.positionIndex = positionIndex;
this.position = this.state.currentTrack.positions[positionIndex];
this.model.onPositionDelete = () => this.onPositionDelete();
this.model.onPositionUpdate = () => this.onPositionUpdate();
this.model.onCancel = () => this.onCancel();
}
init() {
const html = this.getHtml();
this.dialog = new uDialog(html);
this.dialog.show();
this.bindAll(this.dialog.element);
}
/**
* @return {string}
*/
getHtml() {
return `<div style="float:left;">${$._('editingposition', this.positionIndex + 1, `<b>${uUtils.htmlEncode(this.position.trackname)}</b>`)}</div>
<div class="red-button button-resolve"><b><a data-bind="onPositionDelete">${$._('delposition')}</a></b></div>
<div style="clear: both; padding-bottom: 1em;"></div>
<form id="positionForm">
<label><b>${$._('comment')}</b></label><br>
<textarea style="width:100%;" maxlength="255" rows="5" placeholder="${$._('comment')}" name="comment" data-bind="comment">${uUtils.htmlEncode(this.position.comment)}</textarea>
<div class="buttons">
<button class="button-reject" data-bind="onCancel" type="button">${$._('cancel')}</button>
<button class="button-resolve" data-bind="onPositionUpdate" type="submit">${$._('submit')}</button>
</div>
</form>`;
}
onPositionDelete() {
if (uDialog.isConfirmed($._('positiondelwarn', this.positionIndex + 1, uUtils.htmlEncode(this.position.trackname)))) {
this.position.delete()
.then(() => {
const track = this.state.currentTrack;
this.state.currentTrack = null;
track.positions.splice(this.positionIndex, 1);
track.recalculatePositions();
this.state.currentTrack = track;
this.dialog.destroy();
}).catch((e) => { uUtils.error(e, `${$._('actionfailure')}\n${e.message}`); });
}
}
onPositionUpdate() {
if (this.validate()) {
this.position.comment = this.model.comment;
this.position.save()
.then(() => {
uObserve.forceUpdate(this.state, 'currentTrack');
this.dialog.destroy()
})
.catch((e) => { uUtils.error(e, `${$._('actionfailure')}\n${e.message}`); });
}
}
onCancel() {
this.dialog.destroy();
}
/**
* Validate form
* @return {boolean} True if valid
*/
validate() {
if (this.model.comment === this.position.comment) {
return false;
}
return true;
}
}

View File

@ -49,6 +49,8 @@ export default class uTrack extends uPositionSet {
this.user = user; this.user = user;
this.plotData = []; this.plotData = [];
this.maxId = 0; this.maxId = 0;
this.totalMeters = 0;
this.totalSeconds = 0;
this.listItem(id, name); this.listItem(id, name);
} }
@ -59,8 +61,14 @@ export default class uTrack extends uPositionSet {
clear() { clear() {
super.clear(); super.clear();
this.clearTrackCounters();
}
clearTrackCounters() {
this.maxId = 0; this.maxId = 0;
this.plotData.length = 0; this.plotData.length = 0;
this.totalMeters = 0;
this.totalSeconds = 0;
} }
/** /**
@ -84,30 +92,16 @@ export default class uTrack extends uPositionSet {
* @param {boolean=} isUpdate If true append to old data * @param {boolean=} isUpdate If true append to old data
*/ */
fromJson(posArr, isUpdate = false) { fromJson(posArr, isUpdate = false) {
let totalMeters = 0;
let totalSeconds = 0;
let positions = []; let positions = [];
if (isUpdate && this.hasPositions) { if (isUpdate && this.hasPositions) {
positions = this.positions; positions = this.positions;
const last = positions[this.length - 1];
totalMeters = last.totalMeters;
totalSeconds = last.totalSeconds;
} else { } else {
this.clear(); this.clear();
} }
for (const pos of posArr) { for (const pos of posArr) {
const position = uPosition.fromJson(pos); const position = uPosition.fromJson(pos);
totalMeters += position.meters; this.calculatePosition(position);
totalSeconds += position.seconds;
position.totalMeters = totalMeters;
position.totalSeconds = totalSeconds;
positions.push(position); positions.push(position);
if (position.altitude != null) {
this.plotData.push({ x: position.totalMeters, y: position.altitude });
}
if (position.id > this.maxId) {
this.maxId = position.id;
}
} }
// update at the end to avoid observers update invidual points // update at the end to avoid observers update invidual points
this.positions = positions; this.positions = positions;
@ -243,4 +237,31 @@ export default class uTrack extends uPositionSet {
return uAjax.post('utils/handletrack.php', data); return uAjax.post('utils/handletrack.php', data);
} }
recalculatePositions() {
this.clearTrackCounters();
let previous = null;
for (const position of this.positions) {
position.meters = previous ? position.distanceTo(previous) : 0;
position.seconds = previous ? position.secondsTo(previous) : 0;
this.calculatePosition(position);
previous = position;
}
}
/**
* Calculate position total counters and plot data
* @param {uPosition} position
*/
calculatePosition(position) {
this.totalMeters += position.meters;
this.totalSeconds += position.seconds;
position.totalMeters = this.totalMeters;
position.totalSeconds = this.totalSeconds;
if (position.altitude != null) {
this.plotData.push({ x: position.totalMeters, y: position.altitude });
}
if (position.id > this.maxId) {
this.maxId = position.id;
}
}
} }

View File

@ -317,4 +317,13 @@ export default class uUtils {
console.error(details); console.error(details);
alert(message); alert(message);
} }
/**
* Degrees to radians
* @param {number} degrees
* @return {number}
*/
static deg2rad(degrees) {
return degrees * Math.PI / 180;
}
} }

View File

@ -65,9 +65,12 @@ export default class ViewModel {
observers.forEach(/** @param {HTMLElement} element */ (element) => { observers.forEach(/** @param {HTMLElement} element */ (element) => {
const name = element.dataset[dataProp]; const name = element.dataset[dataProp];
if (name === key) { if (name === key) {
if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) { if (element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement) {
this.onChangeBind(element, key); this.onChangeBind(element, key);
} else if (element instanceof HTMLAnchorElement || element instanceof HTMLButtonElement) { } else if (element instanceof HTMLAnchorElement ||
element instanceof HTMLButtonElement) {
this.onClickBind(element, key); this.onClickBind(element, key);
} else { } else {
this.viewUpdateBind(element, key); this.viewUpdateBind(element, key);

View File

@ -111,6 +111,11 @@ $lang["editingtrack"] = "You are editing track %s"; // substitutes track name
$lang["deltrack"] = "Remove track"; $lang["deltrack"] = "Remove track";
$lang["trackname"] = "Track name"; $lang["trackname"] = "Track name";
$lang["edittrack"] = "Edit track"; $lang["edittrack"] = "Edit track";
$lang["positiondelwarn"] = "Warning!\n\nYou are going to permanently delete position %d of track %s.\n\nAre you sure?"; // substitutes position index and track name
$lang["editingposition"] = "You are editing position #%d of track %s"; // substitutes position index and track name
$lang["delposition"] = "Remove position";
$lang["comment"] = "Comment";
$lang["editposition"] = "Edit position";
$lang["passlenmin"] = "Password must be at least %d characters"; // substitutes password minimum length $lang["passlenmin"] = "Password must be at least %d characters"; // substitutes password minimum length
$lang["passrules_1"] = "It should contain at least one lower case letter, one upper case letter"; $lang["passrules_1"] = "It should contain at least one lower case letter, one upper case letter";
$lang["passrules_2"] = "It should contain at least one lower case letter, one upper case letter and one digit"; $lang["passrules_2"] = "It should contain at least one lower case letter, one upper case letter and one digit";

65
utils/handleposition.php Normal file
View File

@ -0,0 +1,65 @@
<?php
/* μ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 <http://www.gnu.org/licenses/>.
*/
require_once(dirname(__DIR__) . "/helpers/auth.php");
require_once(ROOT_DIR . "/helpers/lang.php");
require_once(ROOT_DIR . "/helpers/track.php");
require_once(ROOT_DIR . "/helpers/utils.php");
require_once(ROOT_DIR . "/helpers/config.php");
$auth = new uAuth();
$action = uUtils::postString('action');
$positionId = uUtils::postInt('posid');
$comment = uUtils::postString('comment');
$lang = (new uLang(uConfig::$lang))->getStrings();
if (empty($action) || empty($positionId)) {
uUtils::exitWithError($lang["servererror"]);
}
$position = new uPosition($positionId);
if (!$position->isValid ||
(!$auth->isAuthenticated() || (!$auth->isAdmin() && $auth->user->id !== $position->userId))) {
uUtils::exitWithError($lang["servererror"]);
}
switch ($action) {
case 'update':
$position->comment = $comment;
if ($position->update() === false) {
uUtils::exitWithError($lang["servererror"]);
}
break;
case 'delete':
if ($position->delete() === false) {
uUtils::exitWithError($lang["servererror"]);
}
break;
default:
uUtils::exitWithError($lang["servererror"]);
break;
}
uUtils::exitWithSuccess();
?>