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. # Comment or leave empty if the postal code is not available.
# DB_LOCATION__POSTAL_CODE=postalCode # 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. ### Frontend configuration.
### This is only required if you want to run the frontend in development mode ### This is only required if you want to run the frontend in development mode

View file

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

View file

@ -22,6 +22,34 @@
<font-awesome-icon icon="fas fa-mountain" /> <font-awesome-icon icon="fas fa-mountain" />
{{ Math.round(point.altitude) }} m {{ Math.round(point.altitude) }} m
</p> </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="locality" v-if="point.locality">{{ point.locality }}</p>
<p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p> <p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p>
<p class="country" v-if="country"> <p class="country" v-if="country">
@ -51,7 +79,7 @@ import Dates from '../mixins/Dates.vue';
import GPSPoint from '../models/GPSPoint'; import GPSPoint from '../models/GPSPoint';
export default { export default {
emit: ['close', 'remove'], emit: ['close', 'edit', 'remove'],
mixins: [Dates], mixins: [Dates],
props: { props: {
point: { point: {
@ -61,6 +89,8 @@ export default {
data() { data() {
return { return {
newValue: null,
editDescription: false,
popup: null as Overlay | null, popup: null as Overlay | null,
} }
}, },
@ -99,12 +129,44 @@ export default {
map.addOverlay(this.popup) 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[]) { setPosition(coordinates: number[]) {
if (this.popup) { if (this.popup) {
this.popup.setPosition(coordinates) 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> </script>
@ -128,7 +190,7 @@ export default {
.header { .header {
position: absolute; position: absolute;
top: 0.5em; top: 0.5em;
right: 0.5em; right: 0;
button { button {
background: none; background: none;
@ -158,6 +220,57 @@ export default {
margin: -0.25em 0 0.25em 0; 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 { .timestamp {
color: var(--color-heading); color: var(--color-heading);
font-weight: bold; font-weight: bold;

View file

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

View file

@ -3,22 +3,50 @@ class GPSPoint {
public latitude: number; public latitude: number;
public longitude: number; public longitude: number;
public altitude: number; public altitude: number;
public deviceId: string;
public address: string; public address: string;
public locality: string; public locality: string;
public country: string; public country: string;
public postalCode: string; public postalCode: string;
public description?: string;
public timestamp: Date; public timestamp: Date;
constructor(public data: any) { constructor({
this.id = data.id; id,
this.latitude = data.latitude; latitude,
this.longitude = data.longitude; longitude,
this.altitude = data.altitude; altitude,
this.address = data.address; deviceId,
this.locality = data.locality; address,
this.country = data.country; locality,
this.postalCode = data.postalCode; country,
this.timestamp = data.timestamp; 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', 'address',
'locality', 'locality',
'country', 'country',
'postalCode' 'postalCode',
'description',
].reduce((acc: any, name: string) => { ].reduce((acc: any, name: string) => {
acc[name] = process.env[this.prefixedEnv(name)]; acc[name] = process.env[this.prefixedEnv(name)];
if (!acc[name]?.length && (requiredColumns[name] || opts.locationUrl === opts.url)) { 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, type: DataTypes.STRING,
allowNull: true allowNull: true
}, },
[$db.locationTableColumns['description']]: {
type: DataTypes.STRING,
allowNull: true
},
[$db.locationTableColumns['timestamp']]: { [$db.locationTableColumns['timestamp']]: {
type: DataTypes.DATE, type: DataTypes.DATE,
defaultValue: DataTypes.NOW, 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']] = { typeDef[locationTableColumns['timestamp']] = {
type: DataTypes.DATE, type: DataTypes.DATE,
defaultValue: DataTypes.NOW defaultValue: DataTypes.NOW

View file

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

View file

@ -26,6 +26,7 @@ class Location {
locality: data[mappings.locality], locality: data[mappings.locality],
country: data[mappings.country], country: data[mappings.country],
postalCode: data[mappings.postalCode], postalCode: data[mappings.postalCode],
description: data[mappings.description],
timestamp: data[mappings.timestamp], timestamp: data[mappings.timestamp],
}); });
}); });
@ -62,6 +63,7 @@ class Location {
locality: data[mappings.locality], locality: data[mappings.locality],
country: data[mappings.country], country: data[mappings.country],
postalCode: data[mappings.postalCode], postalCode: data[mappings.postalCode],
description: data[mappings.description],
timestamp: data[mappings.timestamp], timestamp: data[mappings.timestamp],
}); });
}); });
@ -94,6 +96,7 @@ class Location {
[mappings.locality]: p.locality, [mappings.locality]: p.locality,
[mappings.country]: p.country, [mappings.country]: p.country,
[mappings.postalCode]: p.postalcode, [mappings.postalCode]: p.postalcode,
[mappings.description]: p.description,
[mappings.timestamp]: p.timestamp [mappings.timestamp]: p.timestamp
} }
}, },
@ -111,6 +114,7 @@ class Location {
locality: data[mappings.locality], locality: data[mappings.locality],
country: data[mappings.country], country: data[mappings.country],
postalCode: data[mappings.postalCode], postalCode: data[mappings.postalCode],
description: data[mappings.description],
timestamp: data[mappings.timestamp], 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> { public async deletePoints(points: number[]): Promise<void> {
try { try {
await $db.GPSData().destroy({ await $db.GPSData().destroy({

View file

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

View file

@ -4,7 +4,7 @@ import { authenticate } from '../../../auth';
import { AuthInfo } from '../../../auth'; import { AuthInfo } from '../../../auth';
import { LocationRequest } from '../../../requests'; import { LocationRequest } from '../../../requests';
import { Optional } from '../../../types'; import { Optional } from '../../../types';
import { RoleName } from '../../../models'; import { GPSPoint, RoleName } from '../../../models';
import ApiV1Route from './Route'; import ApiV1Route from './Route';
class GPSData extends ApiV1Route { class GPSData extends ApiV1Route {
@ -48,6 +48,20 @@ class GPSData extends ApiV1Route {
res.status(201).send(); 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() @authenticate()
delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
const pointIds = req.body as number[]; const pointIds = req.body as number[];