Support description for location points.

This commit is contained in:
Fabio Manganiello 2025-03-23 20:23:26 +01:00
parent eb9e6abd22
commit 53a9a2afeb
Signed by: blacklight
GPG key ID: D90FBA7F76362774
12 changed files with 275 additions and 28 deletions
.env.example
frontend/src
src

View file

@ -84,6 +84,10 @@ ADMIN_EMAIL=admin@example.com
# Comment or leave empty if the postal code is not available.
# DB_LOCATION__POSTAL_CODE=postalCode
# The name of the column that contains the description of each location point
# Comment or leave empty if the description is not available.
# DB_LOCATION__DESCRIPTION=description
###
### Frontend configuration.
### This is only required if you want to run the frontend in development mode

View file

@ -30,7 +30,8 @@
<PointInfo :point="selectedPoint"
ref="popup"
@remove="onRemove"
@close="selectedPoint = null" />
@edit="editPoint"
@close="clearSelectedPoint" />
<div class="controls">
<div class="form-container" v-if="showControls">
@ -135,6 +136,7 @@ export default {
routesLayer: null as Optional<VectorLayer>,
selectedFeature: null as Optional<Feature>,
selectedPoint: null as Optional<GPSPoint>,
selectedPointIndex: null as Optional<number>,
showControls: false,
showMetrics: new TimelineMetricsConfiguration(),
}
@ -206,11 +208,20 @@ export default {
} finally {
this.loading = false
this.pointToRemove = null
this.selectedPoint = null
this.selectedFeature = null
this.clearSelectedPoint()
}
},
async editPoint(value: GPSPoint) {
const index = this.selectedPointIndex
if (index === null) {
return
}
await this.updatePoints([value])
this.gpsPoints[index] = value
},
onRemove(point: GPSPoint) {
this.pointToRemove = point
},
@ -273,9 +284,14 @@ export default {
if (feature) {
this.selectedFeature = feature as Feature
const point = this.gpsPoints.find((gps: GPSPoint) => {
const point = this.gpsPoints.find((gps: GPSPoint, index: number) => {
const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
return gps.longitude === longitude && gps.latitude === latitude
if (gps.longitude === longitude && gps.latitude === latitude) {
this.selectedPointIndex = index
return true
}
return false
})
if (point) {
@ -286,12 +302,17 @@ export default {
map.getView().setCenter(event.coordinate)
}
} else {
this.selectedPoint = null
this.selectedFeature = null
this.clearSelectedPoint()
}
})
},
clearSelectedPoint() {
this.selectedPoint = null
this.selectedPointIndex = null
this.selectedFeature = null
},
refreshShowMetricsFromURL() {
this.showMetrics = new TimelineMetricsConfiguration(this.parseQuery(window.location.href))
},

View file

@ -22,6 +22,34 @@
<font-awesome-icon icon="fas fa-mountain" />
{{ Math.round(point.altitude) }} m
</p>
<form class="description editor" @submit.prevent="editPoint" v-if="editDescription">
<div class="row">
<textarea
:value="point.description"
@keydown.enter="editPoint"
@blur="onDescriptionBlur"
ref="description"
placeholder="Enter a description" />
<button type="submit" title="Save">
<font-awesome-icon icon="fas fa-save" />
</button>
</div>
</form>
<p class="description"
:class="{ 'no-content': !point.description?.length }"
@click="editDescription = true"
v-else>
<span class="icon">
<font-awesome-icon icon="fas fa-edit" />
</span>
<span class="text">
{{ point.description?.length ? point.description : 'No description' }}
</span>
</p>
<p class="locality" v-if="point.locality">{{ point.locality }}</p>
<p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p>
<p class="country" v-if="country">
@ -51,7 +79,7 @@ import Dates from '../mixins/Dates.vue';
import GPSPoint from '../models/GPSPoint';
export default {
emit: ['close', 'remove'],
emit: ['close', 'edit', 'remove'],
mixins: [Dates],
props: {
point: {
@ -61,6 +89,8 @@ export default {
data() {
return {
newValue: null,
editDescription: false,
popup: null as Overlay | null,
}
},
@ -99,12 +129,44 @@ export default {
map.addOverlay(this.popup)
},
editPoint() {
this.newValue.description = (this.$refs.description as HTMLTextAreaElement).value
this.$emit('edit', this.newValue)
this.editDescription = false
},
onDescriptionBlur() {
// Give it a moment to allow relevant click events to trigger
setTimeout(() => {
this.editDescription = false
}, 100)
},
setPosition(coordinates: number[]) {
if (this.popup) {
this.popup.setPosition(coordinates)
}
},
},
watch: {
point: {
immediate: true,
handler(point: GPSPoint | null) {
if (point) {
this.newValue = point
}
},
},
editDescription(edit: boolean) {
if (edit) {
this.$nextTick(() => {
this.$refs.description?.focus()
})
}
},
},
}
</script>
@ -128,7 +190,7 @@ export default {
.header {
position: absolute;
top: 0.5em;
right: 0.5em;
right: 0;
button {
background: none;
@ -158,6 +220,57 @@ export default {
margin: -0.25em 0 0.25em 0;
}
.description {
cursor: pointer;
&:hover {
.icon {
color: var(--color-accent);
}
}
.icon {
margin-right: 0.5em;
}
.text {
font-style: italic;
}
&:not(.no-content) {
.text {
font-weight: bold;
}
.icon {
color: var(--color-accent);
}
}
&.no-content {
font-size: 0.9em;
opacity: 0.5;
}
textarea {
min-height: 5em;
}
button {
background: var(--color-accent);
border: none;
color: var(--color-accent);
font-size: 0.9em;
margin: 0;
padding: 0.5em 1.5em;
cursor: pointer;
&:hover {
color: var(--color-heading);
}
}
}
.timestamp {
color: var(--color-heading);
font-weight: bold;

View file

@ -12,13 +12,13 @@ export default {
}) || []
return points.map((gps: any) =>
new GPSPoint({
...gps,
// Normalize timestamp to Date object
timestamp: new Date(gps.timestamp),
})
)
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
new GPSPoint({
...gps,
// Normalize timestamp to Date object
timestamp: new Date(gps.timestamp),
})
)
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
},
async deletePoints(points: GPSPoint[]) {
@ -27,6 +27,13 @@ export default {
body: points.map((point: GPSPoint) => point.id),
})
},
async updatePoints(points: GPSPoint[]) {
await this.request('/gpsdata', {
method: 'PATCH',
body: points,
})
},
},
}
</script>

View file

@ -3,22 +3,50 @@ class GPSPoint {
public latitude: number;
public longitude: number;
public altitude: number;
public deviceId: string;
public address: string;
public locality: string;
public country: string;
public postalCode: string;
public description?: string;
public timestamp: Date;
constructor(public data: any) {
this.id = data.id;
this.latitude = data.latitude;
this.longitude = data.longitude;
this.altitude = data.altitude;
this.address = data.address;
this.locality = data.locality;
this.country = data.country;
this.postalCode = data.postalCode;
this.timestamp = data.timestamp;
constructor({
id,
latitude,
longitude,
altitude,
deviceId,
address,
locality,
country,
postalCode,
description,
timestamp,
}: {
id: number;
latitude: number;
longitude: number;
altitude: number;
deviceId: string;
address: string;
locality: string;
country: string;
postalCode: string;
description?: string;
timestamp: Date;
}) {
this.id = id;
this.latitude = latitude;
this.longitude = longitude;
this.altitude = altitude;
this.deviceId = deviceId;
this.address = address;
this.locality = locality;
this.country = country;
this.postalCode = postalCode;
this.description = description;
this.timestamp = timestamp;
}
}

View file

@ -94,7 +94,8 @@ class Db {
'address',
'locality',
'country',
'postalCode'
'postalCode',
'description',
].reduce((acc: any, name: string) => {
acc[name] = process.env[this.prefixedEnv(name)];
if (!acc[name]?.length && (requiredColumns[name] || opts.locationUrl === opts.url)) {

View file

@ -218,6 +218,10 @@ async function createLocationHistoryTable(query: { context: any }) {
type: DataTypes.STRING,
allowNull: true
},
[$db.locationTableColumns['description']]: {
type: DataTypes.STRING,
allowNull: true
},
[$db.locationTableColumns['timestamp']]: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,

View file

@ -66,6 +66,14 @@ function GPSData(locationTableColumns: Record<string, string>): Record<string, a
};
}
const descriptionCol: string = locationTableColumns['description'];
if (descriptionCol?.length) {
typeDef[descriptionCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
typeDef[locationTableColumns['timestamp']] = {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW

View file

@ -8,6 +8,7 @@ class GPSPoint {
public locality: string | null;
public country: string | null;
public postalCode: string | null;
public description: string | null;
public timestamp: Date;
constructor(record: any) {
@ -20,6 +21,7 @@ class GPSPoint {
this.locality = record.locality;
this.country = record.country;
this.postalCode = record.postalCode;
this.description = record.description;
this.timestamp = record.timestamp;
}
}

View file

@ -26,6 +26,7 @@ class Location {
locality: data[mappings.locality],
country: data[mappings.country],
postalCode: data[mappings.postalCode],
description: data[mappings.description],
timestamp: data[mappings.timestamp],
});
});
@ -62,6 +63,7 @@ class Location {
locality: data[mappings.locality],
country: data[mappings.country],
postalCode: data[mappings.postalCode],
description: data[mappings.description],
timestamp: data[mappings.timestamp],
});
});
@ -94,6 +96,7 @@ class Location {
[mappings.locality]: p.locality,
[mappings.country]: p.country,
[mappings.postalCode]: p.postalcode,
[mappings.description]: p.description,
[mappings.timestamp]: p.timestamp
}
},
@ -111,6 +114,7 @@ class Location {
locality: data[mappings.locality],
country: data[mappings.country],
postalCode: data[mappings.postalCode],
description: data[mappings.description],
timestamp: data[mappings.timestamp],
});
});
@ -119,6 +123,41 @@ class Location {
}
}
public async updatePoints(points: GPSPoint[]): Promise<void> {
const mappings: any = $db.locationTableColumns;
// Lowercase the keys of the mappings object -
// some databases are case-insensitive and this will help with consistency
const normalizedPoints = points.map((p) =>
Object.entries(p).reduce((acc, [key, value]) => {
acc[key.toLowerCase()] = value;
return acc;
} , {} as Record<string, any>)
);
try {
await $db.GPSData().bulkCreate(
normalizedPoints.map((p) => {
return {
[mappings.id]: p.id,
[mappings.deviceId]: p.deviceid,
[mappings.latitude]: p.latitude,
[mappings.longitude]: p.longitude,
[mappings.altitude]: p.altitude,
[mappings.address]: p.address,
[mappings.locality]: p.locality,
[mappings.country]: p.country,
[mappings.postalCode]: p.postalcode,
[mappings.description]: p.description,
[mappings.timestamp]: p.timestamp
}
}),
{ updateOnDuplicate: Object.keys(mappings) }
);
} catch (error) {
throw new Error(`Error updating data: ${error}`);
}
}
public async deletePoints(points: number[]): Promise<void> {
try {
await $db.GPSData().destroy({

View file

@ -14,6 +14,7 @@ class LocationRequest {
country: Optional<string> = null;
locality: Optional<string> = null;
postalCode: Optional<string> = null;
description: Optional<string> = null;
orderBy: string = 'timestamp';
order: string = 'DESC';
@ -27,6 +28,7 @@ class LocationRequest {
this.country = req.country;
this.locality = req.locality;
this.postalCode = req.postalCode;
this.description = req.description;
this.orderBy = req.orderBy || this.orderBy;
this.order = req.order || this.order;
}
@ -95,6 +97,10 @@ class LocationRequest {
where[colMapping.postalCode || 'postalCode'] = this.postalCode;
}
if (this.description != null) {
where[colMapping.description || 'description'] = {[Op.like]: `%${this.description}%`};
}
queryMap.where = where;
queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]];
return queryMap;

View file

@ -4,7 +4,7 @@ import { authenticate } from '../../../auth';
import { AuthInfo } from '../../../auth';
import { LocationRequest } from '../../../requests';
import { Optional } from '../../../types';
import { RoleName } from '../../../models';
import { GPSPoint, RoleName } from '../../../models';
import ApiV1Route from './Route';
class GPSData extends ApiV1Route {
@ -48,6 +48,20 @@ class GPSData extends ApiV1Route {
res.status(201).send();
}
@authenticate()
patch = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
const points = (req.body as GPSPoint[]).map((p) => {
const descr = p.description?.trim()
p.description = descr?.length ? descr : null;
return p;
});
const deviceIds = points.map((p: any) => p.deviceId).filter((d: any) => !!d);
this.validateOwnership(deviceIds, auth!);
await $repos.location.updatePoints(points);
res.status(204).send();
}
@authenticate()
delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
const pointIds = req.body as number[];