- `;
-
- track.points.forEach((point, pointIndex) => {
- const speedInfo = pointIndex > 0 ? calculateSpeedInfo(track.points[pointIndex - 1], point) : null;
- const speedColor = speedInfo ? getSpeedColor(speedInfo.speed) : '#e2e8f0';
- const speedText = speedInfo ? `${speedInfo.speed.toFixed(1)}` : '-';
-
- const pointId = `point-${trackIndex}-${pointIndex}`;
-
- // Format accuracy for display
- const accuracyText = point.accuracy !== undefined ? `${point.accuracy.toFixed(0)}m` : '';
-
- html += `
-
-
-
${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}
-
${formatCompactTimestamp(point.timestamp)}
-
${speedText}
-
${accuracyText}
-
-
×
-
- `;
-
- // Track the last added point for scrolling
- if (trackIndex === currentTrackIndex && pointIndex === track.points.length - 1) {
- lastAddedPoint = pointId;
- }
- });
-
+ // view mode – prefer pinned point, then hover
+ if (pinnedPoint) {
+ showPointInfoForPinned();
+ } else if (features.length) {
+ showPointInfo(features[0].properties);
+ } else {
+ hidePointInfo();
+ }
+ }
+ } else {
+ if (editModeEnabled) {
+ hidePointInfo();
+ } else if (pinnedPoint) {
+ showPointInfoForPinned();
+ } else {
+ hidePointInfo();
+ }
+ }
+}
+
+// ---- point info panel ----------------------------------------------------
+function showPointInfo(props) {
+ const panel = document.getElementById('pointInfo');
+ const title = document.getElementById('pointInfoTitle');
+ const body = document.getElementById('pointInfoBody');
+ title.textContent = `${props.trackName} · Point ${props.pointIndex+1}`;
+ let html = `
+
Lat ${props.lat.toFixed(6)}
+
Lng ${props.lng.toFixed(6)}
+
Time ${new Date(props.timestamp).toLocaleString()}
+
Speed ${props.speed ? props.speed.toFixed(1)+' km/h' : '-'}
+
Elev ${props.elevation ? props.elevation.toFixed(1)+'m' : '-'}
+
Acc ${typeof props.accuracy === 'number' ? props.accuracy.toFixed(1)+'m' : '-'}
+
+ Delete
+ Center
+
`;
+
+ // ---- diffs to neighbour points -----------------------------------------
+ const track = tracks[props.trackIndex];
+ if (track) {
+ const pi = props.pointIndex;
+ const prev = pi > 0 ? track.points[pi - 1] : null;
+ const next = pi < track.points.length - 1 ? track.points[pi + 1] : null;
+ if (prev || next) {
+ html += '
';
+ html += '
Relative to neighbours
';
+ html += '
';
+ const p = track.points[pi];
+ if (prev) {
+ const dist = calculateDistance(prev.lat, prev.lng, p.lat, p.lng);
+ const dtSec = (p.timestamp - prev.timestamp) / 1000;
+ const spd = dist > 0 && dtSec > 0 ? (dist/1000) / (dtSec/3600) : null;
+ html += '
';
+ html += `
← Prev dist ${dist.toFixed(1)} m
`;
+ html += `
← Prev Δt ${dtSec.toFixed(0)} s
`;
+ html += `
← Prev Speed ${spd ? spd.toFixed(1)+' km/h' : '-'}
`;
html += '
';
- });
-
- pointsList.innerHTML = html;
-
- // Auto-scroll to latest point in paint mode
- if (paintMode && paintActive && lastAddedPoint) {
- setTimeout(() => {
- const element = document.getElementById(lastAddedPoint);
- if (element) {
- element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- }, 50);
- }
-}
-
-function toggleTrack(trackIndex) {
- tracks[trackIndex].collapsed = !tracks[trackIndex].collapsed;
+ }
+ if (next) {
+ const dist = calculateDistance(p.lat, p.lng, next.lat, next.lng);
+ const dtSec = (next.timestamp - p.timestamp) / 1000;
+ const spd = dist > 0 && dtSec > 0 ? (dist/1000) / (dtSec/3600) : null;
+ html += '
';
+ html += `
Next dist → ${dist.toFixed(1)} m
`;
+ html += `
Next Δt → ${dtSec.toFixed(0)} s
`;
+ html += `
Next Speed → ${spd ? spd.toFixed(1)+' km/h' : '-'}
`;
+ html += '
';
+ }
+ html += '
'; // flex container
+ html += '
'; // diffs
+ }
+ }
+ body.innerHTML = html;
+ panel.style.display = 'block';
+}
+function hidePointInfo(force = false) {
+ const el = document.getElementById('pointInfo');
+ // If the mouse is currently over the panel, do not hide it unless the caller explicitly forces it (e.g. the close button).
+ if (!force && el && el.matches(':hover')) return;
+ // close the panel and clear any pinned point
+ if (pinnedPoint && !editModeEnabled) {
+ clearPinned();
+ }
+ if (el) el.style.display = 'none';
+}
+function deletePointFromInfo(ti, pi) {
+ removePoint(ti, pi);
+ if (pinnedPoint && pinnedPoint.trackIndex === ti && pinnedPoint.pointIndex === pi) {
+ clearPinned();
+ }
+ hidePointInfo(true);
+}
+function centerOnPoint(lat, lng) {
+ map.flyTo({ center: [lng, lat], zoom: 14 });
+}
+
+function showPointInfoForPinned() {
+ if (!pinnedPoint) return;
+ const track = tracks[pinnedPoint.trackIndex];
+ if (!track || pinnedPoint.pointIndex >= track.points.length) { clearPinned(); hidePointInfo(true); return; }
+ const p = track.points[pinnedPoint.pointIndex];
+ let speed = null;
+ if (pinnedPoint.pointIndex > 0) {
+ const prev = track.points[pinnedPoint.pointIndex-1];
+ speed = (calculateDistance(prev.lat,prev.lng,p.lat,p.lng)/1000) / ((p.timestamp - prev.timestamp)/3600000);
+ }
+ const props = {
+ trackIndex: pinnedPoint.trackIndex,
+ pointIndex: pinnedPoint.pointIndex,
+ trackName: track.name,
+ color: track.color,
+ lat: p.lat,
+ lng: p.lng,
+ timestamp: p.timestamp.toISOString(),
+ speed,
+ elevation: p.elevation,
+ accuracy: p.accuracy
+ };
+ showPointInfo(props);
+}
+
+function clearPinned() {
+ pinnedPoint = null;
+}
+
+// ---- core point operations ------------------------------------------------
+function addPoint(lat, lng, options = {}) {
+ if (!tracks.length) createNewTrack();
+ const track = tracks[currentTrackIndex];
+ let ts = options.timestamp || getCurrentPickerDate();
+ if (!options.skipDayChangeCheck && shouldCreateNewTrackForDayChange(ts, track)) {
+ createNewTrack();
+ return addPoint(lat, lng, options);
+ }
+ if (!options.skipStops && document.getElementById('autoStops').checked && shouldAddStop(track)) {
+ ts = addRealisticStop(ts);
+ }
+ // accuracy: keep explicit value (from import) as is; otherwise leave undefined.
+ // use slider value only when noise generation is needed and no explicit accuracy given.
+ let noiseAcc = options.accuracy;
+ if (!options.skipNoise && noiseAcc === undefined) {
+ noiseAcc = parseFloat(document.getElementById('accuracySlider').value);
+ }
+ const coords = options.skipNoise ? {lat,lng} : applyGPSNoise(lat,lng,noiseAcc);
+ let elevation = options.elevation;
+ if (elevation === undefined) {
+ const base = parseFloat(document.getElementById('elevation').value);
+ const varE = parseFloat(document.getElementById('elevationVariation').value);
+ elevation = base + (Math.random()-0.5)*2*varE;
+ }
+ const point = {
+ lat: coords.lat, lng: coords.lng,
+ originalLat: lat, originalLng: lng,
+ timestamp: ts,
+ elevation,
+ accuracy: options.accuracy // undefined when not given → display '-'
+ };
+ track.points.push(point);
+ if (!options.skipTimeUpdate) advancePickerTime(ts);
+ if (!options.skipUpdate) {
+ updateAllLayers();
updatePointsList();
+ updateStatus();
+ }
}
-function selectTrackFromHeader(trackIndex) {
- // If clicking on current track, just toggle collapse
- if (trackIndex === currentTrackIndex) {
- toggleTrack(trackIndex);
- } else {
- // Select new track and ensure it's expanded
- currentTrackIndex = trackIndex;
- tracks[trackIndex].collapsed = false;
- updatePointsList();
- updateStatus();
- }
-}
-
-function selectPoint(trackIndex, pointIndex) {
- const track = tracks[trackIndex];
- if (pointIndex < 0 || pointIndex >= track.points.length) return;
-
- const point = track.points[pointIndex];
- map.setView([point.lat, point.lng], map.getZoom());
-
- // Highlight selected point in list
- document.querySelectorAll('.point-item').forEach(item => {
- item.classList.remove('selected');
- });
-
- // Find and highlight the correct point item
- const trackElements = document.querySelectorAll('.track-points');
- if (trackElements[trackIndex]) {
- const pointItems = trackElements[trackIndex].querySelectorAll('.point-item');
- if (pointItems[pointIndex]) {
- pointItems[pointIndex].classList.add('selected');
- }
- }
-}
-
-function formatTimestamp(timestamp) {
- return timestamp.toLocaleString();
-}
-
-function formatCompactTimestamp(timestamp) {
- const today = new Date();
- const pointDate = new Date(timestamp);
-
- // Check if it's the same date as today
- const isToday = pointDate.toDateString() === today.toDateString();
-
- if (isToday) {
- return pointDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
- } else {
- // Include date for different days
- const dateStr = pointDate.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
- const timeStr = pointDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- return `${dateStr} ${timeStr}`;
- }
-}
-
-function updateSpeedLegend() {
- const speedLegendInline = document.getElementById('speedLegendInline');
- const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
-
- const speedRanges = [
- { min: 0, max: 5, label: '0-5' },
- { min: 5, max: 10, label: '5-10' },
- { min: 10, max: 15, label: '10-15' },
- { min: 15, max: 20, label: '15-20' },
- { min: 20, max: 25, label: '20-25' },
- { min: 25, max: 30, label: '25-30' },
- { min: 30, max: 50, label: '30-50' },
- { min: 50, max: 100, label: '50+' }
- ];
-
- let html = '';
- speedRanges.forEach(range => {
- const color = getSpeedColor((range.min + range.max) / 2);
- html += `
-
- `;
- });
-
- speedLegendInline.innerHTML = html;
-}
-
-function getSpeedColor(speed) {
- // Color gradient based on speed ranges (5km/h intervals)
- if (speed < 5) return '#48bb78'; // Green - very slow
- if (speed < 10) return '#68d391'; // Light green - slow
- if (speed < 15) return '#9ae6b4'; // Lighter green - walking/cycling
- if (speed < 20) return '#fbb040'; // Orange - moderate cycling
- if (speed < 25) return '#ed8936'; // Dark orange - fast cycling
- if (speed < 30) return '#f56565'; // Red - very fast cycling/slow car
- if (speed < 50) return '#e53e3e'; // Dark red - car speed
- return '#c53030'; // Very dark red - high speed
-}
-
-function updateStatus() {
- const statusText = document.getElementById('statusText');
- const pointsSummary = document.getElementById('pointsSummary');
-
- let paintStatus = 'OFF';
- if (paintMode && paintActive) {
- paintStatus = 'PAINTING';
- } else if (paintMode) {
- paintStatus = 'READY';
- }
-
- const totalPoints = tracks.reduce((sum, track) => sum + track.points.length, 0);
- const maxSpeed = document.getElementById('maxSpeed').value;
- const modeText = `Paint: ${paintStatus} • Speed: ${maxSpeed}km/h • Current: ${tracks[currentTrackIndex]?.name || 'None'}`;
-
- if (totalPoints === 0) {
- if (!editModeEnabled) {
- statusText.textContent = 'View mode - switch to edit mode to add points';
- pointsSummary.textContent = 'Switch to edit mode to start creating tracks';
- } else if (paintMode && paintActive) {
- statusText.textContent = 'Painting active - move mouse to add points (±: adjust speed)';
- pointsSummary.textContent = 'Move mouse over the map to paint points';
- } else if (paintMode) {
- statusText.textContent = 'Paint mode ready - click map to start painting (±: adjust speed)';
- pointsSummary.textContent = 'Click on the map to start painting points';
- } else {
- statusText.textContent = 'Ready to create GPX tracks';
- pointsSummary.textContent = 'Click on the map to add points';
- }
- } else if (totalPoints === 1) {
- if (!editModeEnabled) {
- statusText.textContent = '1 point - switch to edit mode to add more';
- } else {
- statusText.textContent = '1 point added - click to add more points';
- }
- pointsSummary.innerHTML = `
1 point in ${tracks.length} track(s) • ${modeText}`;
- } else {
- const duration = calculateTotalDuration();
- const totalDistance = calculateTotalDistance();
- if (!editModeEnabled) {
- statusText.textContent = `${totalPoints} points in ${tracks.length} track(s) - viewing mode`;
- } else {
- statusText.textContent = `${totalPoints} points in ${tracks.length} track(s) - Total duration: ${formatDuration(duration)}`;
- }
- pointsSummary.innerHTML = `
${totalPoints} points • ${formatDistance(totalDistance)} • ${formatDuration(duration)} • ${modeText}`;
- }
-}
-
-function calculateTotalDuration() {
- if (tracks.length === 0) return 0;
-
- let firstTimestamp = null;
- let lastTimestamp = null;
-
- tracks.forEach(track => {
- if (track.points.length > 0) {
- const trackFirst = track.points[0].timestamp;
- const trackLast = track.points[track.points.length - 1].timestamp;
-
- if (!firstTimestamp || trackFirst < firstTimestamp) {
- firstTimestamp = trackFirst;
- }
- if (!lastTimestamp || trackLast > lastTimestamp) {
- lastTimestamp = trackLast;
- }
- }
- });
-
- if (!firstTimestamp || !lastTimestamp) return 0;
-
- return Math.floor((lastTimestamp - firstTimestamp) / 1000); // seconds
-}
-
-function formatDuration(seconds) {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}h ${minutes}m ${secs}s`;
- } else if (minutes > 0) {
- return `${minutes}m ${secs}s`;
- } else {
- return `${secs}s`;
- }
+function removePoint(trackIdx, pointIdx) {
+ const track = tracks[trackIdx];
+ if (!track || pointIdx<0 || pointIdx>=track.points.length) return;
+ track.points.splice(pointIdx,1);
+ updateAllLayers();
+ updatePointsList();
+ updateStatus();
}
function clearAll() {
- const totalPoints = tracks.reduce((sum, track) => sum + track.points.length, 0);
- if (totalPoints === 0) return;
-
- if (confirm('Are you sure you want to clear all tracks and points?')) {
- // Clear tracks array
- tracks = [];
-
- // Clear markers array
- markers = [];
-
- // Remove all polylines
- polylines.forEach(polyline => map.removeLayer(polyline));
- polylines = [];
-
- // Clear canvas
- redrawMarkers();
-
- // Create first track
- createNewTrack();
-
- // Update UI
- updatePointsList();
- updateStatus();
- }
-}
-
-function shouldCreateNewTrackForDayChange(newTimestamp, currentTrack) {
- const autoNewTrack = document.getElementById('autoNewTrack').checked;
- if (!autoNewTrack || currentTrack.points.length === 0) {
- return false;
- }
-
- const lastPoint = currentTrack.points[currentTrack.points.length - 1];
- const lastDate = new Date(lastPoint.timestamp).toDateString();
- const newDate = new Date(newTimestamp).toDateString();
-
- return lastDate !== newDate;
-}
-
-
-function exportTrackGPX(trackIndex) {
- const track = tracks[trackIndex];
- if (track.points.length === 0) {
- alert('This track has no points to export.');
- return;
- }
-
- const gpxContent = generateTrackGPX(track);
- const filename = generateTrackFilename(track);
-
- downloadFile(gpxContent, filename, 'application/gpx+xml');
-}
-
-function exportAllGPX() {
- const tracksWithPoints = tracks.filter(track => track.points.length > 0);
- if (tracksWithPoints.length === 0) {
- alert('Please add some points before exporting.');
- return;
- }
-
- tracksWithPoints.forEach((track, index) => {
- setTimeout(() => {
- const gpxContent = generateTrackGPX(track);
- const filename = generateTrackFilename(track);
- downloadFile(gpxContent, filename, 'application/gpx+xml');
- }, index * 100); // Small delay between downloads
- });
-}
-
-function generateTrackGPX(track) {
- const startDateTimeValue = document.getElementById('startDateTime').value;
- const startDateStr = startDateTimeValue ? new Date(startDateTimeValue).toISOString().split('T')[0] : '';
-
- let gpx = `
-
-
- ${track.name} ${startDateStr}
- Generated test track for Reitti
- ${new Date().toISOString()}
-
-
- ${track.name}
-
-`;
-
- track.points.forEach(point => {
- const isoTimestamp = point.timestamp.toISOString();
- gpx += `
- ${point.elevation.toFixed(1)}
- ${isoTimestamp}
-
-`;
- });
-
- gpx += `
-
- `;
-
- return gpx;
-}
-
-function generateTrackFilename(track) {
- const startDateTimeValue = document.getElementById('startDateTime').value;
-
- let filename = track.name.toLowerCase().replace(/\s+/g, '_');
-
- if (startDateTimeValue) {
- const dateTime = new Date(startDateTimeValue);
- const dateStr = dateTime.toISOString().split('T')[0];
- const timeStr = dateTime.toTimeString().split(' ')[0].replace(/:/g, '');
- filename += `_${dateStr}_${timeStr}`;
- } else if (track.points.length > 0) {
- const firstPoint = track.points[0];
- const dateStr = firstPoint.timestamp.toISOString().split('T')[0];
- const timeStr = firstPoint.timestamp.toTimeString().split(' ')[0].replace(/:/g, '');
- filename += `_${dateStr}_${timeStr}`;
- }
-
- return `${filename}.gpx`;
-}
-
-function downloadFile(content, filename, mimeType) {
- const blob = new Blob([content], { type: mimeType });
- const url = URL.createObjectURL(blob);
-
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
-
- URL.revokeObjectURL(url);
-}
-
-// GPX file handling functions
-function handleGPXFiles(event) {
- const files = Array.from(event.target.files);
- if (files.length === 0) return;
-
- files.forEach((file, index) => {
- const reader = new FileReader();
- reader.onload = function(e) {
- if (file.name.toLowerCase().endsWith('.json')) {
- handleGoogleJson(e.target.result, file.name);
- } else {
- try {
- const result = parseAndImportGPX(e.target.result, file.name, index === 0);
- } catch (error) {
- alert(`Error parsing GPX file "${file.name}": ${error.message}`);
- }
- }
- };
- reader.readAsText(file);
- });
-
- // Reset the input so the same files can be selected again
- event.target.value = '';
-}
-
-function handleGoogleJson(content, filename) {
- try {
- const data = JSON.parse(content);
- const locations = data.locations || [];
- if (locations.length === 0) {
- alert("No location data found in JSON.");
- return;
- }
-
- // Group points by date
- const days = {};
- locations.forEach(loc => {
- const timestamp = loc.timestamp || loc.timestampMs;
- if (!timestamp) return;
-
- const date = new Date(timestamp);
- const dateStr = date.toISOString().split('T')[0];
-
- if (!days[dateStr]) days[dateStr] = [];
- days[dateStr].push({
- lat: loc.latitudeE7 / 1e7,
- lng: loc.longitudeE7 / 1e7,
- timestamp: date,
- elevation: loc.altitude || 0,
- accuracy: loc.accuracy || 0
- });
- });
-
- pendingJsonData = days;
- pendingJsonFilename = filename.replace('.json', '');
- showJsonDatePicker();
- } catch (e) {
- alert("Error parsing Google Records JSON: " + e.message);
- }
-}
-
-function showJsonDatePicker() {
- const modal = document.getElementById('jsonDatePicker');
- const grid = document.getElementById('dateGrid');
- grid.innerHTML = '';
- selectedDates.clear();
- lastClickedDate = null;
-
- const sortedDates = Object.keys(pendingJsonData).sort();
- sortedDates.forEach(date => {
- const div = document.createElement('div');
- div.className = 'date-item';
- div.textContent = date;
- div.dataset.date = date;
- div.onclick = (e) => handleDateClick(date, e.shiftKey);
- grid.appendChild(div);
- });
-
- modal.classList.add('open');
-}
-
-function handleDateClick(date, isShift) {
- const sortedDates = Object.keys(pendingJsonData).sort();
-
- if (isShift && lastClickedDate) {
- const startIdx = sortedDates.indexOf(lastClickedDate);
- const endIdx = sortedDates.indexOf(date);
- const [low, high] = [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)];
-
- selectedDates.clear();
- for (let i = low; i <= high; i++) {
- selectedDates.add(sortedDates[i]);
- }
- } else {
- if (selectedDates.has(date)) {
- selectedDates.delete(date);
- } else {
- selectedDates.add(date);
- }
- lastClickedDate = date;
- }
- updateDateGridUI();
-}
-
-function updateDateGridUI() {
- const items = document.querySelectorAll('.date-item');
- items.forEach(item => {
- if (selectedDates.has(item.dataset.date)) {
- item.classList.add('selected');
- } else {
- item.classList.remove('selected');
- }
- });
-}
-
-function closeJsonPicker() {
- document.getElementById('jsonDatePicker').classList.remove('open');
- pendingJsonData = null;
-}
-
-function importSelectedJsonDates() {
- if (selectedDates.size === 0) {
- alert("Please select at least one date.");
- return;
- }
-
- const sortedSelected = Array.from(selectedDates).sort();
- let totalPoints = 0;
- const bounds = L.latLngBounds();
-
- // Collapse all existing tracks first
- tracks.forEach(track => {
- track.collapsed = true;
- });
-
- sortedSelected.forEach((dateStr, index) => {
- const dayPoints = pendingJsonData[dateStr];
- // Sort points within the day
- dayPoints.sort((a, b) => a.timestamp - b.timestamp);
-
- const trackName = `${pendingJsonFilename} - ${dateStr}`;
- const track = createNewTrack(trackName, dayPoints[0].timestamp);
-
- // Ensure the newly created track is expanded if it's part of the batch
- track.collapsed = false;
-
- dayPoints.forEach(p => {
- addPoint(p.lat, p.lng, {
- timestamp: p.timestamp,
- elevation: p.elevation,
- accuracy: p.accuracy,
- skipNoise: true,
- skipDayChangeCheck: true,
- skipStops: true,
- skipTimeUpdate: true,
- skipUIUpdate: true // Skip UI updates during batch
- });
- bounds.extend([p.lat, p.lng]);
- totalPoints++;
- });
- });
-
- if (totalPoints > 0) {
- map.fitBounds(bounds.pad(0.1));
- }
-
- closeJsonPicker();
+ if (!confirm('Clear all tracks and points?')) return;
+ tracks = [];
+ currentTrackIndex = 0;
+ updateAllLayers();
+ updatePointsList();
+ updateStatus();
+ createNewTrack();
+}
+
+// ---- track management -----------------------------------------------------
+function createNewTrack(name, startTime, skipUpdate = false) {
+ const idx = tracks.length;
+ const color = TRACK_COLORS[idx % TRACK_COLORS.length];
+ let sTime = startTime || getCurrentPickerDate();
+ if (!startTime && tracks.length) {
+ const lastTrack = tracks[tracks.length-1];
+ if (lastTrack.points.length) {
+ const lastT = lastTrack.points[lastTrack.points.length-1].timestamp;
+ sTime = new Date(lastT.getTime() + parseInt(document.getElementById('timeInterval').value)*1000);
+ }
+ }
+ const track = { id: idx, name: name || `Track ${idx+1}`, points: [], color, collapsed: false, startTime: sTime };
+ tracks.push(track);
+ currentTrackIndex = idx;
+ if (!skipUpdate) {
+ updateAllLayers();
updatePointsList();
updateStatus();
- redrawMarkers();
-}
-
-function parseAndImportGPX(gpxContent, filename, isFirstFile = true) {
- const parser = new DOMParser();
- const gpxDoc = parser.parseFromString(gpxContent, 'text/xml');
-
- // Check for parsing errors
- const parserError = gpxDoc.querySelector('parsererror');
- if (parserError) {
- throw new Error('Invalid GPX file format');
- }
-
- // Extract track points from all tracks and track segments
- const trackPoints = [];
- const gpxTracks = gpxDoc.querySelectorAll('trk');
-
- gpxTracks.forEach(track => {
- const trackSegments = track.querySelectorAll('trkseg');
- trackSegments.forEach(segment => {
- const points = segment.querySelectorAll('trkpt');
- points.forEach(point => {
- const lat = parseFloat(point.getAttribute('lat'));
- const lng = parseFloat(point.getAttribute('lon'));
-
- if (isNaN(lat) || isNaN(lng)) return;
-
- const timeElement = point.querySelector('time');
- const elevationElement = point.querySelector('ele');
-
- let timestamp = new Date();
- if (timeElement && timeElement.textContent) {
- timestamp = new Date(timeElement.textContent);
- if (isNaN(timestamp.getTime())) {
- timestamp = new Date();
- }
- }
-
- let elevation = parseFloat(document.getElementById('elevation').value);
- if (elevationElement && elevationElement.textContent) {
- const parsedElevation = parseFloat(elevationElement.textContent);
- if (!isNaN(parsedElevation)) {
- elevation = parsedElevation;
- }
- }
-
- trackPoints.push({
- lat: lat,
- lng: lng,
- timestamp: timestamp,
- elevation: elevation
- });
- });
- });
+ }
+ return track;
+}
+function newTrack() { createNewTrack(); }
+
+// ---- layers update --------------------------------------------------------
+function updateAllLayers() {
+ if (!map.getSource('tracks')) return;
+ // tracks line
+ const features = [];
+ tracks.forEach(track => {
+ if (track.points.length<2) return;
+ features.push({
+ type: 'Feature',
+ properties: { color: track.color },
+ geometry: { type:'LineString', coordinates: track.points.map(p => [p.lng, p.lat]) }
});
-
- if (trackPoints.length === 0) {
- alert('No valid track points found in the GPX file.');
- return;
- }
-
- // Sort points by timestamp
- trackPoints.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
-
- // Group points by local timezone day
- const pointsByDay = groupPointsByLocalDay(trackPoints);
-
- // Create tracks for each day
- let importedTracksCount = 0;
- let isFirstImportedTrack = isFirstFile;
-
- Object.keys(pointsByDay).sort().forEach(dayKey => {
- const dayPoints = pointsByDay[dayKey];
- if (dayPoints.length === 0) return;
-
- let track;
- let trackIndex;
- let color;
-
- // Check if we can reuse the first empty track
- if (isFirstImportedTrack && tracks.length > 0 && tracks[0].points.length === 0) {
- // Reuse the existing empty track
- track = tracks[0];
- trackIndex = 0;
- color = track.color;
-
- // Update track properties
- track.name = `${filename.replace('.gpx', '')} - ${dayKey}`;
- track.startTime = dayPoints[0].timestamp;
- track.collapsed = false;
-
- // Collapse all other existing tracks
- tracks.forEach((t, index) => {
- if (index !== 0) {
- t.collapsed = true;
- }
- });
- } else {
- // Collapse all existing tracks
- tracks.forEach(track => {
- track.collapsed = true;
- });
-
- // Create new track for this day
- trackIndex = tracks.length;
- color = TRACK_COLORS[trackIndex % TRACK_COLORS.length];
- const trackName = `${filename.replace('.gpx', '')} - ${dayKey}`;
-
- track = {
- id: trackIndex,
- name: trackName,
- points: [],
- color: color,
- collapsed: false,
- startTime: dayPoints[0].timestamp
- };
-
- tracks.push(track);
-
- // Create polyline for this track
- const polyline = L.polyline([], {
- color: color,
- weight: 3,
- opacity: 0.7
- }).addTo(map);
- polylines.push(polyline);
- }
-
- isFirstImportedTrack = false;
-
- // Add points to the track
- dayPoints.forEach((point, pointIndex) => {
- const trackPoint = {
- id: pointIndex,
- trackId: trackIndex,
- lat: point.lat,
- lng: point.lng,
- originalLat: point.lat,
- originalLng: point.lng,
- timestamp: point.timestamp,
- elevation: point.elevation,
- accuracy: 0 // Imported points have no accuracy simulation
- };
-
- track.points.push(trackPoint);
-
- // Store marker data for canvas rendering
- const markerData = {
- lat: point.lat,
- lng: point.lng,
- color: color,
- trackIndex: trackIndex,
- pointIndex: pointIndex,
- title: `${track.name} - Point ${pointIndex + 1}`
- };
-
- markers.push(markerData);
- });
-
- // Update polyline for this track
- updatePolyline(trackIndex);
- importedTracksCount++;
- });
-
- // Set current track to the last imported track
- if (importedTracksCount > 0) {
- currentTrackIndex = tracks.length - 1;
-
- // Fit map to show all imported points
- if (trackPoints.length > 0) {
- const bounds = L.latLngBounds();
- trackPoints.forEach(point => {
- bounds.extend([point.lat, point.lng]);
- });
- map.fitBounds(bounds.pad(0.1));
- }
- }
-
- // Update UI
- updatePointsList();
-
- // Redraw all markers
- redrawMarkers();
-
- updateStatus();
-
- // Return import statistics for summary
- return {
- pointsCount: trackPoints.length,
- tracksCount: importedTracksCount
- };
-}
-
-function groupPointsByLocalDay(points) {
- const pointsByDay = {};
-
- points.forEach(point => {
- // Get local date string (YYYY-MM-DD) using the browser's timezone
- const localDate = new Date(point.timestamp.getTime() - (point.timestamp.getTimezoneOffset() * 60000));
- const localDateString = localDate.toISOString().split('T')[0];
-
- if (!pointsByDay[localDateString]) {
- pointsByDay[localDateString] = [];
- }
-
- pointsByDay[localDateString].push(point);
+ });
+ map.getSource('tracks').setData({ type:'FeatureCollection', features });
+
+ // points
+ const pts = [];
+ tracks.forEach((track, ti) => {
+ track.points.forEach((p, pi) => {
+ let speed = null;
+ if (pi>0) {
+ const prev = track.points[pi-1];
+ speed = (calculateDistance(prev.lat,prev.lng,p.lat,p.lng)/1000) / ((p.timestamp-prev.timestamp)/3600000);
+ }
+ pts.push({
+ type:'Feature',
+ properties: {
+ trackIndex: ti, pointIndex: pi,
+ trackName: track.name,
+ color: track.color,
+ lat: p.lat, lng: p.lng,
+ timestamp: p.timestamp.toISOString(),
+ speed,
+ elevation: p.elevation,
+ accuracy: p.accuracy
+ },
+ geometry: { type:'Point', coordinates: [p.lng, p.lat] }
+ });
});
-
- return pointsByDay;
+ });
+ map.getSource('points').setData({ type:'FeatureCollection', features: pts });
+ map.getSource('preview').setData(emptyFC());
}
-// New functions for Phase 2 features
-
-function onMapMouseMove(e) {
- // Check if we are hovering over a marker first
- const containerPoint = map.mouseEventToContainerPoint(e.originalEvent);
- const tolerance = 10; // pixels
- let hoveredMarker = null;
-
- for (let i = 0; i < markers.length; i++) {
- const markerData = markers[i];
- const markerPoint = map.latLngToContainerPoint([markerData.lat, markerData.lng]);
-
- const dist = Math.sqrt(
- Math.pow(containerPoint.x - markerPoint.x, 2) +
- Math.pow(containerPoint.y - markerPoint.y, 2)
- );
-
- if (dist <= tolerance) {
- hoveredMarker = markerData;
- break;
- }
- }
-
- if (hoveredMarker) {
- showMarkerTooltip(e.originalEvent, hoveredMarker);
- previewLine.setLatLngs([]);
- return;
- }
-
- // Don't show preview or allow painting if edit mode is disabled
- if (!editModeEnabled) {
- hideHoverTooltip();
- previewLine.setLatLngs([]);
- return;
- }
-
- // Update mouse position for paint mode
- if (paintMode) {
- lastMousePosition = e.latlng;
- if (paintActive) {
- // Add point immediately when mouse moves during painting
- const now = Date.now();
- if (now - lastPaintTime >= paintThrottleMs) {
- const currentTrack = tracks[currentTrackIndex];
- if (currentTrack && currentTrack.points.length > 0) {
- addPointWithInterpolation(lastMousePosition.lat, lastMousePosition.lng);
- } else {
- addPoint(lastMousePosition.lat, lastMousePosition.lng);
- }
- lastPaintTime = now;
-
- // Reset the auto-paint interval since we just added a point
- resetAutoPaintInterval();
- }
- return; // Don't show preview when actively painting
+// ---- point list panel -----------------------------------------------------
+function updatePointsList() {
+ const container = document.getElementById('pointsList');
+ const badge = document.getElementById('pointCountBadge');
+ let total = 0;
+ tracks.forEach(t => total += t.points.length);
+ badge.textContent = total;
+
+ let html = '';
+ tracks.forEach((track, ti) => {
+ html += ``;
+ if (!track.collapsed) {
+ track.points.forEach((p, pi) => {
+ let speed = null;
+ if (pi>0) {
+ const prev = track.points[pi-1];
+ speed = (calculateDistance(prev.lat,prev.lng,p.lat,p.lng)/1000) / ((p.timestamp-prev.timestamp)/3600000);
}
+ const speedColor = speed ? getSpeedColorCached(speed) : '#888';
+ html += `
+ ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}
+ ${new Date(p.timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}
+
`;
+ });
}
-
- const currentTrack = tracks[currentTrackIndex];
- if (!currentTrack || currentTrack.points.length === 0) {
- hideHoverTooltip();
- previewLine.setLatLngs([]);
- return;
- }
-
- const lastPoint = currentTrack.points[currentTrack.points.length - 1];
- const mouseLatLng = e.latlng;
-
- // Update preview line with current track color
- previewLine.setStyle({ color: currentTrack.color });
- previewLine.setLatLngs([[lastPoint.lat, lastPoint.lng], [mouseLatLng.lat, mouseLatLng.lng]]);
-
- // Calculate preview information
- const distance = calculateDistance(lastPoint.lat, lastPoint.lng, mouseLatLng.lat, mouseLatLng.lng);
- const timeInterval = parseInt(document.getElementById('timeInterval').value);
- const speed = (distance / 1000) / (timeInterval / 3600); // km/h
- const speedClass = getSpeedClass(speed);
-
- // Show hover tooltip
- showHoverTooltip(e.originalEvent, mouseLatLng, distance, speed, speedClass);
-}
-
-function onMapMouseOut(e) {
- hideHoverTooltip();
- previewLine.setLatLngs([]);
+ });
+ if (!total) html = '
No points yet
';
+ container.innerHTML = html;
}
-function showMarkerTooltip(mouseEvent, markerData) {
- const track = tracks[markerData.trackIndex];
- const point = track.points[markerData.pointIndex];
- const tooltip = hoverTooltip;
-
- let speedText = '-';
- let speedClass = 'speed-ok';
-
- if (markerData.pointIndex > 0) {
- const prevPoint = track.points[markerData.pointIndex - 1];
- const speedInfo = calculateSpeedInfo(prevPoint, point);
- speedText = `${speedInfo.speed.toFixed(1)} km/h`;
- speedClass = getSpeedClass(speedInfo.speed);
- }
-
- // Format accuracy for tooltip
- const accuracyText = point.accuracy !== undefined ? `${point.accuracy.toFixed(0)}m` : 'N/A';
-
- tooltip.innerHTML = `
-
${track.name} - Point ${markerData.pointIndex + 1}
-
Time: ${formatTimestamp(point.timestamp)}
-
Speed: ${speedText}
-
Elevation: ${point.elevation.toFixed(1)}m
-
Accuracy: ${accuracyText}
- `;
-
- tooltip.style.left = (mouseEvent.pageX + 10) + 'px';
- tooltip.style.top = (mouseEvent.pageY - 10) + 'px';
- tooltip.style.display = 'block';
+function toggleTrack(idx) {
+ tracks[idx].collapsed = !tracks[idx].collapsed;
+ updatePointsList();
}
-function showHoverTooltip(mouseEvent, latLng, distance, speed, speedClass) {
- const tooltip = hoverTooltip;
- const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
- const timeInterval = parseInt(document.getElementById('timeInterval').value);
- const maxDistance = (maxSpeed * 1000 / 3600) * timeInterval; // meters
-
- let interpolationInfo = '';
- if (distance > maxDistance) {
- const numSegments = Math.ceil(distance / maxDistance);
- interpolationInfo = `
Will add ${numSegments} points
`;
- }
-
- // Show current accuracy setting in hover tooltip
- const currentAccuracy = document.getElementById('accuracySlider').value;
-
- tooltip.innerHTML = `
-
Lat: ${latLng.lat.toFixed(6)}
-
Lng: ${latLng.lng.toFixed(6)}
-
Distance: ${distance.toFixed(0)}m
-
Speed: ${speed.toFixed(1)} km/h
-
Accuracy: ${currentAccuracy}m
- ${interpolationInfo}
- `;
-
- tooltip.style.left = (mouseEvent.pageX + 10) + 'px';
- tooltip.style.top = (mouseEvent.pageY - 10) + 'px';
- tooltip.style.display = 'block';
+function exportTrackGPX(trackIndex) {
+ const track = tracks[trackIndex];
+ if (!track || !track.points.length) return;
+ const gpx = generateGPX(track);
+ downloadFile(gpx, `${track.name.replace(/\s+/g,'_')}.gpx`, 'application/gpx+xml');
+}
+
+function deleteTrack(trackIndex) {
+ const track = tracks[trackIndex];
+ if (!track) return;
+ if (!confirm(`Delete track "${track.name}" and all its points?`)) return;
+ // clear any pinned point that belongs to this track
+ if (pinnedPoint && pinnedPoint.trackIndex === trackIndex) {
+ clearPinned();
+ hidePointInfo(true);
+ }
+ tracks.splice(trackIndex, 1);
+ // update currentTrackIndex if it became invalid
+ if (currentTrackIndex >= tracks.length) {
+ currentTrackIndex = Math.max(0, tracks.length - 1);
+ }
+ updateAllLayers();
+ updatePointsList();
+ updateStatus();
+}
+
+function highlightPoint(ti, pi) {
+ const track = tracks[ti];
+ if (!track || pi>=track.points.length) return;
+ const p = track.points[pi];
+ map.flyTo({ center: [p.lng, p.lat], zoom: 15 });
+}
+
+// ---- paint mode -----------------------------------------------------------
+function togglePaintMode() {
+ if (!editModeEnabled) return;
+ paintMode = !paintMode;
+ if (paintMode) {
+ paintActive = false; // not painting until first click
+ } else {
+ paintActive = false;
+ }
+ window.paintMode = paintMode;
+ window.paintActive = paintActive;
+ updatePaintButton();
+ map.getContainer().style.cursor = paintMode ? 'crosshair' : '';
+
+ // When paint mode is toggled on, clear any other active tool.
+ // When toggled off, default back to add‑point mode.
+ if (typeof window.setTool === 'function') {
+ window.setTool(paintMode ? null : 'addpoint');
+ }
+}
+
+function togglePaintActive() {
+ if (!paintMode) return;
+ paintActive = !paintActive;
+ window.paintActive = paintActive;
+ updatePaintButton();
+}
+window.togglePaintActive = togglePaintActive;
+
+function updatePaintButton() {
+ const btn = document.getElementById('btnPaintMode');
+ if (!paintMode) btn.textContent = ' 🎨 Paint';
+ else if (paintActive) btn.textContent = '⏸ Painting';
+ else btn.textContent = '▶ Paint Ready';
+ btn.classList.toggle('active', paintMode);
+}
+
+// ---- interpolation --------------------------------------------------------
+function addPointWithInterpolation(targetLat, targetLng) {
+ const track = tracks[currentTrackIndex];
+ if (!track || !track.points.length) { addPoint(targetLat, targetLng); return; }
+ const last = track.points[track.points.length-1];
+ const dist = calculateDistance(last.lat, last.lng, targetLat, targetLng);
+ const timeInterval = parseInt(document.getElementById('timeInterval').value);
+ const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
+ let maxDist = (maxSpeed*1000/3600)*timeInterval;
+ if (maxDist <= 0) maxDist = 1;
+ if (dist <= maxDist) { addPoint(targetLat, targetLng); return; }
+ let segments = Math.ceil(dist/maxDist);
+ if (segments > MAX_INTERPOLATION_SEGMENTS) {
+ segments = MAX_INTERPOLATION_SEGMENTS;
+ console.warn('Too many interpolation points, capped at ' + MAX_INTERPOLATION_SEGMENTS);
+ }
+ const dLat = (targetLat - last.lat) / segments;
+ const dLng = (targetLng - last.lng) / segments;
+ const baseTs = last.timestamp;
+ const intervalMs = timeInterval * 1000;
+ for (let i=1; i<=segments; i++) {
+ const ts = new Date(baseTs.getTime() + i * intervalMs);
+ addPoint(last.lat + dLat*i, last.lng + dLng*i, {
+ timestamp: ts,
+ skipUpdate: true,
+ skipTimeUpdate: true,
+ skipDayChangeCheck: true,
+ skipStops: true
+ });
+ }
+ // after batch, update layers once
+ updateAllLayers();
+ updatePointsList();
+ updateStatus();
+ // advance picker time to after the last added point
+ const lastPointTs = track.points[track.points.length-1].timestamp;
+ advancePickerTime(lastPointTs);
}
-function hideHoverTooltip() {
- hoverTooltip.style.display = 'none';
+// ---- time & date picker (native) ------------------------------------------
+function getCurrentPickerDate() {
+ const el = document.getElementById('startDatetimeLocal');
+ return el && el.value ? new Date(el.value) : new Date();
}
-function calculateDistance(lat1, lng1, lat2, lng2) {
- const R = 6371000; // Earth's radius in meters
- const dLat = (lat2 - lat1) * Math.PI / 180;
- const dLng = (lng2 - lng1) * Math.PI / 180;
- const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
- Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
- Math.sin(dLng/2) * Math.sin(dLng/2);
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
- return R * c;
+function advancePickerTime(lastTs) {
+ const interval = parseInt(document.getElementById('timeInterval').value);
+ const next = new Date(lastTs.getTime() + interval*1000);
+ const localStr = next.getFullYear() + '-' + pad2(next.getMonth()+1) + '-' + pad2(next.getDate()) +
+ 'T' + pad2(next.getHours()) + ':' + pad2(next.getMinutes()) + ':' + pad2(next.getSeconds());
+ document.getElementById('startDatetimeLocal').value = localStr;
}
-function calculateSpeedInfo(point1, point2) {
- const distance = calculateDistance(point1.lat, point1.lng, point2.lat, point2.lng);
- const timeDiff = (point2.timestamp - point1.timestamp) / 1000; // seconds
- const speed = (distance / 1000) / (timeDiff / 3600); // km/h
-
- return { distance, speed };
+// ---- helpers for stops / day change ---------------------------------------
+function shouldCreateNewTrackForDayChange(newTs, track) {
+ if (!document.getElementById('autoNewTrack').checked || !track.points.length) return false;
+ const last = track.points[track.points.length-1].timestamp;
+ return new Date(last).toDateString() !== new Date(newTs).toDateString();
}
-
-function getSpeedClass(speed) {
+function shouldAddStop(track) {
+ return track.points.length >= 5 && Math.random() < stopProbability;
+}
+function addRealisticStop(baseTs) {
+ const stopSec = Math.random()*(10*60-30)+30;
+ return new Date(baseTs.getTime() + stopSec*1000);
+}
+function applyGPSNoise(lat,lng,acc) {
+ if (acc===0) return {lat,lng};
+ const latOff = (Math.random()-0.5)*2*(acc/111000);
+ const lngOff = (Math.random()-0.5)*2*(acc/(111000*Math.cos(lat*Math.PI/180)));
+ return { lat: lat+latOff, lng: lng+lngOff };
+}
+function calculateDistance(lat1,lng1,lat2,lng2) {
+ const R=6371000, dLat=(lat2-lat1)*Math.PI/180, dLng=(lng2-lng1)*Math.PI/180;
+ const a=Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2;
+ return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
+}
+function getSpeedColorCached(kmh) {
+ if (speedColorCache[kmh]) return speedColorCache[kmh];
+ let col;
+ if (kmh<5) col='#48bb78'; else if (kmh<10) col='#68d391'; else if (kmh<15) col='#9ae6b4';
+ else if (kmh<20) col='#fbb040'; else if (kmh<25) col='#ed8936'; else if (kmh<30) col='#f56565';
+ else col='#e53e3e';
+ speedColorCache[kmh]=col;
+ return col;
+}
+
+// ---- preview popup --------------------------------------------------------
+function updatePreviewPopup(e) {
+ const popup = document.getElementById('previewPopup');
+ if (!popup) return;
+ const mouseLat = e.lngLat.lat;
+ const mouseLng = e.lngLat.lng;
+ const track = tracks[currentTrackIndex];
+ let distance = null;
+ let speed = null;
+ let pointsToAdd = 1;
+ if (track && track.points.length) {
+ const last = track.points[track.points.length-1];
+ distance = calculateDistance(last.lat, last.lng, mouseLat, mouseLng);
+ const timeInterval = parseInt(document.getElementById('timeInterval').value);
const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
-
- if (speed <= maxSpeed) {
- return 'speed-ok';
- } else if (speed <= maxSpeed * 2) {
- return 'speed-fast';
+ const maxDist = (maxSpeed*1000/3600) * timeInterval;
+ if (distance <= maxDist) {
+ pointsToAdd = 1;
+ speed = (distance/1000) / (timeInterval/3600);
} else {
- return 'speed-unrealistic';
- }
-}
-
-function applyGPSNoise(lat, lng, accuracyMeters) {
- if (accuracyMeters === 0) {
- return { lat, lng };
- }
-
- // Convert accuracy to degrees (approximate)
- const latOffset = (Math.random() - 0.5) * 2 * (accuracyMeters / 111000);
- const lngOffset = (Math.random() - 0.5) * 2 * (accuracyMeters / (111000 * Math.cos(lat * Math.PI / 180)));
-
- return {
- lat: lat + latOffset,
- lng: lng + lngOffset
+ const segments = Math.ceil(distance / maxDist);
+ pointsToAdd = segments;
+ speed = (distance/segments/1000) / (timeInterval/3600);
+ }
+ }
+ let html = `
Lat, Lng ${mouseLat.toFixed(5)}, ${mouseLng.toFixed(5)}
`;
+ if (distance !== null) html += `
Distance ${distance.toFixed(1)} m
`;
+ if (speed !== null) html += `
Speed ${speed.toFixed(1)} km/h
`;
+ if (pointsToAdd > 1) html += `
Will add ${pointsToAdd} points
`;
+ if (!distance) html += `
Click to add first point
`;
+ popup.innerHTML = html;
+ // position near cursor
+ const mapRect = map.getContainer().getBoundingClientRect();
+ let left = mapRect.left + e.point.x + 15;
+ let top = mapRect.top + e.point.y + 15;
+ // respect drawer bounds
+ const drawer = document.getElementById('gpxEditPanel');
+ if (drawer && drawer.style.display !== 'none') {
+ const drawerRect = drawer.getBoundingClientRect();
+ if (left < drawerRect.right + 10) left = drawerRect.right + 12;
+ }
+ popup.style.left = left + 'px';
+ popup.style.top = top + 'px';
+ popup.style.display = 'block';
+}
+
+// ---- import / export ------------------------------------------------------
+function handleGPXFiles(event) {
+ const files = Array.from(event.target.files);
+ files.forEach(file => {
+ const reader = new FileReader();
+ reader.onload = e => {
+ if (file.name.endsWith('.json')) handleGoogleJson(e.target.result, file.name);
+ else parseAndImportGPX(e.target.result, file.name);
};
-}
-
-function addPointWithInterpolation(targetLat, targetLng) {
- const currentTrack = tracks[currentTrackIndex];
- if (!currentTrack || currentTrack.points.length === 0) {
- addPoint(targetLat, targetLng);
- return;
- }
-
- const lastPoint = currentTrack.points[currentTrack.points.length - 1];
- const distance = calculateDistance(lastPoint.lat, lastPoint.lng, targetLat, targetLng);
- const timeInterval = parseInt(document.getElementById('timeInterval').value);
- const maxSpeed = parseFloat(document.getElementById('maxSpeed').value);
-
- // Calculate max distance for given time interval and speed
- const maxDistance = (maxSpeed * 1000 / 3600) * timeInterval; // meters
-
- if (distance <= maxDistance) {
- // Direct point addition - speed is acceptable
- addPoint(targetLat, targetLng);
+ reader.readAsText(file);
+ });
+ event.target.value = '';
+}
+
+// Import GPX – group points by day and create separate tracks
+function parseAndImportGPX(content, filename) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(content,'text/xml');
+ const allPoints = [];
+ doc.querySelectorAll('trkpt').forEach(pt => {
+ const lat = parseFloat(pt.getAttribute('lat'));
+ const lng = parseFloat(pt.getAttribute('lon'));
+ if (isNaN(lat)||isNaN(lng)) return;
+ const timeEl = pt.querySelector('time');
+ let ts = new Date();
+ if (timeEl) ts = new Date(timeEl.textContent) || ts;
+ const ele = parseFloat(pt.querySelector('ele')?.textContent || '0');
+
+ // attempt to read accuracy from
+ let accuracy;
+ const accEl = pt.querySelector('extensions > accuracy') || pt.querySelector('accuracy');
+ if (accEl) {
+ const val = parseFloat(accEl.textContent);
+ if (!isNaN(val)) accuracy = val;
+ }
+
+ allPoints.push({lat,lng,timestamp:ts, elevation:ele, accuracy});
+ });
+ if (!allPoints.length) return;
+ allPoints.sort((a,b)=>a.timestamp-b.timestamp);
+
+ // group by date
+ const days = {};
+ allPoints.forEach(p => {
+ const dateStr = p.timestamp.toISOString().split('T')[0];
+ if (!days[dateStr]) days[dateStr] = [];
+ days[dateStr].push(p);
+ });
+
+ const sortedDates = Object.keys(days).sort();
+ const bounds = new maplibregl.LngLatBounds();
+
+ sortedDates.forEach((dateStr, idx) => {
+ const dayPoints = days[dateStr];
+ dayPoints.sort((a,b)=>a.timestamp-b.timestamp);
+ const trackName = `${filename.replace('.gpx','')} - ${dateStr}`;
+
+ let track;
+ // reuse the first empty track for the very first day, otherwise create a new track
+ if (idx === 0 && tracks.length && tracks[0].points.length === 0) {
+ track = tracks[0];
+ track.name = trackName;
+ track.startTime = dayPoints[0].timestamp;
} else {
- // Need interpolation - calculate intermediate points
- const numSegments = Math.ceil(distance / maxDistance);
- const latStep = (targetLat - lastPoint.lat) / numSegments;
- const lngStep = (targetLng - lastPoint.lng) / numSegments;
-
- // Add intermediate points
- for (let i = 1; i <= numSegments; i++) {
- const interpolatedLat = lastPoint.lat + (latStep * i);
- const interpolatedLng = lastPoint.lng + (lngStep * i);
- addPoint(interpolatedLat, interpolatedLng);
- }
- }
-}
-
-function calculateTotalDistance() {
- let totalDistance = 0;
-
- tracks.forEach(track => {
- if (track.points.length < 2) return;
-
- for (let i = 1; i < track.points.length; i++) {
- totalDistance += calculateDistance(
- track.points[i-1].lat, track.points[i-1].lng,
- track.points[i].lat, track.points[i].lng
- );
- }
+ track = createNewTrack(trackName, dayPoints[0].timestamp, true);
+ }
+
+ dayPoints.forEach(p => {
+ addPoint(p.lat, p.lng, {
+ timestamp: p.timestamp,
+ elevation: p.elevation,
+ accuracy: p.accuracy,
+ skipNoise: true,
+ skipStops: true,
+ skipTimeUpdate: true,
+ skipDayChangeCheck: true,
+ skipUpdate: true
+ });
+ bounds.extend([p.lng, p.lat]);
});
-
- return totalDistance;
-}
+ });
-function formatDistance(meters) {
- if (meters < 1000) {
- return `${meters.toFixed(0)}m`;
- } else {
- return `${(meters / 1000).toFixed(1)}km`;
- }
+ map.fitBounds(bounds, {padding:40});
+ updateAllLayers();
+ updatePointsList();
+ updateStatus();
}
-// Paint mode functions
-function togglePaintMode() {
- // Don't allow paint mode if edit mode is disabled
- if (!editModeEnabled) {
- return;
- }
-
- paintMode = !paintMode;
- paintActive = false; // Reset paint active state when toggling mode
-
- // Stop any active painting when toggling mode off
- if (!paintMode) {
- stopAutoPainting();
- lastMousePosition = null;
- }
-
- updatePaintModeButton();
-
- // Change cursor style when paint mode is active
- if (paintMode) {
- map.getContainer().style.cursor = 'crosshair';
- } else {
- map.getContainer().style.cursor = '';
- }
-
- updateStatus();
-}
-
-function updatePaintModeButton() {
- const button = document.getElementById('paintModeToggle');
-
- if (!paintMode) {
- button.textContent = 'Paint Mode: OFF';
- button.className = 'control-button';
- } else if (paintActive) {
- button.textContent = 'Paint Mode: PAINTING';
- button.className = 'control-button paint-active';
- } else {
- button.textContent = 'Paint Mode: READY';
- button.className = 'control-button paint-ready';
- }
-}
-
-function startAutoPainting() {
- if (paintInterval) {
- clearInterval(paintInterval);
- }
-
- paintInterval = setInterval(() => {
- if (paintActive && lastMousePosition) {
- const currentTrack = tracks[currentTrackIndex];
- if (currentTrack && currentTrack.points.length > 0) {
- addPointWithInterpolation(lastMousePosition.lat, lastMousePosition.lng);
- } else {
- addPoint(lastMousePosition.lat, lastMousePosition.lng);
- }
- lastPaintTime = Date.now();
- }
- }, 500); // Add point every 500ms
-}
-
-function resetAutoPaintInterval() {
- if (paintActive && paintInterval) {
- // Restart the interval to prevent double-adding points
- clearInterval(paintInterval);
- paintInterval = setInterval(() => {
- if (paintActive && lastMousePosition) {
- const currentTrack = tracks[currentTrackIndex];
- if (currentTrack && currentTrack.points.length > 0) {
- addPointWithInterpolation(lastMousePosition.lat, lastMousePosition.lng);
- } else {
- addPoint(lastMousePosition.lat, lastMousePosition.lng);
- }
- lastPaintTime = Date.now();
- }
- }, 500);
- }
-}
-
-function stopAutoPainting() {
- if (paintInterval) {
- clearInterval(paintInterval);
- paintInterval = null;
- }
-}
-
-// Realistic stops functions
-function shouldAddStop(track) {
- if (track.points.length < 5) return false; // Need some points before considering stops
- return Math.random() < stopProbability;
+// Google JSON import (same as before, with day grouping)
+function handleGoogleJson(content, filename) {
+ try {
+ const data = JSON.parse(content);
+ const locs = data.locations || [];
+ if (!locs.length) return;
+ const days = {};
+ locs.forEach(loc => {
+ const ts = new Date(loc.timestamp || loc.timestampMs);
+ const dateStr = ts.toISOString().split('T')[0];
+ if (!days[dateStr]) days[dateStr] = [];
+ days[dateStr].push({ lat: loc.latitudeE7/1e7, lng: loc.longitudeE7/1e7, timestamp: ts, elevation: loc.altitude||0, accuracy: loc.accuracy||0 });
+ });
+ pendingJsonData = days;
+ pendingJsonFilename = filename.replace('.json','');
+ showJsonDatePicker();
+ } catch(e) { alert('JSON parse error: '+e.message); }
}
-
-function addRealisticStop(baseTimestamp) {
- // Add a random stop duration between 30 seconds and 10 minutes
- const stopDuration = Math.random() * (10 * 60 - 30) + 30; // 30s to 10min in seconds
- return new Date(baseTimestamp.getTime() + (stopDuration * 1000));
+function showJsonDatePicker() {
+ selectedDates.clear(); lastClickedDate = null;
+ const grid = document.getElementById('dateGrid'); // we didn't include in html yet, so fallback
+ if (!grid) { // simple prompt
+ const dates = Object.keys(pendingJsonData).sort();
+ const selected = prompt('Select dates (comma separated):\n'+dates.join('\n'));
+ if (!selected) return;
+ selected.split(',').map(s=>s.trim()).forEach(d => { if (pendingJsonData[d]) selectedDates.add(d); });
+ importSelectedJsonDates();
+ return;
+ }
+ // (skip the modal/date grid implementation for brevity – keep existing fallback)
}
-
-// Canvas marker implementation for better performance
-function initializeCanvasMarkers() {
- // Create canvas element
- markerCanvas = document.createElement('canvas');
- markerCanvas.style.position = 'absolute';
- markerCanvas.style.top = '0';
- markerCanvas.style.left = '0';
- markerCanvas.style.pointerEvents = 'none';
- markerCanvas.style.zIndex = '400'; // Above map tiles but below controls
-
- // Create custom Leaflet layer for canvas
- markerCanvasLayer = L.Layer.extend({
- onAdd: function(map) {
- this._map = map;
- map.getPanes().overlayPane.appendChild(markerCanvas);
- this._reset();
- map.on('viewreset', this._reset, this);
- map.on('zoom', this._reset, this);
- map.on('move', this._reset, this);
- },
-
- onRemove: function(map) {
- map.getPanes().overlayPane.removeChild(markerCanvas);
- map.off('viewreset', this._reset, this);
- map.off('zoom', this._reset, this);
- map.off('move', this._reset, this);
- },
-
- _reset: function() {
- const size = this._map.getSize();
- const topLeft = this._map.containerPointToLayerPoint([0, 0]);
-
- markerCanvas.style.left = topLeft.x + 'px';
- markerCanvas.style.top = topLeft.y + 'px';
- markerCanvas.width = size.x;
- markerCanvas.height = size.y;
-
- redrawMarkers();
- }
+function importSelectedJsonDates() {
+ if (!selectedDates.size) return;
+ const sorted = Array.from(selectedDates).sort();
+ let total = 0;
+ const bounds = new maplibregl.LngLatBounds();
+ sorted.forEach(dateStr => {
+ const dayPoints = pendingJsonData[dateStr];
+ dayPoints.sort((a,b)=>a.timestamp-b.timestamp);
+ const track = createNewTrack(`${pendingJsonFilename} - ${dateStr}`, dayPoints[0].timestamp, true);
+ dayPoints.forEach(p => {
+ addPoint(p.lat,p.lng,{ timestamp:p.timestamp, elevation:p.elevation, accuracy:p.accuracy, skipNoise:true, skipStops:true, skipTimeUpdate:true, skipDayChangeCheck:true, skipUpdate: true });
+ bounds.extend([p.lng,p.lat]);
+ total++;
});
-
- // Add canvas layer to map
- new markerCanvasLayer().addTo(map);
-
- // Add click handler for marker interaction
- map.getContainer().addEventListener('contextmenu', handleMarkerContextMenu);
+ });
+ if (total) map.fitBounds(bounds, {padding:40});
+ updateAllLayers(); updatePointsList(); updateStatus();
+}
+
+function exportAll() {
+ tracks.forEach(track => {
+ if (!track.points.length) return;
+ const gpx = generateGPX(track);
+ downloadFile(gpx, `${track.name.replace(/\s+/g,'_')}.gpx`, 'application/gpx+xml');
+ });
+}
+function generateGPX(track) {
+ let xml = `
+
+ ${track.name} `;
+ track.points.forEach(p => {
+ xml += `${p.elevation.toFixed(1)} ${p.timestamp.toISOString()} \n`;
+ });
+ xml += ` `;
+ return xml;
+}
+function downloadFile(content, filename, mime) {
+ const blob = new Blob([content],{type:mime});
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(a.href);
+}
+
+// ---- additional control functions -----------------------------------------
+function toggleEditMode() {
+ editModeEnabled = !editModeEnabled;
+ window.editModeEnabled = editModeEnabled;
+ const btn = document.getElementById('btnEditMode');
+ btn.classList.toggle('active', editModeEnabled);
+ btn.textContent = editModeEnabled ? '✎ Edit (on)' : '✎ Edit';
+ const drawer = document.getElementById('gpxEditPanel');
+ if (!editModeEnabled) {
+ if (paintMode) togglePaintMode();
+ if (drawer) drawer.style.display = 'none';
+ document.body.classList.remove('edit-mode');
+ clearPinned();
+ hidePointInfo(true);
+ } else {
+ if (drawer) drawer.style.display = '';
+ document.body.classList.add('edit-mode');
+ clearPinned();
+ hidePointInfo(true);
+ }
+ updateStatus();
}
-function redrawMarkers() {
- if (!markerCanvas) return;
-
- const ctx = markerCanvas.getContext('2d');
- ctx.clearRect(0, 0, markerCanvas.width, markerCanvas.height);
-
- // Draw all markers
- markers.forEach(markerData => {
- const point = map.latLngToContainerPoint([markerData.lat, markerData.lng]);
- drawMarker(ctx, point.x, point.y, markerData.color);
+function shiftTrackTime(amount, unit) {
+ const track = tracks[currentTrackIndex];
+ if (!track || !track.points.length) return;
+ let ms = unit === 'hour' ? amount*3600000 : amount*86400000;
+ track.points.forEach(p => p.timestamp = new Date(p.timestamp.getTime()+ms));
+ track.startTime = new Date(track.startTime.getTime()+ms);
+ updateAllLayers();
+ updatePointsList();
+ updateStatus();
+}
+
+function randomizeAll() {
+ const timeOff = (Math.random()*2-1)*30*86400000;
+ const lngOff = (Math.random()*2-1)*180;
+ tracks.forEach(track => {
+ track.startTime = new Date(track.startTime.getTime()+timeOff);
+ track.points.forEach(p => {
+ p.timestamp = new Date(p.timestamp.getTime()+timeOff);
+ let nl = p.lng + lngOff;
+ while (nl < -180) nl+=360; while (nl >= 180) nl-=360;
+ p.lng = nl; p.originalLng = nl;
});
+ });
+ updateAllLayers(); updatePointsList(); updateStatus();
+ const bounds = new maplibregl.LngLatBounds();
+ tracks.forEach(t => t.points.forEach(p => bounds.extend([p.lng,p.lat])));
+ if (!bounds.isEmpty()) map.fitBounds(bounds, {padding:40});
}
-function drawMarker(ctx, x, y, color) {
- const radius = 4;
- const borderWidth = 2;
-
- // Draw white border
- ctx.beginPath();
- ctx.arc(x, y, radius + borderWidth, 0, 2 * Math.PI);
- ctx.fillStyle = 'white';
- ctx.fill();
- ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
- ctx.lineWidth = 1;
- ctx.stroke();
-
- // Draw colored center
- ctx.beginPath();
- ctx.arc(x, y, radius, 0, 2 * Math.PI);
- ctx.fillStyle = color;
- ctx.fill();
+function updateStatus() {
+ // minimal status – could be placed in a small bar, omitted for now
}
-function handleMarkerContextMenu(e) {
- if (!editModeEnabled) return;
-
- e.preventDefault();
-
- const containerPoint = map.mouseEventToContainerPoint(e);
- const tolerance = 10; // pixels
-
- // Find marker near click point
- for (let i = 0; i < markers.length; i++) {
- const markerData = markers[i];
- const markerPoint = map.latLngToContainerPoint([markerData.lat, markerData.lng]);
-
- const distance = Math.sqrt(
- Math.pow(containerPoint.x - markerPoint.x, 2) +
- Math.pow(containerPoint.y - markerPoint.y, 2)
- );
-
- if (distance <= tolerance) {
- removePoint(markerData.trackIndex, markerData.pointIndex);
- break;
- }
- }
+// controls init
+function initControls() {
+ document.getElementById('accuracySlider').addEventListener('input', function() {
+ document.getElementById('accuracyValue').textContent = this.value;
+ });
}
-// Time shifting functions
-function shiftTrackTime(amount, unit) {
- if (tracks.length === 0 || currentTrackIndex >= tracks.length) {
- alert('No track selected to shift time.');
- return;
- }
-
- const currentTrack = tracks[currentTrackIndex];
- if (currentTrack.points.length === 0) {
- alert('Current track has no points to shift.');
- return;
- }
-
- // Calculate milliseconds to shift
- let shiftMs = 0;
- if (unit === 'hour') {
- shiftMs = amount * 60 * 60 * 1000; // hours to milliseconds
- } else if (unit === 'day') {
- shiftMs = amount * 24 * 60 * 60 * 1000; // days to milliseconds
- }
-
- // Shift all points in the current track
- currentTrack.points.forEach(point => {
- point.timestamp = new Date(point.timestamp.getTime() + shiftMs);
- });
-
- // Update track start time
- if (currentTrack.points.length > 0) {
- currentTrack.startTime = new Date(currentTrack.points[0].timestamp);
- }
-
- // Update the datetime input to reflect the new time of the last point
- if (currentTrack.points.length > 0) {
- const lastPoint = currentTrack.points[currentTrack.points.length - 1];
- const timeInterval = parseInt(document.getElementById('timeInterval').value);
- const nextTimestamp = new Date(lastPoint.timestamp.getTime() + (timeInterval * 1000));
-
- // Format for datetime-local input
- const year = nextTimestamp.getFullYear();
- const month = String(nextTimestamp.getMonth() + 1).padStart(2, '0');
- const day = String(nextTimestamp.getDate()).padStart(2, '0');
- const hours = String(nextTimestamp.getHours()).padStart(2, '0');
- const minutes = String(nextTimestamp.getMinutes()).padStart(2, '0');
- const seconds = String(nextTimestamp.getSeconds()).padStart(2, '0');
-
- const datetimeString = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
- document.getElementById('startDateTime').value = datetimeString;
- }
-
- // Update UI
- updatePointsList();
- updateStatus();
-
- // Show confirmation
- const unitText = unit === 'hour' ? 'hour' : 'day';
- const direction = amount > 0 ? 'forward' : 'backward';
- const absAmount = Math.abs(amount);
+// About modal functions
+function showAbout() {
+ const modal = document.getElementById('aboutModal');
+ if (modal) modal.style.display = 'flex';
+}
+function closeAbout() {
+ const modal = document.getElementById('aboutModal');
+ if (modal) modal.style.display = 'none';
}
-// Randomization function for all tracks
-function randomizeAllData() {
- if (tracks.length === 0) {
- alert('No data to randomize.');
- return;
- }
-
- // 1. Time offset: ±1 month (30 days) in milliseconds
- const timeOffsetMs = (Math.random() * 2 - 1) * 30 * 24 * 60 * 60 * 1000;
-
- // 2. Longitude offset: arbitrary amount, wrap within [-180, 180]
- // Choose a random offset between -180 and 180 degrees
- const lngOffset = (Math.random() * 2 - 1) * 180; // -180 to 180
-
- // Apply to all tracks and points
- tracks.forEach(track => {
- // Shift track start time
- if (track.startTime) {
- track.startTime = new Date(track.startTime.getTime() + timeOffsetMs);
- }
- track.points.forEach(point => {
- // Apply time offset
- point.timestamp = new Date(point.timestamp.getTime() + timeOffsetMs);
-
- // Apply longitude offset and wrap to valid range
- let newLng = point.lng + lngOffset;
- // Wrap to [-180, 180)
- while (newLng < -180) newLng += 360;
- while (newLng >= 180) newLng -= 360;
- point.lng = newLng;
- // Also update originalLng for consistency
- point.originalLng = newLng;
- });
- });
-
- // Update markers array
- markers.forEach(marker => {
- let newLng = marker.lng + lngOffset;
- while (newLng < -180) newLng += 360;
- while (newLng >= 180) newLng -= 360;
- marker.lng = newLng;
- });
-
- // Update polylines
- tracks.forEach((track, index) => {
- updatePolyline(index);
- });
-
- // Redraw markers
- redrawMarkers();
-
- // Update UI
- updatePointsList();
- updateStatus();
+// Export helpers for keyboard navigation
+window.getPinnedPoint = function() {
+ return pinnedPoint;
+};
+window.setPinnedPoint = function(ti, pi) {
+ const track = tracks[ti];
+ if (!track || pi < 0 || pi >= track.points.length) return;
+ pinnedPoint = { trackIndex: ti, pointIndex: pi };
+ if (window.lastSelectedPoint !== undefined) {
+ window.lastSelectedPoint = { trackIndex: ti, pointIndex: pi };
+ }
+ showPointInfoForPinned();
+};
- // Pan the map to show the moved points
- // Calculate bounds of all points
- const bounds = L.latLngBounds();
- let hasPoints = false;
-
- tracks.forEach(track => {
- track.points.forEach(point => {
- bounds.extend([point.lat, point.lng]);
- hasPoints = true;
- });
- });
-
- // If there are points, fit the map to their bounds
- if (hasPoints) {
- map.fitBounds(bounds.pad(0.1)); // Add 10% padding
- }
-}
+// automatically start in view mode (edit off)
+editModeEnabled = false;
diff --git a/docs/tools/gpx-generator/img/logo.svg b/docs/tools/gpx-generator/img/logo.svg
new file mode 100644
index 000000000..d7cd316d7
--- /dev/null
+++ b/docs/tools/gpx-generator/img/logo.svg
@@ -0,0 +1,311 @@
+
+
+
+
diff --git a/docs/tools/gpx-generator/index.html b/docs/tools/gpx-generator/index.html
index a37272086..5821ba7ba 100644
--- a/docs/tools/gpx-generator/index.html
+++ b/docs/tools/gpx-generator/index.html
@@ -1,809 +1,1393 @@
-
-
- GPX Test Data Generator - Create and Edit GPS Tracks
-
-
-
-
-
-
-
-
-
-
-
-
-
+ /* new floating settings panel (edit drawer) */
+ #gpxEditPanel {
+ position: fixed;
+ left: 16px;
+ top: 110px;
+ display: flex; flex-direction: column;
+ background: var(--panel-solid);
+ border: 1px solid var(--panel-line);
+ border-radius: 10px;
+ z-index: 20;
+ box-shadow: 0 20px 50px rgba(0,0,0,0.6);
+ }
+ #gpxEditPanel .history-header { flex-shrink: 0; }
+ #gpxEditPanel .gpx-drawer-body {
+ flex: 1;
+ overflow-y: auto;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 18px;
+ padding: 16px;
+ }
+
+ /* full‑width header at the top */
+ #gpxHeader {
+ position: fixed;
+ top: 0; left: 0; right: 0;
+ height: 50px;
+ background: var(--panel-header-bg, #1a1c23);
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ padding: 0 24px;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
+ }
+
+ #gpxHeader .drawer-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: nowrap;
+ width: 100%;
+ overflow-x: auto;
+ }
+
+ /* keep existing point‑list styles */
+ .gpx-drawer-body {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 18px;
+ padding: 16px;
+ height: auto;
+ }
+ .gpx-drawer-section { margin-bottom: 18px; }
+ .gpx-drawer-section-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--color-highlight);
+ margin-bottom: 8px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--panel-line);
+ }
+ .gpx-drawer .control-group { margin-bottom: 12px; }
+ .gpx-drawer .control-label { margin-bottom: 4px; }
+ .gpx-drawer input[type="number"],
+ .gpx-drawer input[type="range"],
+ .gpx-drawer input[type="datetime-local"] {
+ width: 100%;
+ }
+ /* point info panel – same as workbench .selection-info just renamed */
+ #pointInfo {
+ position: fixed;
+ bottom: 200px; left: 16px;
+ width: 380px;
+ max-height: 400px;
+ overflow-y: auto;
+ z-index: 50;
+ background: #f7f7f7;
+ color: #222;
+ }
+ #pointInfo .sel-info-body { padding: 10px 12px; }
+ #pointInfo .sel-info-row { display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 12px; }
+ #pointInfo .sel-info-row .k { color: #222; }
+ #pointInfo .sel-info-row .v { color: #222; text-align: right; }
+ #pointInfo .sel-info-actions { display: flex; gap: 6px; padding: 6px 0 0; border-top: 1px solid rgba(0,0,0,0.12); margin-top: 6px; }
+ #pointInfo .sel-info-head .sel-info-title {
+ color: #222;
+ }
+ #pointInfo .sel-info-close {
+ color: #222;
+ background: none;
+ border: none;
+ font-size: 16px;
+ cursor: pointer;
+ }
+ /* diffs section */
+ #pointInfo .sel-info-diffs {
+ margin-top: 8px;
+ border-top: 1px solid rgba(0,0,0,0.12);
+ padding-top: 6px;
+ }
+ /* floating points panel */
+ #pointsPanel {
+ position: fixed;
+ right: 16px; top: 110px;
+ width: 380px;
+ max-height: calc(100vh - 200px);
+ display: flex; flex-direction: column;
+ background: var(--panel-solid);
+ border: 1px solid var(--panel-line);
+ border-radius: 10px;
+ z-index: 10;
+ box-shadow: 0 20px 50px rgba(0,0,0,0.6);
+ }
+ #pointsPanel .history-header { flex-shrink: 0; }
+ #pointsPanel .history-list { flex:1; overflow-y: auto; }
+ .gpx-track-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 8px 12px;
+ background: rgba(255,255,255,0.02);
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+ cursor: pointer;
+ }
+ .gpx-track-header:hover { background: rgba(255,255,255,0.05); }
+ .gpx-track-header .track-color { width: 12px; height: 12px; border-radius: 50%; margin-right: 8px; display: inline-block; vertical-align: middle; }
+ .gpx-point { padding: 4px 12px 4px 28px; font-size: 11px; border-bottom: 1px solid rgba(255,255,255,0.03); cursor: pointer; display: flex; justify-content: space-between; }
+ .gpx-point:hover { background: rgba(255,255,255,0.04); }
+ .gpx-point .coords { color: var(--ink-dim); }
+ .gpx-point .time { color: var(--color-text-white); min-width: 70px; text-align: right; }
+ .speed-color-dot { display:inline-block; width:8px; height:8px; border-radius:2px; margin-right:4px; }
+
+ /* -- track header sub‑components -- */
+ .track-info {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+ flex: 1;
+ min-width: 0;
+ }
+ .track-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 150px; /* adjust to taste */
+ }
+ .track-controls {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ margin-left: 8px;
+ }
+ .track-export-btn {
+ padding: 2px 6px;
+ font-size: 10px;
+ line-height: 1;
+ border: 1px solid var(--panel-line);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--ink);
+ cursor: pointer;
+ white-space: nowrap;
+ }
+ .track-export-btn:hover {
+ background: var(--panel-hover, rgba(255,255,255,0.1));
+ }
+
+ /* preview popup */
+ #previewPopup {
+ display: none;
+ position:fixed; z-index:70; pointer-events:none;
+ padding: 8px 10px; font-size:11px; line-height:1.4;
+ background: var(--panel-solid);
+ border:1px solid var(--panel-line);
+ border-radius:6px;
+ color: var(--ink);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.45);
+ }
+ #previewPopup .row { display:flex; justify-content:space-between; margin-bottom:2px; }
+ #previewPopup .label { color:var(--ink-faint); }
+ #previewPopup .value { text-align:right; color:var(--ink); }
+
+ /* when edit drawer is visible, reposition point info beside the drawer */
+ body.edit-mode #pointInfo { left: 430px; }
+
+ /* About modal */
+ #aboutModal {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; width: 100%; height: 100%;
+ background: rgba(0,0,0,0.45);
+ z-index: 300;
+ justify-content: center;
+ align-items: center;
+ }
+ #aboutModal.show { display: flex; }
+ #aboutModal .modal-box {
+ background: var(--panel-solid);
+ border: 1px solid var(--panel-line);
+ border-radius: 12px;
+ max-width: 520px;
+ width: 90%;
+ box-shadow: 0 20px 40px rgba(0,0,0,0.6);
+ color: var(--ink);
+ }
+ #aboutModal .modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--panel-line);
+ }
+ #aboutModal .modal-body {
+ padding: 20px;
+ font-size: 14px;
+ line-height: 1.5;
+ }
+ #aboutModal .modal-body a {
+ color: var(--color-highlight, #3498db);
+ }
+ #aboutModal .modal-close {
+ background: none;
+ border: none;
+ font-size: 22px;
+ line-height: 1;
+ cursor: pointer;
+ color: var(--ink-faint);
+ padding: 0 4px;
+ }
+
+ /* Polku about improvements */
+ #aboutModal .about-logo {
+ display: block;
+ margin: 0 auto 20px auto;
+ height: 80px;
+ max-width: 100%;
+ }
+ #aboutModal .about-description {
+ font-size: 15px;
+ line-height: 1.6;
+ color: var(--ink);
+ }
+ #aboutModal .about-description strong {
+ font-weight: 700;
+ color: var(--color-highlight, #3498db);
+ }
+ #aboutModal .about-description a {
+ font-weight: 600;
+ }
+
+ /* Help modal */
+ #helpModal {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; width: 100%; height: 100%;
+ background: rgba(0,0,0,0.45);
+ z-index: 300;
+ justify-content: center;
+ align-items: center;
+ }
+ #helpModal.show { display: flex; }
+ #helpModal .modal-box {
+ background: var(--panel-solid);
+ border: 1px solid var(--panel-line);
+ border-radius: 12px;
+ max-width: 520px;
+ width: 90%;
+ box-shadow: 0 20px 40px rgba(0,0,0,0.6);
+ color: var(--ink);
+ }
+ #helpModal .modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--panel-line);
+ }
+ #helpModal .modal-body {
+ padding: 20px;
+ font-size: 14px;
+ line-height: 1.5;
+ }
+ #helpModal .modal-close {
+ background: none;
+ border: none;
+ font-size: 22px;
+ line-height: 1;
+ cursor: pointer;
+ color: var(--ink-faint);
+ padding: 0 4px;
+ }
+
+ /* box select overlay */
+ #boxSelectOverlay {
+ position: absolute;
+ border: 2px dashed rgba(255,255,255,0.9);
+ background: rgba(255,255,255,0.15);
+ pointer-events: none;
+ display: none;
+ z-index: 5;
+ }
+
+ /* navigation inside point info */
+ .sel-info-nav {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ padding-top: 6px;
+ border-top: 1px solid rgba(0,0,0,0.12);
+ }
+
-
-
-
-
-
-
-
-
-
-
-
Track Settings
-
- Start Date & Time
-
-
-
- Interval (seconds)
-
-
-
- Max Speed (km/h)
-
-
-
-
-
-
Elevation & GPS
-
- Elevation (m)
-
-
-
- ±Variation (m)
-
-
-
- GPS Accuracy: 5 m
-
-
-
-
-
-
Options
-
-
-
- Auto new track on day change
-
-
-
-
-
- Add realistic stops
-
-
-
-
-
-
Drawing Tools
-
-
Paint Mode: OFF
-
Click map to start/stop painting
-
-
- New Track
-
-
-
-
-
Time Adjustment
-
-
Shift Current Track
-
- -1h
- +1h
-
-
- -1d
- +1d
-
-
-
-
-
-
-
-
-
-
-
-
-
- Click on the map to add points
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Date & Time
+
+ Start Time
+
+
+
+
+
+
Track Settings
+
+ Interval (s)
+
-
-
-
Ready to create GPX track
-
-
-
+
+ Max Speed (km/h)
+
+
+
+
Elevation & GPS
+
+ Elevation (m)
+
+
+
+ ±Variation (m)
+
+
+
+ GPS Accuracy: 5 m
+
+
+
-
-
-
-
-
-
-
This interactive GPX generator is designed for creating realistic GPS test data for location-based applications. It's particularly useful for testing and development of GPS tracking systems, route planning applications, and location analytics tools.
-
-
Features:
-
- Interactive map-based point creation
- Realistic speed and elevation simulation
- Customizable GPS accuracy and timing
- Multiple track support with export capabilities
- Paint mode for continuous track drawing
-
-
-
Use Cases:
-
- Testing location-based mobile applications
- Creating sample data for GPS analytics
- Simulating user movement patterns
- Generating test routes for navigation systems
-
-
-
This tool is part of the Reitti project - an open-source location tracking and analytics platform.
-
+
+
+
+
Time Adjustment
+
+
Shift Current Track
+
+ -1h
+ +1h
+ -1d
+ +1d
+
+
+
+
+
Drawing
+
New Track
+
🎨 Paint
+
+
+
-
-
-
-
-
-
Select a range of dates from the Google Records file. Click a date to select it, or use Shift+Click to select a range.
-
-
- Cancel
- Import Selected
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This interactive GPX generator is designed for creating realistic GPS test data for location-based applications. It's particularly useful for testing and development of GPS tracking systems, route planning applications, and location analytics tools.
+
All processing happens locally – no track data is ever sent to any server. Your privacy is fully respected.
+
Features:
+
+ Interactive map-based point creation
+ Realistic speed and elevation simulation
+ Customizable GPS accuracy and timing
+ Multiple track support with export capabilities
+ Paint mode for continuous track drawing
+ Moving a whole path by a randomized value around the globe
+
+
Use Cases:
+
+ Testing location-based mobile applications
+ Creating sample data for GPS analytics
+ Simulating user movement patterns
+ Generating test routes for navigation systems
+
+
Polku (Finnish for “path” or “trail”) captures the core idea: connecting points into a track tells a story of movement. It is a companion tool for the Reitti project. A personal location tracking and analysis application that helps you understand your movement patterns and significant places. Together, these tools give you full control over your GPS data.
+
Visit the Reitti website or explore the code on GitHub .
+
+
+
+
+
+
+
+
+
+
+
Selection Modes
+
+ View mode – click a point to see its details; press ← /→ to move between points.
+ Edit mode → Select – click point to select/deselect; drag to move selected point(s); use Box select for areas.
+ Edit mode → Box select – drag to select all points in area; Ctrl ‑click to add to selection; Ctrl ‑drag (box) to add to selection.
+
+
Keyboard Shortcuts
+
+
+ ← / →
+ Move to previous / next point (single selection); point info stays open
+
+
+ Shift +← / Shift +→
+ Expand selection by one adjacent point in that direction
+
+
+ Ctrl ‑click on a point
+ Add / remove that point from the current selection (toggle)
+
+
+
+
Paint Mode
+
+ Paint mode lets you draw track points continuously while moving the mouse,
+ similar to a brush on canvas. Click the 🎨 Paint button in the Settings panel
+ to enable paint mode. Then click anywhere on the map to start painting; the button changes
+ to ⏸ Painting . Move the mouse to lay down points – the faster you move, the more
+ interpolation points are inserted, respecting the current maximum speed and interval settings.
+ Click again on the map (or press the ⏸ Painting button) to stop painting.
+
+
+ When paint mode is active all other tools (Select, Box Select, Add Point) are turned off.
+ New points are created with the same timestamp interval, GPS accuracy, elevation and
+ realistic‑stop behaviour that you have configured in the Settings drawer.
+
+
+
-
-
-
+
+
+
+
+
+ container.appendChild(prevBtn);
+ container.appendChild(nextBtn);
+ body.appendChild(container);
+ } finally {
+ navButtonsGuard = false;
+ }
+ }
+
+ const pointInfoBody = document.getElementById('pointInfoBody');
+ if (pointInfoBody) {
+ const bodyObserver = new MutationObserver(addNavButtons);
+ bodyObserver.observe(pointInfoBody, { childList: true });
+ }
+ const pointInfo = document.getElementById('pointInfo');
+ if (pointInfo) {
+ let visibilityGuard = false;
+ const visibilityObserver = new MutationObserver(function() {
+ if (visibilityGuard) return;
+ try {
+ visibilityGuard = true;
+ if (pointInfo.style.display !== 'none') addNavButtons();
+ } finally {
+ visibilityGuard = false;
+ }
+ });
+ visibilityObserver.observe(pointInfo, { attributes: true, attributeFilter: ['style'] });
+ }
+ })();
+
+
+
+
diff --git a/docs/tools/gpx-generator/js/datetime-picker.js b/docs/tools/gpx-generator/js/datetime-picker.js
new file mode 100644
index 000000000..e90192cf8
--- /dev/null
+++ b/docs/tools/gpx-generator/js/datetime-picker.js
@@ -0,0 +1,703 @@
+/**
+ * DateTimePicker - A custom datetime picker component
+ * Provides calendar, year selection, and (optionally) time selection.
+ *
+ * Modes:
+ * - Full mode: container has both .date-input and .time-input
+ * - Date-only: container has only .date-input
+ * (time is always 00:00:00 in selectedDate and getValue)
+ */
+class DateTimePicker {
+ /**
+ * Creates a new DateTimePicker instance
+ * @param {HTMLElement} element - The container element
+ * @param {Object} options - Configuration options
+ * @param {string} options.timeFormat - Time format ('12h' or '24h')
+ * @param {Date} options.minDate - Minimum selectable date
+ * @param {Date} options.maxDate - Maximum selectable date
+ * @param {Function} options.onValidate - Validation callback function
+ * @param {string} options.locale - Locale for date formatting
+ */
+ constructor(element, options = {}) {
+ this.options = {
+ timeFormat: options.timeFormat || '24h',
+ minDate: options.minDate || null,
+ maxDate: options.maxDate || null,
+ onValidate: options.onValidate || null,
+ locale: options.locale || navigator.language,
+ popupPlacement: options.popupPlacement || 'auto' // 'top' | 'bottom' | 'auto'
+ };
+
+ this.element = element;
+ this.dateInput = element.querySelector('.date-input');
+ this.timeInput = element.querySelector('.time-input');
+ this.triggerButton = element.querySelector('.picker-trigger');
+
+ // Date-only mode when no time input exists
+ this.dateOnly = !this.timeInput;
+
+ // Create popup structure if it doesn't exist
+ if (!element.querySelector('.picker-popup')) {
+ this.createPopupStructure();
+ }
+
+ this.popup = element.querySelector('.picker-popup');
+ this.calendarSection = element.querySelector('.calendar-section');
+ this.yearScroll = element.querySelector('.year-scroll');
+ this.timeScroll = element.querySelector('.time-scroll');
+ this._listeners = { change: [] };
+ this.currentDate = new Date();
+ this.selectedDate = null;
+
+ this.init();
+ }
+
+ /**
+ * Create the popup structure dynamically
+ */
+ createPopupStructure() {
+ const popup = document.createElement('div');
+ popup.className = 'picker-popup';
+ popup.style.display = 'none';
+
+ const pickerContainer = document.createElement('div');
+ pickerContainer.className = 'picker-container';
+ if (this.dateOnly) pickerContainer.classList.add('date-only');
+
+ // Calendar section
+ const calendarSection = document.createElement('div');
+ calendarSection.className = 'calendar-section';
+
+ const calendarHeader = document.createElement('div');
+ calendarHeader.className = 'calendar-header';
+
+ const prevMonthBtn = document.createElement('button');
+ prevMonthBtn.type = 'button';
+ prevMonthBtn.className = 'prev-month';
+ prevMonthBtn.innerHTML = '<';
+
+ const monthYear = document.createElement('div');
+ monthYear.className = 'month-year';
+
+ const nextMonthBtn = document.createElement('button');
+ nextMonthBtn.type = 'button';
+ nextMonthBtn.className = 'next-month';
+ nextMonthBtn.innerHTML = '>';
+
+ calendarHeader.appendChild(prevMonthBtn);
+ calendarHeader.appendChild(monthYear);
+ calendarHeader.appendChild(nextMonthBtn);
+
+ const weekdays = document.createElement('div');
+ weekdays.className = 'weekdays';
+
+ const daysGrid = document.createElement('div');
+ daysGrid.className = 'days-grid';
+
+ // Today button
+ const todayButton = document.createElement('button');
+ todayButton.type = 'button';
+ todayButton.className = 'today-button';
+ todayButton.textContent = 'Today';
+
+ calendarSection.appendChild(calendarHeader);
+ calendarSection.appendChild(weekdays);
+ calendarSection.appendChild(daysGrid);
+ calendarSection.appendChild(todayButton);
+
+ // Year scroll section
+ const yearScroll = document.createElement('div');
+ yearScroll.className = 'year-scroll';
+
+ const yearList = document.createElement('div');
+ yearList.className = 'year-list';
+
+ yearScroll.appendChild(yearList);
+
+ pickerContainer.appendChild(calendarSection);
+ pickerContainer.appendChild(yearScroll);
+
+ // Time scroll section — only in full mode
+ if (!this.dateOnly) {
+ const timeScroll = document.createElement('div');
+ timeScroll.className = 'time-scroll';
+
+ const timeList = document.createElement('div');
+ timeList.className = 'time-list';
+
+ timeScroll.appendChild(timeList);
+ pickerContainer.appendChild(timeScroll);
+ }
+
+ popup.appendChild(pickerContainer);
+ this.element.appendChild(popup);
+ }
+
+ /**
+ * Initialize the datetime picker
+ */
+ init() {
+ this.setupEventListeners();
+ this.renderCalendar();
+ this.renderYearList();
+ if (!this.dateOnly) this.renderTimeList();
+ this.updateFromInputs();
+
+ // Preselect current date and closest time if no date is selected
+ if (!this.selectedDate) {
+ this.selectToday();
+ }
+ }
+
+ /**
+ * Set up event listeners for the picker
+ */
+ setupEventListeners() {
+ // Trigger button click handler
+ this.triggerButton.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.togglePopup();
+ });
+
+ // Input change events
+ this.dateInput.addEventListener('change', () => {
+ this.updateFromInputs();
+ if (this.options.onValidate) {
+ this.options.onValidate();
+ }
+ });
+
+ if (this.timeInput) {
+ this.timeInput.addEventListener('change', () => {
+ this.updateFromInputs();
+ if (this.options.onValidate) {
+ this.options.onValidate();
+ }
+ });
+ }
+
+ // Calendar navigation
+ const prevMonthBtn = this.element.querySelector('.prev-month');
+ const nextMonthBtn = this.element.querySelector('.next-month');
+ const todayBtn = this.element.querySelector('.today-button');
+
+ if (prevMonthBtn) {
+ prevMonthBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.currentDate.setMonth(this.currentDate.getMonth() - 1);
+ this.renderCalendar();
+ });
+ }
+
+ if (nextMonthBtn) {
+ nextMonthBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.currentDate.setMonth(this.currentDate.getMonth() + 1);
+ this.renderCalendar();
+ });
+ }
+
+ if (todayBtn) {
+ todayBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.selectToday();
+ });
+ }
+
+ // Close popup when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!this.element.contains(e.target)) {
+ this.closePopup();
+ }
+ });
+
+ // Prevent popup from closing when clicking inside it
+ this.popup.addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
+ }
+
+ /**
+ * Select today's date (and, in full mode, closest 15-min time).
+ * In date-only mode, time is pinned to 00:00:00.
+ */
+ selectToday() {
+ const now = new Date();
+ this.currentDate = new Date(now);
+ this.selectedDate = new Date(now);
+
+ if (this.dateOnly) {
+ this.selectedDate.setHours(0, 0, 0, 0);
+ } else {
+ // Round to nearest 15 minutes
+ const minutes = Math.round(now.getMinutes() / 15) * 15;
+ this.selectedDate.setMinutes(minutes, 0, 0);
+ }
+
+ this.updateInputs();
+ this.renderCalendar();
+ this.renderYearList();
+ if (!this.dateOnly) this.highlightSelectedTime();
+ }
+
+ /**
+ * Render the calendar grid
+ */
+ renderCalendar() {
+ const monthYear = this.element.querySelector('.month-year');
+ const weekdays = this.element.querySelector('.weekdays');
+ const daysGrid = this.element.querySelector('.days-grid');
+
+ // Update month/year header
+ monthYear.textContent = this.currentDate.toLocaleDateString(this.options.locale, {
+ month: 'long',
+ year: 'numeric'
+ });
+
+ // Render weekdays
+ weekdays.innerHTML = '';
+ const weekdayNames = [];
+ for (let i = 0; i < 7; i++) {
+ const date = new Date(2023, 0, i + 1); // Start from Sunday
+ weekdayNames.push(date.toLocaleDateString(this.options.locale, { weekday: 'short' }));
+ }
+ weekdayNames.forEach(day => {
+ const dayElement = document.createElement('div');
+ dayElement.className = 'weekday';
+ dayElement.textContent = day;
+ weekdays.appendChild(dayElement);
+ });
+
+ // Render days grid
+ daysGrid.innerHTML = '';
+ const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
+ const startDate = new Date(firstDay);
+ startDate.setDate(startDate.getDate() - firstDay.getDay());
+
+ for (let i = 0; i < 42; i++) {
+ const date = new Date(startDate);
+ date.setDate(startDate.getDate() + i);
+
+ const dayElement = document.createElement('button');
+ dayElement.type = 'button';
+ dayElement.className = 'day';
+ dayElement.textContent = date.getDate();
+
+ if (date.getMonth() !== this.currentDate.getMonth()) {
+ dayElement.classList.add('other-month');
+ }
+
+ if (this.selectedDate && this.isSameDay(date, this.selectedDate)) {
+ dayElement.classList.add('selected');
+ }
+
+ if (this.isDateDisabled(date)) {
+ dayElement.disabled = true;
+ dayElement.classList.add('disabled');
+ }
+
+ dayElement.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (!this.isDateDisabled(date)) {
+ this.selectDate(date);
+ }
+ });
+
+ daysGrid.appendChild(dayElement);
+ }
+ }
+
+ /**
+ * Render the year selection list
+ */
+ renderYearList() {
+ const yearList = this.element.querySelector('.year-list');
+ yearList.innerHTML = '';
+
+ const currentYear = new Date().getFullYear();
+ const startYear = currentYear - 50;
+ const endYear = currentYear + 50;
+
+ for (let year = startYear; year <= endYear; year++) {
+ const yearElement = document.createElement('button');
+ yearElement.type = 'button';
+ yearElement.className = 'year-item';
+ yearElement.textContent = year;
+
+ if (year === this.currentDate.getFullYear()) {
+ yearElement.classList.add('selected');
+ }
+
+ yearElement.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.changeYear(year);
+ });
+
+ yearList.appendChild(yearElement);
+ }
+ }
+
+ /**
+ * Change the current year and maintain the selected day if possible
+ * @param {number} year - The year to change to
+ */
+ changeYear(year) {
+ const currentDay = this.selectedDate ? this.selectedDate.getDate() : 1;
+ const currentMonth = this.selectedDate ? this.selectedDate.getMonth() : this.currentDate.getMonth();
+
+ this.currentDate.setFullYear(year);
+
+ let newDate = new Date(year, currentMonth, currentDay);
+
+ if (newDate.getMonth() !== currentMonth || this.isDateDisabled(newDate)) {
+ const lastDayOfMonth = new Date(year, currentMonth + 1, 0).getDate();
+ newDate = new Date(year, currentMonth, Math.min(currentDay, lastDayOfMonth));
+
+ if (this.isDateDisabled(newDate)) {
+ newDate = this.findNextValidDate(newDate);
+ }
+ }
+
+ if (this.selectedDate) {
+ this.selectedDate.setFullYear(year, currentMonth, newDate.getDate());
+ if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0);
+ this.updateInputs();
+ }
+
+ this.renderCalendar();
+ this.renderYearList();
+ }
+
+ /**
+ * Find the next valid date starting from a given date
+ */
+ findNextValidDate(startDate) {
+ let currentDate = new Date(startDate);
+ const maxAttempts = 31;
+
+ for (let i = 0; i < maxAttempts; i++) {
+ currentDate.setDate(currentDate.getDate() + 1);
+ if (!this.isDateDisabled(currentDate)) {
+ return currentDate;
+ }
+ }
+
+ return new Date(startDate.getFullYear(), startDate.getMonth(), 1);
+ }
+
+ /**
+ * Render the time selection list (full mode only)
+ */
+ renderTimeList() {
+ if (this.dateOnly) return;
+ const timeList = this.element.querySelector('.time-list');
+ if (!timeList) return;
+ timeList.innerHTML = '';
+
+ for (let hour = 0; hour < 24; hour++) {
+ for (let minute = 0; minute < 60; minute += 15) {
+ const timeElement = document.createElement('button');
+ timeElement.type = 'button';
+ timeElement.className = 'time-item';
+
+ const timeString = this.formatTime(hour, minute);
+ timeElement.textContent = timeString;
+ timeElement.dataset.hour = hour;
+ timeElement.dataset.minute = minute;
+
+ timeElement.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.selectTime(hour, minute);
+ });
+
+ timeList.appendChild(timeElement);
+ }
+ }
+ }
+
+ /**
+ * Format time according to the specified format
+ */
+ formatTime(hour, minute) {
+ if (this.options.timeFormat === '12h') {
+ const period = hour >= 12 ? 'PM' : 'AM';
+ const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ return `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`;
+ } else {
+ return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
+ }
+ }
+ /**
+ * Update the picker's displayed date without firing the 'change' event.
+ * Useful for transient sync (e.g., hover tracking) that shouldn't be
+ * treated as a user selection.
+ * @param {Date} date
+ */
+ setDateSilent(date) {
+ if (!(date instanceof Date) || isNaN(date.getTime())) return;
+
+ this.selectedDate = new Date(date);
+ if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0);
+ this.currentDate = new Date(this.selectedDate);
+
+ // Update inputs without triggering change on them either
+ const y = this.selectedDate.getFullYear();
+ const m = (this.selectedDate.getMonth() + 1).toString().padStart(2, '0');
+ const d = this.selectedDate.getDate().toString().padStart(2, '0');
+ this.dateInput.value = `${y}-${m}-${d}`;
+
+ if (this.timeInput) {
+ const hh = this.selectedDate.getHours().toString().padStart(2, '0');
+ const mm = this.selectedDate.getMinutes().toString().padStart(2, '0');
+ this.timeInput.value = `${hh}:${mm}`;
+ }
+
+ this.renderCalendar();
+ this.renderYearList();
+ if (!this.dateOnly) this.highlightSelectedTime();
+ }
+ /**
+ * Select a specific date. In date-only mode the time is pinned to 00:00:00.
+ */
+ selectDate(date) {
+ if (!this.selectedDate) {
+ this.selectedDate = new Date();
+ }
+ this.selectedDate.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
+ if (this.dateOnly) this.selectedDate.setHours(0, 0, 0, 0);
+ this.updateInputs();
+ this.renderCalendar();
+ }
+
+ /**
+ * Select a specific time (no-op in date-only mode)
+ */
+ selectTime(hour, minute) {
+ if (this.dateOnly) return;
+ if (!this.selectedDate) {
+ this.selectedDate = new Date();
+ }
+ this.selectedDate.setHours(hour, minute, 0, 0);
+ this.updateInputs();
+ this.highlightSelectedTime();
+ }
+
+ /**
+ * Highlight the currently selected time in the time list (full mode only)
+ */
+ highlightSelectedTime() {
+ if (this.dateOnly) return;
+ this.element.querySelectorAll('.time-item').forEach(item => {
+ item.classList.remove('selected');
+ if (this.selectedDate &&
+ parseInt(item.dataset.hour) === this.selectedDate.getHours() &&
+ parseInt(item.dataset.minute) === this.selectedDate.getMinutes()) {
+ item.classList.add('selected');
+ }
+ });
+ }
+
+ updateInputs() {
+ if (!this.selectedDate) return;
+
+ const year = this.selectedDate.getFullYear();
+ const month = (this.selectedDate.getMonth() + 1).toString().padStart(2, '0');
+ const day = this.selectedDate.getDate().toString().padStart(2, '0');
+ this.dateInput.value = `${year}-${month}-${day}`;
+ this.dateInput.dispatchEvent(new Event('change'));
+
+ if (this.timeInput) {
+ const hours = this.selectedDate.getHours().toString().padStart(2, '0');
+ const minutes = this.selectedDate.getMinutes().toString().padStart(2, '0');
+ this.timeInput.value = `${hours}:${minutes}`;
+ this.timeInput.dispatchEvent(new Event('change'));
+ }
+
+ this._emit('change');
+ }
+
+ /**
+ * Update the picker state from the input values
+ */
+ updateFromInputs() {
+ if (!this.dateInput.value) return;
+
+ if (this.dateOnly) {
+ // Parse YYYY-MM-DD as local midnight
+ const [y, m, d] = this.dateInput.value.split('-').map(Number);
+ this.selectedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
+ } else if (this.timeInput && this.timeInput.value) {
+ const dateTimeString = `${this.dateInput.value}T${this.timeInput.value}`;
+ this.selectedDate = new Date(dateTimeString);
+ } else {
+ return;
+ }
+
+ this.currentDate = new Date(this.selectedDate);
+ this.renderCalendar();
+ this.renderYearList();
+ if (!this.dateOnly) this.highlightSelectedTime();
+
+ this._emit('change');
+ }
+
+ /**
+ * Toggle the popup visibility
+ */
+ togglePopup() {
+ const isVisible = this.popup.style.display !== 'none';
+ if (isVisible) {
+ this.closePopup();
+ } else {
+ this.openPopup();
+ }
+ }
+
+ openPopup() {
+ this.popup.style.display = 'block';
+ this.applyPopupPlacement();
+ if (this.selectedDate) {
+ this.scrollToSelectedYear();
+ if (!this.dateOnly) this.scrollToSelectedTime();
+ }
+ }
+
+ /**
+ * Decide whether the popup should render above or below the trigger.
+ * - 'top' / 'bottom': explicit, always used
+ * - 'auto': flip to top only if there isn't enough room below
+ */
+ applyPopupPlacement() {
+ const mode = this.options.popupPlacement;
+ let placeOnTop;
+
+ if (mode === 'top') placeOnTop = true;
+ else if (mode === 'bottom') placeOnTop = false;
+ else {
+ // auto — measure available space
+ const triggerRect = this.triggerButton.getBoundingClientRect();
+ const popupHeight = this.popup.offsetHeight;
+ const viewportH = window.innerHeight;
+ const spaceBelow = viewportH - triggerRect.bottom;
+ const spaceAbove = triggerRect.top;
+ // Prefer bottom, but flip if it would clip and top has more room
+ placeOnTop = spaceBelow < popupHeight && spaceAbove > spaceBelow;
+ }
+
+ this.popup.classList.toggle('placement-top', placeOnTop);
+ this.popup.classList.toggle('placement-bottom', !placeOnTop);
+ }
+ /**
+ * Close the popup
+ */
+ closePopup() {
+ this.popup.style.display = 'none';
+ }
+
+ /**
+ * Scroll to the selected year in the year list
+ */
+ scrollToSelectedYear() {
+ const selectedYear = this.element.querySelector('.year-item.selected');
+ if (selectedYear) {
+ selectedYear.scrollIntoView({ block: 'center' });
+ }
+ }
+
+ /**
+ * Scroll to the selected time in the time list (full mode only)
+ */
+ scrollToSelectedTime() {
+ if (this.dateOnly) return;
+ const selectedTime = this.element.querySelector('.time-item.selected');
+ if (selectedTime) {
+ selectedTime.scrollIntoView({ block: 'center' });
+ }
+ }
+
+ /**
+ * Check if two dates are the same day
+ */
+ isSameDay(date1, date2) {
+ return date1.getFullYear() === date2.getFullYear() &&
+ date1.getMonth() === date2.getMonth() &&
+ date1.getDate() === date2.getDate();
+ }
+
+ /**
+ * Check if a date is disabled
+ */
+ isDateDisabled(date) {
+ if (this.options.minDate && date < this.options.minDate) {
+ return true;
+ }
+ return this.options.maxDate && date > this.options.maxDate;
+
+ }
+
+ /**
+ * Get the current value as ISO string.
+ * Date-only mode returns 'YYYY-MM-DDT00:00:00'.
+ */
+ getValue() {
+ if (!this.dateInput.value) return '';
+ if (this.dateOnly) {
+ return `${this.dateInput.value}T00:00:00`;
+ }
+ if (this.timeInput && this.timeInput.value) {
+ return `${this.dateInput.value}T${this.timeInput.value}`;
+ }
+ return '';
+ }
+
+ /**
+ * Set the value from ISO string. In date-only mode the time portion
+ * is ignored and the date is stored with time pinned to 00:00:00.
+ */
+ setValue(value) {
+ if (!value) return;
+ const [datePart, timePart] = value.split('T');
+ this.dateInput.value = datePart;
+ if (!this.dateOnly && this.timeInput) {
+ this.timeInput.value = timePart ? timePart.substring(0, 5) : '';
+ }
+ this.updateFromInputs();
+ }
+
+ /**
+ * Subscribe to a picker event.
+ * @param {string} eventName - Currently supported: 'change'
+ * @param {Function} handler - Called with (value, selectedDate, picker)
+ * @returns {Function} Unsubscribe function
+ */
+ on(eventName, handler) {
+ if (!this._listeners[eventName]) this._listeners[eventName] = [];
+ this._listeners[eventName].push(handler);
+ return () => this.off(eventName, handler);
+ }
+
+ /**
+ * Unsubscribe a previously registered handler.
+ */
+ off(eventName, handler) {
+ const arr = this._listeners[eventName];
+ if (!arr) return;
+ const i = arr.indexOf(handler);
+ if (i >= 0) arr.splice(i, 1);
+ }
+
+ /**
+ * Internal: invoke all handlers for an event.
+ */
+ _emit(eventName) {
+ const arr = this._listeners[eventName];
+ if (!arr || !arr.length) return;
+ const value = this.getValue();
+ const selectedDate = this.selectedDate ? new Date(this.selectedDate) : null;
+ for (const h of arr) {
+ try { h(value, selectedDate, this); }
+ catch (err) { console.error(`[DateTimePicker] ${eventName} handler threw:`, err); }
+ }
+ }
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index df2b9f8a7..dc8552396 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.5.10
+ 3.5.14
com.dedicatedcode
@@ -65,6 +65,11 @@
org.springframework.boot
spring-boot-starter-data-redis
+
+ com.github.kagkarlsson
+ db-scheduler-spring-boot-starter
+ 16.7.0
+
org.springframework.boot
spring-boot-starter-cache
@@ -152,7 +157,12 @@
spring-security-test
test
-
+
+ com.github.docker-java
+ docker-java-api
+ 3.7.0
+ compile
+
diff --git a/scripts/dead-message-keys.sh b/scripts/dead-message-keys.sh
new file mode 100755
index 000000000..b92419e1c
--- /dev/null
+++ b/scripts/dead-message-keys.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+# Define paths
+PROPERTIES_FILE="src/main/resources/messages.properties"
+SOURCE_DIR="src/main"
+
+echo "Scanning for unused keys..."
+echo "------------------------------------------------"
+
+# Extract all keys, ignoring comments and empty lines
+keys=$(grep -v '^\s*[#!]' "$PROPERTIES_FILE" | grep -v '^\s*$' | cut -d'=' -f1 | sed 's/^[ \t]*//;s/[ \t]*$//')
+
+for key in $keys; do
+ # Check if the key is a frontend JS key
+ if [[ "$key" == js.* ]]; then
+ # Strip the "js." prefix
+ search_term="${key#js.}"
+
+ # Search for t('key') or t("key") using Extended Regex (-E)
+ # We escape the parentheses and quotes for the regex engine
+ if ! grep -rqE "\bt\(['\"]${search_term}['\"]" "$SOURCE_DIR"; then
+ echo "[Frontend] Unused JS key : $key"
+ fi
+ else
+ # It's a backend/Thymeleaf key.
+ # Search for the literal key (e.g., inside #{my.key} or Java code)
+ if ! grep -rq "$key" "$SOURCE_DIR"; then
+ echo "[Backend] Unused key : $key"
+ fi
+ fi
+done
+
+echo "------------------------------------------------"
+echo "Scan complete. Please manually verify before deleting!"
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/config/RedisQueueConfiguration.java b/src/main/java/com/dedicatedcode/reitti/config/RedisQueueConfiguration.java
deleted file mode 100644
index ae5ed3c9c..000000000
--- a/src/main/java/com/dedicatedcode/reitti/config/RedisQueueConfiguration.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.dedicatedcode.reitti.config;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
-import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.data.redis.connection.RedisConnectionFactory;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
-import org.springframework.data.redis.serializer.RedisSerializer;
-import org.springframework.data.redis.serializer.StringRedisSerializer;
-
-@Configuration
-public class RedisQueueConfiguration {
-
- @Bean
- public RedisSerializer redisValueSerializer() {
- ObjectMapper objectMapper = new ObjectMapper();
- objectMapper.registerModule(new JavaTimeModule());
- objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
- objectMapper.activateDefaultTyping(
- BasicPolymorphicTypeValidator.builder()
- .allowIfBaseType(Object.class)
- .build(),
- ObjectMapper.DefaultTyping.NON_FINAL
- );
- return new GenericJackson2JsonRedisSerializer(objectMapper);
- }
-
- @Bean
- public RedisTemplate redisTemplate(
- RedisConnectionFactory connectionFactory,
- RedisSerializer redisValueSerializer
- ) {
- RedisTemplate template = new RedisTemplate<>();
- template.setConnectionFactory(connectionFactory);
-
- StringRedisSerializer keySerializer = new StringRedisSerializer();
-
- template.setKeySerializer(keySerializer);
- template.setHashKeySerializer(keySerializer);
- template.setValueSerializer(redisValueSerializer);
- template.setHashValueSerializer(redisValueSerializer);
-
- template.afterPropertiesSet();
- return template;
- }
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java
index 72427a3c0..be06230fc 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java
@@ -1,5 +1,6 @@
package com.dedicatedcode.reitti.config;
+import com.dedicatedcode.reitti.config.security.*;
import com.dedicatedcode.reitti.model.Role;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/TaskConfig.java b/src/main/java/com/dedicatedcode/reitti/config/TaskConfig.java
new file mode 100644
index 000000000..8e3b905e2
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/config/TaskConfig.java
@@ -0,0 +1,100 @@
+package com.dedicatedcode.reitti.config;
+
+import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
+import com.dedicatedcode.reitti.event.TriggerProcessingEvent;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.service.DataCleanupService;
+import com.dedicatedcode.reitti.service.UserSseEmitterService;
+import com.dedicatedcode.reitti.service.geocoding.ReverseGeocodingListener;
+import com.dedicatedcode.reitti.service.importer.PromotionJobHandler;
+import com.dedicatedcode.reitti.service.jobs.JobSchedulingService;
+import com.dedicatedcode.reitti.service.jobs.JobType;
+import com.dedicatedcode.reitti.service.jobs.VisitSensitivityConfigurationRecalculationTask;
+import com.dedicatedcode.reitti.service.processing.*;
+import com.github.kagkarlsson.scheduler.task.Task;
+import com.github.kagkarlsson.scheduler.task.helper.Tasks;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.UUID;
+
+@Configuration
+public class TaskConfig {
+
+ @Bean
+ public Task patchDeviceOntoTimelineTask(PatchDeviceOntoTimelineJob handler) {
+ return Tasks.oneTime("patch-device-onto-timeline-task", PatchDeviceOntoTimelineJob.TaskData.class)
+ .execute((instance, context) -> {
+ PatchDeviceOntoTimelineJob.TaskData data = instance.getData();
+ handler.execute(data);
+ });
+ }
+
+ @Bean
+ public Task sseEmitterTask(UserSseEmitterService userSseEmitterService) {
+ return Tasks.oneTime("sse-emitter-task", UserSseEmitterService.TaskData.class)
+ .execute((instance, context) -> {
+ UserSseEmitterService.TaskData data = instance.getData();
+ userSseEmitterService.sendEventToUser(data.user(), data.eventData());
+ });
+ }
+
+ @Bean
+ public Task significantPlaceCreatedTask(ReverseGeocodingListener reverseGeocodingListener) {
+ return Tasks.oneTime("reverse-geocoding-task", SignificantPlaceCreatedEvent.class)
+ .execute((instance, context) -> {
+ SignificantPlaceCreatedEvent data = instance.getData();
+ reverseGeocodingListener.handleSignificantPlaceCreated(data);
+ });
+ }
+
+ @Bean
+ public Task updateCuratedTimelineTask(UpdateCuratedTimelineJob handler) {
+ return Tasks.oneTime("updating-curated-timeline-task", UpdateCuratedTimelineJob.TaskData.class)
+ .execute((instance, context) -> {
+ handler.execute(instance.getData());
+ });
+ }
+
+ @Bean
+ public Task processingPipelineTask(ProcessingPipelineTrigger handler) {
+ return Tasks.oneTime("processing-pipeline-task", TriggerProcessingEvent.class)
+ .execute((instance, context) -> {
+ handler.execute(instance.getData());
+ });
+ }
+
+ @Bean
+ public Task locationDataCleanupTask(LocationDataCleanupJob handler) {
+ return Tasks.oneTime("location-data-cleanup-task", LocationDataCleanupJob.TaskData.class)
+ .execute((instance, context) -> {
+ handler.execute(instance.getData());
+ });
+ }
+
+ @Bean
+ public Task promotionTask(PromotionJobHandler handler) {
+ return Tasks.oneTime("promotion-task", PromotionJobHandler.PromotionTaskData.class)
+ .execute((instance, context) -> {
+ handler.execute(instance.getData());
+ });
+ }
+
+ @Bean
+ public Task polygonUpdateTask(DataCleanupService handler) {
+ return Tasks.oneTime("polygon-update-task", DataCleanupService.TaskData.class)
+ .execute((instance, context) -> {
+ DataCleanupService.TaskData data = instance.getData();
+ handler.execute(data);
+ });
+ }
+
+ @Bean
+ public Task dataRecalculationTask(VisitSensitivityConfigurationRecalculationTask handler) {
+ return Tasks.oneTime("data-recalculation-task", VisitSensitivityConfigurationRecalculationTask.TaskData.class)
+ .execute((instance, context) -> {
+ VisitSensitivityConfigurationRecalculationTask.TaskData data = instance.getData();
+ handler.execute(data);
+ });
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/config/BaseTokenAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/BaseTokenAuthenticationFilter.java
similarity index 94%
rename from src/main/java/com/dedicatedcode/reitti/config/BaseTokenAuthenticationFilter.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/BaseTokenAuthenticationFilter.java
index 7ff65a17a..0a0e0e24e 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/BaseTokenAuthenticationFilter.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/BaseTokenAuthenticationFilter.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.service.ApiTokenService;
import jakarta.servlet.http.HttpServletRequest;
@@ -12,7 +12,6 @@ protected BaseTokenAuthenticationFilter(ApiTokenService apiTokenService) {
}
protected void trackApiTokenUsage(HttpServletRequest request, String token) {
- // Extract the path and remote IP of the request, supporting reverse proxy
String requestPath = request.getRequestURI();
String remoteIp = getClientIpAddress(request);
this.apiTokenService.trackUsage(token, requestPath, remoteIp);
diff --git a/src/main/java/com/dedicatedcode/reitti/config/CustomAuthenticationSuccessHandler.java b/src/main/java/com/dedicatedcode/reitti/config/security/CustomAuthenticationSuccessHandler.java
similarity index 97%
rename from src/main/java/com/dedicatedcode/reitti/config/CustomAuthenticationSuccessHandler.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/CustomAuthenticationSuccessHandler.java
index 76fd290a9..261b95dfc 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/CustomAuthenticationSuccessHandler.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/CustomAuthenticationSuccessHandler.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.model.security.UserSettings;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java b/src/main/java/com/dedicatedcode/reitti/config/security/CustomOidcUserService.java
similarity index 99%
rename from src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/CustomOidcUserService.java
index a6801dd42..d492b21f5 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/CustomOidcUserService.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.model.security.ExternalUser;
import com.dedicatedcode.reitti.model.security.User;
@@ -17,7 +17,6 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
-import java.io.IOException;
import java.net.URI;
import java.util.Optional;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/HtmxAuthenticationEntryPoint.java b/src/main/java/com/dedicatedcode/reitti/config/security/HtmxAuthenticationEntryPoint.java
similarity index 96%
rename from src/main/java/com/dedicatedcode/reitti/config/HtmxAuthenticationEntryPoint.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/HtmxAuthenticationEntryPoint.java
index 80fa93d75..4bdad3547 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/HtmxAuthenticationEntryPoint.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/HtmxAuthenticationEntryPoint.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.service.ContextPathHolder;
import jakarta.servlet.http.HttpServletRequest;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationFilter.java
similarity index 99%
rename from src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationFilter.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationFilter.java
index 208878193..bce73cc88 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationFilter.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationFilter.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationToken.java b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationToken.java
similarity index 93%
rename from src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationToken.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationToken.java
index 7502fd860..27832e838 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkAuthenticationToken.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkAuthenticationToken.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkSessionValidationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkSessionValidationFilter.java
similarity index 97%
rename from src/main/java/com/dedicatedcode/reitti/config/MagicLinkSessionValidationFilter.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkSessionValidationFilter.java
index 69c71f39c..21ff6ef2d 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/MagicLinkSessionValidationFilter.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/MagicLinkSessionValidationFilter.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
import com.dedicatedcode.reitti.repository.MagicLinkJdbcService;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java b/src/main/java/com/dedicatedcode/reitti/config/security/OidcSecurityConfiguration.java
similarity index 98%
rename from src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/OidcSecurityConfiguration.java
index 6a4b61d1e..bd2a2e543 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/OidcSecurityConfiguration.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/TokenAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/TokenAuthenticationFilter.java
similarity index 72%
rename from src/main/java/com/dedicatedcode/reitti/config/TokenAuthenticationFilter.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/TokenAuthenticationFilter.java
index 3a341fbe3..00f2a9584 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/TokenAuthenticationFilter.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/TokenAuthenticationFilter.java
@@ -1,6 +1,8 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.model.Role;
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.security.ApiToken;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.ApiTokenService;
import jakarta.servlet.FilterChain;
@@ -33,16 +35,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
}
if(authHeader != null) {
- Optional user = apiTokenService.getUserByToken(authHeader);
+ Optional tokenOpt = apiTokenService.getToken(authHeader);
- if (user.isPresent()) {
- User authenticatedUser = user.get().withRole(Role.API_ACCESS);
- UsernamePasswordAuthenticationToken authenticationToken =
- new UsernamePasswordAuthenticationToken(
- authenticatedUser,
- null,
- authenticatedUser.getAuthorities()
- );
+ if (tokenOpt.isPresent()) {
+ ApiToken token = tokenOpt.get();
+ User authenticatedUser = token.getUser().withRole(Role.API_ACCESS);
+ Device authenticatedDevice = token.getDevice();
+
+ UserDeviceAuthenticationToken authenticationToken = new UserDeviceAuthenticationToken(
+ authenticatedUser,
+ authenticatedDevice
+ );
trackApiTokenUsage(request, authHeader);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
diff --git a/src/main/java/com/dedicatedcode/reitti/config/UrlTokenAuthenticationFilter.java b/src/main/java/com/dedicatedcode/reitti/config/security/UrlTokenAuthenticationFilter.java
similarity index 97%
rename from src/main/java/com/dedicatedcode/reitti/config/UrlTokenAuthenticationFilter.java
rename to src/main/java/com/dedicatedcode/reitti/config/security/UrlTokenAuthenticationFilter.java
index c47ed6bd9..79a1aaad7 100644
--- a/src/main/java/com/dedicatedcode/reitti/config/UrlTokenAuthenticationFilter.java
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/UrlTokenAuthenticationFilter.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.config;
+package com.dedicatedcode.reitti.config.security;
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.security.User;
diff --git a/src/main/java/com/dedicatedcode/reitti/config/security/UserDeviceAuthenticationToken.java b/src/main/java/com/dedicatedcode/reitti/config/security/UserDeviceAuthenticationToken.java
new file mode 100644
index 000000000..2c6a06eda
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/config/security/UserDeviceAuthenticationToken.java
@@ -0,0 +1,29 @@
+package com.dedicatedcode.reitti.config.security;
+
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.security.DeviceTokenUser;
+import com.dedicatedcode.reitti.model.security.User;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+
+public class UserDeviceAuthenticationToken extends AbstractAuthenticationToken {
+
+ private final User user;
+ private final Device device;
+
+ public UserDeviceAuthenticationToken(User user, Device device) {
+ super(user.getAuthorities());
+ this.user = user;
+ this.device = device;
+ setAuthenticated(true);
+ }
+
+ @Override
+ public Object getCredentials() {
+ return null;
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return new DeviceTokenUser(user, device);
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java b/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java
index e732e41e7..d787f1bd7 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/AvatarController.java
@@ -24,13 +24,22 @@ public AvatarController(AvatarService avatarService) {
@GetMapping("/{userId}")
public ResponseEntity getAvatar(@PathVariable Long userId) {
Optional avatarData = avatarService.getAvatarByUserId(userId);
-
if (avatarData.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Avatar not found");
}
+ return serveBinary(avatarData.get());
+ }
+
+ @GetMapping("/{userId}/{deviceId}")
+ public ResponseEntity getAvatar(@PathVariable Long userId, @PathVariable Long deviceId) {
+ Optional avatarData = avatarService.getAvatarDeviceId(userId, deviceId);
+ if (avatarData.isEmpty()) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Avatar not found");
+ }
+ return serveBinary(avatarData.get());
+ }
- AvatarService.AvatarData avatar = avatarData.get();
-
+ private static ResponseEntity serveBinary(AvatarService.AvatarData avatar) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(avatar.mimeType()));
headers.setContentLength(avatar.imageData().length);
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/MapStyleControllerAdvice.java b/src/main/java/com/dedicatedcode/reitti/controller/MapStyleControllerAdvice.java
new file mode 100644
index 000000000..3fdcd7432
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/MapStyleControllerAdvice.java
@@ -0,0 +1,38 @@
+package com.dedicatedcode.reitti.controller;
+
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService;
+import com.dedicatedcode.reitti.service.MapLibreMapStylesService;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ModelAttribute;
+
+@ControllerAdvice
+public class MapStyleControllerAdvice {
+
+ private final MapLibreMapStylesService mapLibreMapStylesService;
+ private final UserMapStyleJdbcService userMapStyleJdbcService;
+ private final ObjectMapper objectMapper;
+
+ public MapStyleControllerAdvice(MapLibreMapStylesService mapLibreMapStylesService,
+ UserMapStyleJdbcService userMapStyleJdbcService,
+ ObjectMapper objectMapper) {
+ this.mapLibreMapStylesService = mapLibreMapStylesService;
+ this.userMapStyleJdbcService = userMapStyleJdbcService;
+ this.objectMapper = objectMapper;
+ }
+
+ @ModelAttribute("mapStylesJson")
+ public String getMapStylesConfiguration(@AuthenticationPrincipal User user) throws JsonProcessingException {
+ if (user == null) { return null; }
+ return this.objectMapper.writeValueAsString(this.mapLibreMapStylesService.getConfig(user));
+ }
+
+ @ModelAttribute("activeMapStyleId")
+ public Long getCurrentUserActiveMapStyleId(@AuthenticationPrincipal User user) {
+ if (user == null) { return null; }
+ return this.userMapStyleJdbcService.getActiveStyleId(user);
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/MetadataController.java b/src/main/java/com/dedicatedcode/reitti/controller/MetadataController.java
new file mode 100644
index 000000000..bb9e2c02d
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/MetadataController.java
@@ -0,0 +1,124 @@
+package com.dedicatedcode.reitti.controller;
+
+import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
+import com.dedicatedcode.reitti.model.geo.Trip;
+import com.dedicatedcode.reitti.model.metadata.MemoryMetadata;
+import com.dedicatedcode.reitti.model.metadata.Mood;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.model.security.UserSettings;
+import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
+import com.dedicatedcode.reitti.repository.TripJdbcService;
+import com.dedicatedcode.reitti.repository.UserSettingsJdbcService;
+import com.dedicatedcode.reitti.service.MetadataOverrideService;
+import com.dedicatedcode.reitti.service.TimeUtil;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Controller
+@RequestMapping("/metadata")
+public class MetadataController {
+ private final TripJdbcService tripJdbcService;
+ private final ProcessedVisitJdbcService processedVisitJdbcService;
+ private final MetadataOverrideService metadataService;
+ private final UserSettingsJdbcService userSettingsJdbcService;
+
+ public MetadataController(TripJdbcService tripJdbcService,
+ ProcessedVisitJdbcService processedVisitJdbcService,
+ MetadataOverrideService metadataService,
+ UserSettingsJdbcService userSettingsJdbcService) {
+ this.tripJdbcService = tripJdbcService;
+ this.processedVisitJdbcService = processedVisitJdbcService;
+ this.metadataService = metadataService;
+ this.userSettingsJdbcService = userSettingsJdbcService;
+ }
+
+ @GetMapping("/{type}/{id}")
+ public String getMetadata(@AuthenticationPrincipal User user,
+ @PathVariable String type,
+ @PathVariable Long id,
+ Model model,
+ @RequestParam(defaultValue = "UTC") ZoneId timezone,
+ @RequestParam(required = false) String returnUrl) {
+
+ UserSettings userSettings = this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
+ Optional visit = type.equals("visit") ? this.processedVisitJdbcService.findById(id) : Optional.empty();
+ Optional trip = type.equals("trip") ? this.tripJdbcService.findById(id) : Optional.empty();
+ Map properties = switch (type) {
+ case ("trip") -> trip.map(Trip::getMetadata).orElse(Collections.emptyMap());
+ case ("visit") -> visit.map(ProcessedVisit::getMetadata).orElse(Collections.emptyMap());
+ default -> throw new IllegalStateException("Unexpected value: " + type);
+ };
+
+ MemoryMetadata metadata = new MemoryMetadata(null, null);
+ metadata.setProperties(properties);
+ model.addAttribute("metadata", metadata);
+ model.addAttribute("availableMoods", Mood.values());
+ model.addAttribute("returnUrl", returnUrl);
+ visit.ifPresent(p -> {
+ model.addAttribute("name", p.getPlace().getName());
+ model.addAttribute("timerange", TimeUtil.formatTimeRange(p.getStartTime(), p.getEndTime(), timezone, LocalDate.now(), userSettings));
+ });
+ trip.ifPresent(t -> model.addAttribute("timerange", TimeUtil.formatTimeRange(t.getStartTime(), t.getEndTime(), timezone, LocalDate.now(), userSettings)));
+
+ return "fragments/index/metadata :: metadata";
+ }
+
+ @GetMapping(value = "/suggestions/{field}", produces = MediaType.APPLICATION_JSON_VALUE)
+ @ResponseBody
+ public List getSuggestions(@AuthenticationPrincipal User user,
+ @PathVariable String field,
+ @RequestParam String query) {
+ return this.metadataService.loadSuggestions(user, field, query);
+ }
+
+ @PostMapping
+ public String updateMetadata(@AuthenticationPrincipal User user,
+ @RequestParam String type,
+ @RequestParam Long id,
+ @RequestParam(required = false) Mood mood,
+ @RequestParam(required = false) String reason,
+ @RequestParam(required = false) String notes,
+ @RequestParam(required = false) List tags,
+ @RequestParam(required = false) String returnUrl) {
+ MemoryMetadata metadata = MemoryMetadata.empty();
+ metadata.setTags(tags);
+ metadata.setReason(reason);
+ metadata.setDescription(notes);
+ metadata.setMood(mood);
+ switch (type) {
+ case "trip" -> {
+ Optional trip = this.tripJdbcService.findById(id);
+ if (trip.isEmpty()) {
+ throw new IllegalArgumentException("Trip not found");
+ } else {
+ this.metadataService.saveTripMetadata(user, trip.get(), metadata);
+ }
+ }
+ case "visit" -> {
+ Optional visit = this.processedVisitJdbcService.findById(id);
+ if (visit.isEmpty()) {
+ throw new IllegalArgumentException("Visit not found");
+ } else {
+ this.metadataService.saveVisitMetadata(user, visit.get(), metadata);
+ }
+ }
+ default -> throw new IllegalStateException("Unexpected value: " + type);
+ }
+ if (returnUrl != null) {
+ return "redirect:" + returnUrl;
+ } else {
+ return "redirect:/";
+ }
+ }
+
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java b/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java
index 8d6b9f875..ddafc4d10 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/ReittiIntegrationController.java
@@ -22,8 +22,8 @@ public ReittiIntegrationController(ReittiIntegrationService reittiIntegrationSer
}
@GetMapping("/avatar/{integrationId}")
- public ResponseEntity getAvatar(@AuthenticationPrincipal User user, @PathVariable Long integrationId) {
- return this.reittiIntegrationService.getAvatar(user, integrationId).map(avatarData -> {
+ public ResponseEntity getAvatar(@PathVariable Long integrationId) {
+ return this.reittiIntegrationService.getAvatar(integrationId).map(avatarData -> {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(avatarData.mimeType()));
headers.setContentLength(avatarData.imageData().length);
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java
index 1c6b1d25a..79709ac5b 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/TimelineController.java
@@ -1,16 +1,12 @@
package com.dedicatedcode.reitti.controller;
-import com.dedicatedcode.reitti.dto.TimelineData;
-import com.dedicatedcode.reitti.dto.TimelineEntry;
-import com.dedicatedcode.reitti.dto.UserTimelineData;
+import com.dedicatedcode.reitti.dto.timeline.*;
+import com.dedicatedcode.reitti.model.devices.Device;
import com.dedicatedcode.reitti.model.geo.TransportMode;
import com.dedicatedcode.reitti.model.geo.Trip;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.model.security.UserSettings;
-import com.dedicatedcode.reitti.repository.TripJdbcService;
-import com.dedicatedcode.reitti.repository.UserJdbcService;
-import com.dedicatedcode.reitti.repository.UserSettingsJdbcService;
-import com.dedicatedcode.reitti.repository.UserSharingJdbcService;
+import com.dedicatedcode.reitti.repository.*;
import com.dedicatedcode.reitti.service.AvatarService;
import com.dedicatedcode.reitti.service.TimelineService;
import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService;
@@ -24,6 +20,7 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
+import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
@@ -37,102 +34,155 @@ public class TimelineController {
private final AvatarService avatarService;
private final ReittiIntegrationService reittiIntegrationService;
+ private final DeviceJdbcService deviceJdbcService;
private final UserSharingJdbcService userSharingJdbcService;
private final TimelineService timelineService;
private final UserSettingsJdbcService userSettingsJdbcService;
private final TransportModeService transportModeService;
private final TripJdbcService tripJdbcService;
+ private final TimelineOverviewStatisticsService timelineOverviewStatisticsService;
+
@Autowired
public TimelineController(UserJdbcService userJdbcService,
AvatarService avatarService,
ReittiIntegrationService reittiIntegrationService,
+ DeviceJdbcService deviceJdbcService,
UserSharingJdbcService userSharingJdbcService,
TimelineService timelineService,
UserSettingsJdbcService userSettingsJdbcService,
TransportModeService transportModeService,
- TripJdbcService tripJdbcService) {
+ TripJdbcService tripJdbcService,
+ TimelineOverviewStatisticsService timelineOverviewStatisticsService) {
this.userJdbcService = userJdbcService;
this.avatarService = avatarService;
this.reittiIntegrationService = reittiIntegrationService;
+ this.deviceJdbcService = deviceJdbcService;
this.userSharingJdbcService = userSharingJdbcService;
this.timelineService = timelineService;
this.userSettingsJdbcService = userSettingsJdbcService;
this.transportModeService = transportModeService;
this.tripJdbcService = tripJdbcService;
+ this.timelineOverviewStatisticsService = timelineOverviewStatisticsService;
}
@GetMapping("/content/range")
public String getTimelineContentRange(@RequestParam LocalDate startDate,
@RequestParam LocalDate endDate,
- @RequestParam(required = false, defaultValue = "UTC") String timezone,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Authentication principal, Model model) {
List authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
- ZoneId userTimezone = ZoneId.of(timezone);
- LocalDate now = LocalDate.now(userTimezone);
+ LocalDate now = LocalDate.now(timezone);
- // Check if any date in the range is not today
if (!startDate.isEqual(now) || !endDate.isEqual(now)) {
if (!authorities.contains("ROLE_USER") && !authorities.contains("ROLE_ADMIN") && !authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
}
+ List allUsersData = new ArrayList<>();
- // Find the user by username
User user = userJdbcService.findByUsername(principal.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
- UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
- // Convert LocalDate to start and end Instant for the date range in user's timezone
- Instant startOfRange = startDate.atStartOfDay(userTimezone).toInstant();
- Instant endOfRange = endDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
+ UserTimelineData userData = createUserTimeLineData(user, authorities, startDate, endDate, timezone, true);
+ allUsersData.add(userData);
- // Build timeline data for current user and connected users
- List allUsersData = new ArrayList<>();
+ if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) {
+ allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, startDate, endDate, timezone));
+ allUsersData.addAll(handleSharedUserDataRange(user, startDate, endDate, timezone, true));
+ }
- // Add current user data first - for range, we'll use the start date for the timeline service
- List currentUserEntries;
- if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
- currentUserEntries = this.timelineService.buildTimelineEntries(user, userTimezone, startDate, startOfRange, endOfRange);
+ TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList());
+
+ model.addAttribute("timelineData", timelineData);
+ model.addAttribute("startDate", startDate);
+ model.addAttribute("endDate", endDate);
+ model.addAttribute("timezone", timezone);
+ model.addAttribute("isRange", true);
+ model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode());
+ model.addAttribute("showUserSelection", timelineData.users().size() > 1 || timelineData.users().stream().anyMatch(data -> data.devices().size() > 1 ));
+
+ return "fragments/timeline :: timeline-content";
+ }
+
+ private UserTimelineData createUserTimeLineData(User user, List authorities, LocalDate startDate, LocalDate endDate, ZoneId timezone, boolean loadTimeline) {
+ Instant startOfRange = startDate.atStartOfDay(timezone).toInstant();
+ Instant endOfRange = endDate.plusDays(1).atStartOfDay(timezone).toInstant().minusMillis(1);
+ boolean shouldAggregate = Duration.between(startOfRange, endOfRange).toDays() > 14;
+
+ List extends TimelineEntry> currentUserEntries;
+ if (loadTimeline && (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS"))) {
+ if (shouldAggregate) {
+ currentUserEntries = this.timelineOverviewStatisticsService.load(user, startOfRange, endOfRange, timezone);
+ } else {
+ currentUserEntries = this.timelineService.buildTimelineEntries(user, timezone, startDate, startOfRange, endOfRange, authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN"));
+ }
} else {
currentUserEntries = Collections.emptyList();
}
+ UserSettings userSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
boolean loadVisits = authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS");
boolean loadPaths = authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS") || authorities.contains("ROLE_MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS") || authorities.contains("ROLE_MAGIC_LINK_ONLY_LIVE");
- String currentUserProcessedVisitsUrl = loadVisits ? String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, timezone) : null;
- String mapMetaDataUrl = String.format("/api/v2/locations/metadata/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone);
- String mapStreamDataUrl = loadPaths ? String.format("/api/v2/locations/stream/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone) : null;
+ String currentUserProcessedVisitsUrl = loadVisits ? String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", user.getId(), startDate, endDate, timezone.getId()) : null;
+ String mapMetaDataUrl = String.format("/api/v2/locations/metadata/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone.getId());
+ String mapStreamDataUrl = loadPaths ? String.format("/api/v2/locations/stream/%d?start=%s&end=%s&timezone=%s", user.getId(), startDate, endDate, timezone.getId()) : null;
String currentUserAvatarUrl = this.avatarService.getInfo(user.getId()).map(avatarInfo -> String.format("/avatars/%d?ts=%s", user.getId(), avatarInfo.updatedAt())).orElse(String.format("/avatars/%d", user.getId()));
String currentUserInitials = this.avatarService.generateInitials(user.getDisplayName());
- allUsersData.add(new UserTimelineData(user.getId() + "",
- user.getDisplayName(),
- currentUserInitials,
- currentUserAvatarUrl,
- userSettings.getColor(),
- currentUserEntries,
- null,
- currentUserProcessedVisitsUrl,
- mapMetaDataUrl,
- mapStreamDataUrl));
+
+ List enabledDevices = Collections.emptyList();
+ if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN") || authorities.contains("ROLE_MAGIC_LINK_FULL_ACCESS")) {
+ if (this.deviceJdbcService.getAllEnabled(user).stream().filter(Device::showOnMap).count() >= 2) {
+ enabledDevices = this.deviceJdbcService.getAllEnabled(user).stream()
+ .filter(Device::showOnMap)
+ .map(d -> new DeviceTimelineData(d.id(),
+ d.name(),
+ this.avatarService.getAvatarDeviceId(user.getId(), d.id()).map(data -> "/avatars/" + user.getId() + "/" + d.id() + "?ts=" + data.updatedAt()).orElse(null),
+ this.avatarService.generateInitials(d.name()),
+ d.enabled(),
+ d.color(),
+ String.format("/api/v2/locations/metadata/%d/device/%d?start=%s&end=%s&timezone=%s", user.getId(), d.id(), startDate, endDate, timezone.getId()),
+ loadPaths ? String.format("/api/v2/locations/stream/%d/device/%d?start=%s&end=%s&timezone=%s", user.getId(), d.id(),startDate, endDate, timezone.getId()) : null))
+ .toList();
+ }
+ }
+ return new UserTimelineData(user.getId() + "",
+ user.getDisplayName(),
+ currentUserInitials,
+ currentUserAvatarUrl,
+ userSettings.getColor(),
+ currentUserEntries,
+ null,
+ currentUserProcessedVisitsUrl,
+ mapMetaDataUrl,
+ mapStreamDataUrl,
+ enabledDevices);
+ }
+
+ @GetMapping("/user-selection")
+ public String loadUserSelection(@RequestParam LocalDate startDate,
+ @RequestParam LocalDate endDate,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Authentication principal,
+ Model model) {
+ List authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
+ List allUsersData = new ArrayList<>();
+ User user = userJdbcService.findByUsername(principal.getName()).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
+ UserTimelineData userData = createUserTimeLineData(user, authorities, startDate, endDate, timezone, false);
+ allUsersData.add(userData);
if (authorities.contains("ROLE_USER") || authorities.contains("ROLE_ADMIN")) {
- allUsersData.addAll(this.reittiIntegrationService.getTimelineDataRange(user, startDate, endDate, userTimezone));
- allUsersData.addAll(handleSharedUserDataRange(user, startDate, endDate, userTimezone));
+ allUsersData.addAll(this.reittiIntegrationService.getUserData(user, startDate, endDate, timezone));
+ allUsersData.addAll(handleSharedUserDataRange(user, startDate, endDate, timezone, false));
}
TimelineData timelineData = new TimelineData(allUsersData.stream().filter(Objects::nonNull).toList());
-
model.addAttribute("timelineData", timelineData);
- model.addAttribute("startDate", startDate);
- model.addAttribute("endDate", endDate);
- model.addAttribute("timezone", timezone);
- model.addAttribute("isRange", true);
- model.addAttribute("timeDisplayMode", userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).getTimeDisplayMode());
- return "fragments/timeline :: timeline-content";
+ model.addAttribute("showUserSelection", timelineData.users().size() > 1);
+ return "fragments/user-selection :: user-selection";
}
@GetMapping("/trips/edit-form/{id}")
@@ -178,15 +228,15 @@ public String getTripView(@PathVariable Long id, Model model) {
return "fragments/trip-edit :: view-mode";
}
- private List handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) {
+ private List handleSharedUserDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone, boolean loadTimeline) {
return this.userSharingJdbcService.findBySharedWithUser(user.getId()).stream()
.map(u -> {
Optional sharedWithUserOpt = this.userJdbcService.findById(u.getSharingUserId());
return sharedWithUserOpt.map(sharedWithUser -> {
Instant startOfRange = startDate.atStartOfDay(userTimezone).toInstant();
Instant endOfRange = endDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
-
- List userTimelineEntries = this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, startDate, startOfRange, endOfRange);
+
+ List userTimelineEntries = loadTimeline ? this.timelineService.buildTimelineEntries(sharedWithUser, userTimezone, startDate, startOfRange, endOfRange, false) : Collections.emptyList();
String currentUserRawLocationPointsUrl = String.format("/api/v1/raw-location-points/%d?startDate=%s&endDate=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId());
String currentUserProcessedVisitsUrl = String.format("/api/v1/visits/%d?startDate=%s&endDate=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId());
String mapMetaDataUrl = String.format("/api/v2/locations/metadata/%d?start=%s&end=%s&timezone=%s", sharedWithUser.getId(), startDate, endDate, userTimezone.getId());
@@ -203,7 +253,8 @@ private List handleSharedUserDataRange(User user, LocalDate st
currentUserRawLocationPointsUrl,
currentUserProcessedVisitsUrl,
mapMetaDataUrl,
- mapStreamDataUrl);
+ mapStreamDataUrl,
+ Collections.emptyList());
});
})
.filter(Optional::isPresent)
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java
index 4faaef52c..1d141d7eb 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/UserSettingsControllerAdvice.java
@@ -28,6 +28,7 @@ public class UserSettingsControllerAdvice {
public static final double DEFAULT_HOME_LATITUDE = 60.1699;
public static final double DEFAULT_HOME_LONGITUDE = 24.9384;
+ private static final String DEFAULT_COLOR = "#F5DEB3FF";
private final UserJdbcService userJdbcService;
private final UserSettingsJdbcService userSettingsJdbcService;
private final TilesCustomizationProvider tilesCustomizationProvider;
@@ -49,8 +50,7 @@ public UserSettingsDTO getCurrentUserSettings() {
if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) {
// Return default settings for anonymous users
- return new UserSettingsDTO(false,
- Language.EN,
+ return new UserSettingsDTO(Language.EN,
Locale.ENGLISH.toLanguageTag(),
Instant.now(),
UnitSystem.METRIC,
@@ -62,7 +62,9 @@ public UserSettingsDTO getCurrentUserSettings() {
TimeDisplayMode.DEFAULT,
TimeMode.TWENTY_FOUR_HOUR,
null,
- null);
+ null,
+ DEFAULT_COLOR
+ );
}
String username = authentication.getName();
@@ -77,8 +79,7 @@ public UserSettingsDTO getCurrentUserSettings() {
latestData = rawLocationPointJdbcService.findLatest(user).map(RawLocationPoint::getTimestamp).orElse(null);
}
Language selectedLanguage = dbSettings.getSelectedLanguage();
- return new UserSettingsDTO(dbSettings.isPreferColoredMap(),
- selectedLanguage,
+ return new UserSettingsDTO(selectedLanguage,
selectedLanguage.getLocale().toLanguageTag(),
latestData,
dbSettings.getUnitSystem(),
@@ -90,11 +91,11 @@ public UserSettingsDTO getCurrentUserSettings() {
dbSettings.getTimeDisplayMode(),
dbSettings.getTimeMode(),
dbSettings.getTimeZoneOverride(),
- dbSettings.getCustomCss() !=null ? "/user-css/" + user.getId() : null);
+ dbSettings.getCustomCss() !=null ? "/user-css/" + user.getId() : null,
+ dbSettings.getColor());
}
// Fallback for authenticated users not found in database
- return new UserSettingsDTO(false,
- Language.EN,
+ return new UserSettingsDTO(Language.EN,
Locale.ENGLISH.toLanguageTag(),
Instant.now(),
UnitSystem.METRIC,
@@ -106,9 +107,12 @@ public UserSettingsDTO getCurrentUserSettings() {
TimeDisplayMode.DEFAULT,
TimeMode.TWENTY_FOUR_HOUR,
null,
- null);
+ null,
+ DEFAULT_COLOR);
+
}
+
private UserSettingsDTO.UIMode mapUserToUiMode(Authentication authentication) {
List grantedRoles = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
if (grantedRoles.contains("ROLE_ADMIN") || grantedRoles.contains("ROLE_USER") || grantedRoles.contains("ROLE_API_ACCESS")) {
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/WorkbenchController.java b/src/main/java/com/dedicatedcode/reitti/controller/WorkbenchController.java
new file mode 100644
index 000000000..b42f67a17
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/WorkbenchController.java
@@ -0,0 +1,35 @@
+package com.dedicatedcode.reitti.controller;
+
+import com.dedicatedcode.reitti.dto.workbench.WorkbenchCommitRequest;
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/workbench")
+public class WorkbenchController {
+
+ private final DeviceJdbcService deviceJdbcService;
+ private final boolean dataManagementEnabled;
+
+ public WorkbenchController(DeviceJdbcService deviceJdbcService, @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
+ this.deviceJdbcService = deviceJdbcService;
+ this.dataManagementEnabled = dataManagementEnabled;
+ }
+
+ @GetMapping
+ public String workbench(@AuthenticationPrincipal User user, Model model) {
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user).stream().filter(Device::enabled).toList());
+ model.addAttribute("dataManagementEnabled", dataManagementEnabled);
+
+ return "workbench";
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/GeoJsonApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/GeoJsonApiController.java
new file mode 100644
index 000000000..7f38dc4f7
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/GeoJsonApiController.java
@@ -0,0 +1,127 @@
+package com.dedicatedcode.reitti.controller.api;
+
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
+import com.dedicatedcode.reitti.service.GeoJsonExportService;
+import com.dedicatedcode.reitti.service.importer.GeoJsonImporter;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/geojson")
+public class GeoJsonApiController {
+
+ private final GeoJsonExportService geoJsonExportService;
+ private final DeviceJdbcService deviceJdbcService;
+ private final GeoJsonImporter geoJsonImporter;
+
+ public GeoJsonApiController(GeoJsonExportService geoJsonExportService, DeviceJdbcService deviceJdbcService,
+ GeoJsonImporter geoJsonImporter) {
+ this.geoJsonExportService = geoJsonExportService;
+ this.deviceJdbcService = deviceJdbcService;
+ this.geoJsonImporter = geoJsonImporter;
+ }
+
+ @GetMapping(value = "/export", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity exportGeoJson(
+ @AuthenticationPrincipal User user,
+ @RequestParam("start") LocalDate start,
+ @RequestParam("end") LocalDate end,
+ @RequestParam(value = "device", required = false) Long deviceId) {
+
+ try {
+ StreamingResponseBody stream = outputStream -> {
+ try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
+ geoJsonExportService.generateGeoJsonContentStreaming(
+ user,
+ ZonedDateTime.of(start.atStartOfDay(), ZoneId.of("UTC")).toInstant(),
+ ZonedDateTime.of(end.atStartOfDay(), ZoneId.of("UTC")).toInstant(),
+ deviceId,
+ writer);
+ } catch (Exception e) {
+ throw new RuntimeException("Error generating GeoJSON file", e);
+ }
+ };
+
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(stream);
+
+ } catch (Exception e) {
+ return ResponseEntity.badRequest()
+ .body(outputStream -> {
+ try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
+ writer.write("Error generating GeoJSON file: " + e.getMessage());
+ } catch (IOException ioException) {
+ throw new RuntimeException(ioException);
+ }
+ });
+ }
+ }
+
+ @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity> importGeoJson(
+ @AuthenticationPrincipal User user,
+ @RequestParam("file") MultipartFile file,
+ @RequestParam(value = "device", required = false) Long deviceId) {
+
+ Map response = new HashMap<>();
+
+ Device device = this.deviceJdbcService.find(user, deviceId).orElse(null);
+ try {
+ if (file.isEmpty() || file.getOriginalFilename() == null) {
+ response.put("success", false);
+ response.put("error", "File is empty");
+ return ResponseEntity.badRequest().body(response);
+ }
+
+ String filename = file.getOriginalFilename();
+ if (!filename.endsWith(".geojson") && !filename.endsWith(".json")) {
+ response.put("success", false);
+ response.put("error", "Only GeoJSON files (.geojson or .json) are supported");
+ return ResponseEntity.badRequest().body(response);
+ }
+
+ try (InputStream inputStream = file.getInputStream()) {
+ Map result = geoJsonImporter.importGeoJson(
+ inputStream, user, device, filename);
+
+ if ((Boolean) result.get("success")) {
+ response.put("success", true);
+ response.put("pointsScheduled", result.get("pointsImported"));
+ response.put("message", "Successfully imported GeoJSON file with "
+ + result.get("pointsImported") + " location points");
+ } else {
+ response.put("success", false);
+ response.put("error", result.get("error"));
+ }
+ return ResponseEntity.ok(response);
+ }
+ } catch (IOException e) {
+ response.put("success", false);
+ response.put("error", "Error processing file: " + e.getMessage());
+ return ResponseEntity.status(500).body(response);
+ } catch (Exception e) {
+ response.put("success", false);
+ response.put("error", "Unexpected error: " + e.getMessage());
+ return ResponseEntity.status(500).body(response);
+ }
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java
index 64f259f72..0fe0c9f2b 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java
@@ -1,6 +1,9 @@
package com.dedicatedcode.reitti.controller.api;
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.security.DeviceTokenUser;
import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
import com.dedicatedcode.reitti.service.GpxExportService;
import com.dedicatedcode.reitti.service.importer.GpxImporter;
import org.springframework.http.MediaType;
@@ -24,27 +27,37 @@
@RestController
@RequestMapping("/api/v1/gpx")
public class GpxApiController {
-
+
+ private final DeviceJdbcService deviceJdbcService;
private final GpxExportService gpxExportService;
private final GpxImporter gpxImporter;
- public GpxApiController(GpxExportService gpxExportService, GpxImporter gpxImporter) {
+ public GpxApiController(DeviceJdbcService deviceJdbcService,
+ GpxExportService gpxExportService,
+ GpxImporter gpxImporter) {
+ this.deviceJdbcService = deviceJdbcService;
this.gpxExportService = gpxExportService;
this.gpxImporter = gpxImporter;
}
@GetMapping("/export")
- public ResponseEntity exportGpx(@AuthenticationPrincipal User user,
+ public ResponseEntity exportGpx(@AuthenticationPrincipal DeviceTokenUser user,
+ @RequestParam(required = false) Long device,
@RequestParam LocalDate start,
@RequestParam LocalDate end) {
try {
+ Device requestedDevice = device == null ? user.getDevice().orElse(null) : this.deviceJdbcService.find(user, device).orElseThrow(IllegalArgumentException::new);
+ if (requestedDevice == null) {
+ throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it.");
+ }
StreamingResponseBody stream = outputStream -> {
try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
gpxExportService.generateGpxContentStreaming(user,
- ZonedDateTime.of(start.atStartOfDay(), ZoneId.of("UTC")).toInstant(),
- ZonedDateTime.of(end.atStartOfDay(), ZoneId.of("UTC")).toInstant(),
- writer,
- false);
+ requestedDevice,
+ ZonedDateTime.of(start.atStartOfDay(), ZoneId.of("UTC")).toInstant(),
+ ZonedDateTime.of(end.atStartOfDay(), ZoneId.of("UTC")).toInstant(),
+ writer,
+ false);
} catch (Exception e) {
throw new RuntimeException("Error generating GPX file", e);
}
@@ -65,8 +78,10 @@ public ResponseEntity exportGpx(@AuthenticationPrincipal
});
}
}
+
@PostMapping("/import")
- public ResponseEntity> importGpx(@AuthenticationPrincipal User user,
+ public ResponseEntity> importGpx(@AuthenticationPrincipal DeviceTokenUser user,
+ @RequestParam(required = false) Long device,
@RequestParam("file") MultipartFile file) {
Map response = new HashMap<>();
@@ -82,9 +97,23 @@ public ResponseEntity> importGpx(@AuthenticationPrincipal Us
response.put("error", "Only GPX files are supported");
return ResponseEntity.badRequest().body(response);
}
-
+ Device requestedDevice;
+ if (device == null) {
+ requestedDevice = user.getDevice().orElse(null);
+ } else {
+ requestedDevice = this.deviceJdbcService.find(user, device).orElse(null);
+ if (requestedDevice == null) {
+ response.put("success", false);
+ response.put("error", "Requested device not found");
+ return ResponseEntity.badRequest().body(response);
+ }
+ }
+ if (requestedDevice == null) {
+ response.put("error", "Token has no device attached. Please use another token or attach a device to it.");
+ return ResponseEntity.badRequest().body(response);
+ }
try (InputStream inputStream = file.getInputStream()) {
- Map result = gpxImporter.importGpx(inputStream, user);
+ Map result = gpxImporter.importGpx(inputStream, user, requestedDevice, file.getOriginalFilename());
if ((Boolean) result.get("success")) {
response.put("success", true);
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java
index a3d39f87b..fe775a34a 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/LocationDataApiController.java
@@ -191,7 +191,7 @@ private List> loadSegmentsInBoundingBoxAndTime(User user, Do
List pointsInBoxWithNeighbors;
long start = System.nanoTime();
if (Duration.between(startOfRange, endOfRange).toDays() > 30 && minLat == null && maxLat == null && minLng == null && maxLng == null) {
- pointsInBoxWithNeighbors = rawLocationPointJdbcService.findSimplifiedRouteForPeriod(user, startOfRange, endOfRange, 10000);
+ pointsInBoxWithNeighbors = rawLocationPointJdbcService.findSimplifiedRouteForPeriod(user, startOfRange, endOfRange);
} else if (minLat == null || maxLat == null || minLng == null || maxLng == null) {
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange, endOfRange);
logger.trace("Loaded points in time range from database in [{}]ms", (System.nanoTime() - start) / 1_000_000);
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java
deleted file mode 100644
index 9b0442801..000000000
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/MapStyleController.java
+++ /dev/null
@@ -1,131 +0,0 @@
-package com.dedicatedcode.reitti.controller.api;
-
-import com.dedicatedcode.reitti.model.security.User;
-import com.dedicatedcode.reitti.repository.UserSettingsJdbcService;
-import com.dedicatedcode.reitti.service.ContextPathHolder;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ArrayNode;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-import com.fasterxml.jackson.databind.node.TextNode;
-import jakarta.servlet.http.HttpServletRequest;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.http.CacheControl;
-import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.annotation.AuthenticationPrincipal;
-import org.springframework.util.StringUtils;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.core.io.ClassPathResource;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-@RestController
-@RequestMapping("/map")
-public class MapStyleController {
-
- private final ObjectMapper objectMapper;
- private final ContextPathHolder contextPathHolder;
- private final UserSettingsJdbcService userSettingsJdbcService;
- private final boolean tileCacheEnabled;
-
- public MapStyleController(
- ObjectMapper objectMapper,
- ContextPathHolder contextPathHolder,
- UserSettingsJdbcService userSettingsJdbcService,
- @Value("${reitti.ui.tiles.cache.url:}") String cacheUrl) {
- this.objectMapper = objectMapper;
- this.contextPathHolder = contextPathHolder;
- this.userSettingsJdbcService = userSettingsJdbcService;
- this.tileCacheEnabled = StringUtils.hasText(cacheUrl);
- }
-
- @GetMapping(value = "/reitti.json", produces = MediaType.APPLICATION_JSON_VALUE)
- public ResponseEntity getStyle(@AuthenticationPrincipal User user, HttpServletRequest request) throws IOException {
- ClassPathResource resource = new ClassPathResource("static/map/reitti.json");
- ClassPathResource coloredResource = new ClassPathResource("static/map/colored.json");
-
- JsonNode style;
- if (this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId()).isPreferColoredMap()) {
- style = objectMapper.readTree(coloredResource.getInputStream());
- } else {
- style = objectMapper.readTree(resource.getInputStream());
- }
-
- if (this.tileCacheEnabled) {
- style = rewriteUrlsForProxy(style, request);
- }
-
- if (!this.contextPathHolder.getContextPath().equals("/")) {
- style = rewriteResourceUrls(style, request);
- }
-
- return ResponseEntity.ok()
- .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
- .body(style);
- }
-
- private JsonNode rewriteResourceUrls(JsonNode style, HttpServletRequest request) {
- ObjectNode mutableStyle = style.deepCopy();
- // Rewrite sources
- TextNode glyphs = (TextNode) mutableStyle.get("glyphs");
- mutableStyle.set("glyphs", new TextNode(this.contextPathHolder.getContextPath() + glyphs.asText()));
- return mutableStyle;
- }
-
- private JsonNode rewriteUrlsForProxy(JsonNode style, HttpServletRequest request) {
- ObjectNode mutableStyle = style.deepCopy();
- String baseUrl = getBaseUrl(request);
-
- // Rewrite sources
- JsonNode sources = mutableStyle.get("sources");
- if (sources != null) {
- ObjectNode mutableSources = (ObjectNode) sources;
-
- // Handle vector tiles (OpenFreeMap)
- if (mutableSources.has("openmaptiles")) {
- ObjectNode openMapTiles = (ObjectNode) mutableSources.get("openmaptiles");
- openMapTiles.remove("url");
- ArrayNode tiles = objectMapper.createArrayNode();
- tiles.add(baseUrl + "/api/v1/tiles/vector/{z}/{x}/{y}.pbf");
- openMapTiles.set("tiles", tiles);
- }
-
- // Handle raster sources
- rewriteRasterSource(mutableSources, "terrain-source", baseUrl + "/api/v1/tiles/terrain/{z}/{x}/{y}.webp");
- rewriteRasterSource(mutableSources, "satellite-source", baseUrl + "/api/v1/tiles/satellite/{z}/{x}/{y}.jpg");
- }
-
- return mutableStyle;
- }
-
- private void rewriteRasterSource(ObjectNode sources, String sourceName, String tileUrl) {
- if (sources.has(sourceName)) {
- ObjectNode source = (ObjectNode) sources.get(sourceName);
- ArrayNode tiles = objectMapper.createArrayNode();
- tiles.add(tileUrl);
- source.set("tiles", tiles);
- }
- }
-
- private String getBaseUrl(HttpServletRequest request) {
- String scheme = request.getScheme();
- String serverName = request.getServerName();
- int serverPort = request.getServerPort();
- String contextPath = request.getContextPath();
-
- StringBuilder url = new StringBuilder();
- url.append(scheme).append("://").append(serverName);
-
- if ((scheme.equals("http") && serverPort != 80) ||
- (scheme.equals("https") && serverPort != 443)) {
- url.append(":").append(serverPort);
- }
-
- url.append(contextPath);
- return url.toString();
- }
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java
index 2c91272bb..ea7cf2a74 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/PreviewApiController.java
@@ -1,6 +1,6 @@
package com.dedicatedcode.reitti.controller.api;
-import com.dedicatedcode.reitti.dto.TimelineEntry;
+import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.TimelineService;
import com.dedicatedcode.reitti.service.VisitDetectionPreviewService;
@@ -36,16 +36,16 @@ public ResponseEntity> getPreviewStatus(@PathVariable String
}
@GetMapping("/{previewId}/timeline")
- public List getPreviewTimeline(@AuthenticationPrincipal User user,
- @PathVariable String previewId,
- @RequestParam String date,
- @RequestParam(required = false, defaultValue = "UTC") String timezone) {
+ public List getPreviewTimeline(@AuthenticationPrincipal User user,
+ @PathVariable String previewId,
+ @RequestParam String date,
+ @RequestParam(required = false, defaultValue = "UTC") String timezone) {
LocalDate selectedDate = LocalDate.parse(date);
ZoneId userTimezone = ZoneId.of(timezone);
Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant();
Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant();
- return this.timelineService.buildTimelineEntries(user, previewId, userTimezone, selectedDate, startOfDay, endOfDay);
+ return this.timelineService.buildTimelineEntries(user, previewId, userTimezone, selectedDate, startOfDay, endOfDay, false);
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/QueueStatsApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/QueueStatsApiController.java
deleted file mode 100644
index 26dbe0c1a..000000000
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/QueueStatsApiController.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.dedicatedcode.reitti.controller.api;
-
-import com.dedicatedcode.reitti.service.QueueStats;
-import com.dedicatedcode.reitti.service.QueueStatsService;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-import java.util.List;
-
-@RestController
-@RequestMapping("/api/v1/queue-stats")
-public class QueueStatsApiController {
-
- private final QueueStatsService queueStatsService;
-
- public QueueStatsApiController(QueueStatsService queueStatsService) {
- this.queueStatsService = queueStatsService;
- }
-
- @GetMapping
- public ResponseEntity> getQueueStats() {
- return ResponseEntity.ok(queueStatsService.getQueueStats());
- }
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java
index a7e523f13..99070de70 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ReittiIntegrationApiController.java
@@ -3,7 +3,7 @@
import com.dedicatedcode.reitti.dto.ReittiRemoteInfo;
import com.dedicatedcode.reitti.dto.SubscriptionRequest;
import com.dedicatedcode.reitti.dto.SubscriptionResponse;
-import com.dedicatedcode.reitti.dto.TimelineEntry;
+import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry;
import com.dedicatedcode.reitti.model.NotificationData;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.UserJdbcService;
@@ -60,10 +60,10 @@ public ResponseEntity getInfo(@AuthenticationPrincipal User us
}
@GetMapping("/timeline")
- public List getTimeline(@AuthenticationPrincipal User user,
- @RequestParam String startDate,
- @RequestParam String endDate,
- @RequestParam(required = false, defaultValue = "UTC") String timezone) {
+ public List getTimeline(@AuthenticationPrincipal User user,
+ @RequestParam String startDate,
+ @RequestParam String endDate,
+ @RequestParam(required = false, defaultValue = "UTC") String timezone) {
ZoneId userTimezone = ZoneId.of(timezone);
@@ -73,7 +73,7 @@ public List getTimeline(@AuthenticationPrincipal User user,
Instant startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
Instant endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
- return this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange);
+ return this.timelineService.buildTimelineEntries(user, userTimezone, selectedStartDate, startOfRange, endOfRange, false);
}
@PostMapping("/subscribe")
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java
index 838f21886..31ab3a346 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TileProxyController.java
@@ -1,6 +1,17 @@
package com.dedicatedcode.reitti.controller.api;
-import com.dedicatedcode.reitti.config.ConditionalOnPropertyNotEmpty;
+import com.dedicatedcode.reitti.model.map.MapStyleDataSource;
+import com.dedicatedcode.reitti.model.map.UserMapStyle;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService;
+import com.dedicatedcode.reitti.service.ContextPathHolder;
+import com.dedicatedcode.reitti.service.MapLibreMapStylesService;
+import com.dedicatedcode.reitti.service.RequestHelper;
+import com.dedicatedcode.reitti.service.TileUrlUtils;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -9,43 +20,65 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.InflaterInputStream;
@RestController
@RequestMapping("/api/v1/tiles")
-@ConditionalOnPropertyNotEmpty("reitti.ui.tiles.cache.url")
public class TileProxyController {
private static final Logger log = LoggerFactory.getLogger(TileProxyController.class);
+ private static final String CUSTOM_UPSTREAM_HEADER = "X-Reitti-Upstream-Url";
private final HttpClient httpClient;
private final String tileCacheUrl;
+ private final boolean tileCacheEnabled;
+ private final String defaultTileService;
+ private final String customTileService;
+ private final ObjectMapper objectMapper;
+ private final UserMapStyleJdbcService userMapStyleJdbcService;
+ private final MapLibreMapStylesService mapLibreMapStylesService;
+ private final ContextPathHolder contextPathHolder;
- // Maps source names to internal paths and coordinate ordering
- private record SourceConfig(String path, boolean swapXY, String contentType) {}
-
- private static final Map SOURCES = Map.of(
- "raster", new SourceConfig("/osm/", false, MediaType.IMAGE_PNG_VALUE),
- "osm", new SourceConfig("/osm/", false, "application/x-protobuf"),
- "vector", new SourceConfig("/vector/", false, "application/x-protobuf"),
- "terrain", new SourceConfig("/terrain/", false, "image/webp"),
- "satellite", new SourceConfig("/satellite/", true, "image/jpeg")
- );
-
- public TileProxyController(@Value("${reitti.ui.tiles.cache.url}") String tileCacheUrl) {
+ private record TileSource(String tileJsonUrl, List tileUrlTemplates, boolean proxyTiles) {}
+
+ public TileProxyController(
+ @Value("${reitti.ui.tiles.cache.url:}") String tileCacheUrl,
+ @Value("${reitti.ui.tiles.default.service}") String defaultTileService,
+ @Value("${reitti.ui.tiles.custom.service:}") String customTileService,
+ ObjectMapper objectMapper,
+ UserMapStyleJdbcService userMapStyleJdbcService,
+ MapLibreMapStylesService mapLibreMapStylesService,
+ ContextPathHolder contextPathHolder) {
this.tileCacheUrl = tileCacheUrl;
+ this.tileCacheEnabled = StringUtils.hasText(tileCacheUrl);
+ this.defaultTileService = defaultTileService;
+ this.customTileService = customTileService;
+ this.objectMapper = objectMapper;
+ this.userMapStyleJdbcService = userMapStyleJdbcService;
+ this.mapLibreMapStylesService = mapLibreMapStylesService;
+ this.contextPathHolder = contextPathHolder;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
@@ -53,60 +86,303 @@ public TileProxyController(@Value("${reitti.ui.tiles.cache.url}") String tileCac
public ResponseEntity getTileLegacy(
@PathVariable int z,
@PathVariable int x,
- @PathVariable int y,
+ @PathVariable int y) {
+
+ String template = StringUtils.hasText(customTileService) ? customTileService : defaultTileService;
+ if (!StringUtils.hasText(template)) {
+ return ResponseEntity.notFound().build();
+ }
+ String upstreamTileUrl = template
+ .replace("{z}", String.valueOf(z))
+ .replace("{x}", String.valueOf(x))
+ .replace("{y}", String.valueOf(y));
+ URI upstreamTileUri = URI.create(upstreamTileUrl);
+ log.trace("Fetching custom tile: {}", upstreamTileUri);
+
+ if (this.tileCacheEnabled) {
+ String tileUrl = tileCacheUrl + "/custom/";
+ return fetchTile(tileUrl, MediaType.IMAGE_PNG_VALUE, "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl));
+ } else {
+ return fetchTile(upstreamTileUrl, MediaType.IMAGE_PNG_VALUE, "custom");
+ }
+ }
+
+ @GetMapping("/styles/{styleId}/style.json")
+ public ResponseEntity getStyleJson(
+ @AuthenticationPrincipal User user,
+ @PathVariable Long styleId) {
+
+ try {
+ JsonNode styleJson = mapLibreMapStylesService.getCompleteStyleJson(styleId, user);
+ if (styleJson == null) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok()
+ .cacheControl(CacheControl.noCache().cachePrivate())
+ .body(styleJson);
+ } catch (Exception e) {
+ log.warn("Failed to serve style JSON [{}]: {}", styleId, e.getMessage());
+ return ResponseEntity.notFound().build();
+ }
+ }
+
+ @GetMapping("/styles/{styleId}/{sourceId}/tilejson.json")
+ public ResponseEntity getStyleSourceTileJson(
+ @AuthenticationPrincipal User user,
+ @PathVariable Long styleId,
+ @PathVariable String sourceId,
HttpServletRequest request) {
- return getTile("raster", z, x, y, "png", request);
+
+ try {
+ boolean proxyTiles = isProxyTilesEnabled(user, styleId);
+ Optional source = resolveTileSource(user, styleId, sourceId, proxyTiles);
+ if (source.isEmpty()) {
+ return ResponseEntity.notFound().build();
+ }
+ TileSource tileSource = source.get();
+
+ // Case 1: We have a TileJSON URL – fetch it and rewrite the tiles array
+ if (tileSource.tileJsonUrl() != null && !tileSource.tileJsonUrl().isBlank()) {
+ String tileJsonUrl = tileSource.tileJsonUrl();
+ if (!tileSource.proxyTiles()) {
+ return ResponseEntity.notFound().build();
+ }
+ URI tileJsonUri = URI.create(tileJsonUrl);
+
+ HttpResponse response = fetchRaw(tileJsonUrl, Map.of());
+ if (response.statusCode() != 200) {
+ log.debug("Failed to fetch custom TileJSON [{}]: HTTP {}", tileJsonUri, response.statusCode());
+ return ResponseEntity.notFound().build();
+ }
+
+ JsonNode tileJson = objectMapper.readTree(responseBody(response));
+ if (tileJson instanceof ObjectNode mutableTileJson && mutableTileJson.get("tiles") instanceof ArrayNode tiles && !tiles.isEmpty()) {
+ ArrayNode rewrittenTiles = objectMapper.createArrayNode();
+ for (JsonNode tileNode : tiles) {
+ String tileUrl = tileNode.asText("");
+ if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) {
+ rewrittenTiles.add(styleSourceTileUrl(styleId, sourceId, tileUrl, request));
+ } else if (!tileUrl.isBlank()) {
+ String resolvedTileUrl = tileJsonUri.resolve(tileUrl).toString();
+ rewrittenTiles.add(styleSourceTileUrl(styleId, sourceId, resolvedTileUrl, request));
+ } else {
+ rewrittenTiles.add(tileUrl);
+ }
+ }
+ mutableTileJson.set("tiles", rewrittenTiles);
+ }
+
+ return ResponseEntity.ok()
+ .cacheControl(CacheControl.noCache().cachePrivate())
+ .body(tileJson);
+ }
+
+ // Case 2: We have a tile URL template directly – build a TileJSON on the fly
+ if (!tileSource.tileUrlTemplates().isEmpty()) {
+ String template = tileSource.tileUrlTemplates().getFirst();
+ ObjectNode tileJson = objectMapper.createObjectNode();
+ tileJson.put("tilejson", "2.2.0");
+ tileJson.put("name", "Custom Tiles");
+ ArrayNode tiles = objectMapper.createArrayNode();
+ tiles.add(styleSourceTileUrl(styleId, sourceId, template, request));
+ tileJson.set("tiles", tiles);
+ return ResponseEntity.ok()
+ .cacheControl(CacheControl.noCache().cachePrivate())
+ .body(tileJson);
+ }
+
+ // No valid source configuration found
+ return ResponseEntity.notFound().build();
+ } catch (Exception e) {
+ log.warn("Failed to fetch custom TileJSON [{}/{}]: {}", styleId, sourceId, e.getMessage());
+ return ResponseEntity.notFound().build();
+ }
}
- @GetMapping("/{source}/{z}/{x}/{y}.{ext}")
- public ResponseEntity getTile(
- @PathVariable String source,
+ @GetMapping("/styles/{styleId}/{sourceId}/{z}/{x}/{y}.{ext}")
+ public ResponseEntity getStyleSourceTile(
+ @AuthenticationPrincipal User user,
+ @PathVariable Long styleId,
+ @PathVariable String sourceId,
@PathVariable int z,
@PathVariable int x,
@PathVariable int y,
- @PathVariable String ext,
- HttpServletRequest request) {
+ @PathVariable String ext) {
+
+ try {
+ boolean proxyTiles = isProxyTilesEnabled(user, styleId);
+ Optional source = resolveTileSource(user, styleId, sourceId, proxyTiles);
+ if (source.isEmpty()) {
+ return ResponseEntity.notFound().build();
+ }
+ String template = tileTemplate(source.get());
+ if (!StringUtils.hasText(template)) {
+ return ResponseEntity.notFound().build();
+ }
+ if (!source.get().proxyTiles()) {
+ return ResponseEntity.notFound().build();
+ }
+ String upstreamTileUrl = template
+ .replace("{z}", String.valueOf(z))
+ .replace("{x}", String.valueOf(x))
+ .replace("{y}", String.valueOf(y))
+ .replace("{r}", "");
- SourceConfig config = SOURCES.get(source);
- if (config == null) {
+ URI upstreamTileUri = URI.create(upstreamTileUrl);
+ log.trace("Fetching custom tile [{}/{}]: {}", styleId, sourceId, upstreamTileUri);
+
+ if (this.tileCacheEnabled) {
+ String tileUrl = tileCacheUrl + "/custom/";
+ return fetchTile(tileUrl, contentTypeForExtension(ext), "custom", Map.of(CUSTOM_UPSTREAM_HEADER, upstreamTileUrl));
+ } else {
+ return fetchTile(upstreamTileUrl, contentTypeForExtension(ext), "custom");
+ }
+ } catch (IllegalArgumentException e) {
+ log.warn("Failed to resolve custom tile [{}/{}]: {}", styleId, sourceId, e.getMessage());
+ return ResponseEntity.badRequest().build();
+ } catch (Exception e) {
+ log.warn("Failed to fetch custom tile [{}/{}]: {}", styleId, sourceId, e.getMessage());
return ResponseEntity.notFound().build();
}
+ }
- try {
- String coordPath = config.swapXY()
- ? String.format("%d/%d/%d", z, y, x)
- : String.format("%d/%d/%d.%s", z, x, y, ext);
- String tileUrl = tileCacheUrl + config.path() + coordPath;
- log.trace("Fetching tile [{}]: {}", source, coordPath);
+ private boolean isProxyTilesEnabled(User user, Long styleId) {
+ try {
+ Optional style = userMapStyleJdbcService.findById(user, styleId);
+ if (style.isPresent()) {
+ MapStyleDataSource source = style.get().dataSource();
+ if (source != null) {
+ return source.proxyTiles();
+ }
+ }
+ } catch (NumberFormatException ignored) {}
+ return true;
+ }
- HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
- .uri(URI.create(tileUrl))
- .timeout(Duration.ofSeconds(30))
- .GET();
+ private ResponseEntity fetchTile(String tileUrl, String contentType, String source) {
+ return fetchTile(tileUrl, contentType, source, Map.of());
+ }
- HttpResponse response = httpClient.send(
- requestBuilder.build(),
- HttpResponse.BodyHandlers.ofByteArray()
- );
+ private ResponseEntity fetchTile(String tileUrl, String contentType, String source, Map requestHeaders) {
+ try {
+ HttpResponse response = fetchRaw(tileUrl, requestHeaders);
if (response.statusCode() == 200) {
HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.parseMediaType(config.contentType()));
+ headers.setContentType(MediaType.parseMediaType(contentType));
headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic());
headers.add("Access-Control-Allow-Origin", "*");
-
+ response.headers()
+ .firstValue(HttpHeaders.CONTENT_ENCODING)
+ .ifPresent(contentEncoding -> headers.add(HttpHeaders.CONTENT_ENCODING, contentEncoding));
+
return ResponseEntity.ok()
.headers(headers)
.body(response.body());
} else {
- log.debug("Failed to fetch tile {}/{}/{} from {}: HTTP {}", x, y, z, source, response.statusCode());
+ log.debug("Failed to fetch tile from {}: HTTP {}", source, response.statusCode());
return ResponseEntity.notFound().build();
}
-
} catch (Exception e) {
- log.warn("Failed to fetch tile {}/{}/{} from {}: {}", x, y, z, source, e.getMessage());
+ log.warn("Failed to fetch tile from {}: {}", source, e.getMessage());
return ResponseEntity.notFound().build();
}
}
+
+ private HttpResponse fetchRaw(String url, Map extraHeaders) throws Exception {
+ HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
+ .header("User-Agent", "Reitti/1.0 (+https://github.com/dedicatedcode/reitti; contact: reitti@dedicatedcode.com)")
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(30))
+ .header(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate")
+ .GET();
+ extraHeaders.forEach(requestBuilder::header);
+ return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray());
+ }
+
+ private byte[] responseBody(HttpResponse response) throws IOException {
+ String contentEncoding = response.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse("");
+ if (contentEncoding.equalsIgnoreCase("gzip")) {
+ try (GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(response.body()))) {
+ return gzipInputStream.readAllBytes();
+ }
+ }
+ if (contentEncoding.equalsIgnoreCase("deflate")) {
+ try (InflaterInputStream inflaterInputStream = new InflaterInputStream(new ByteArrayInputStream(response.body()))) {
+ return inflaterInputStream.readAllBytes();
+ }
+ }
+ return response.body();
+ }
+
+ private Optional resolveTileSource(User user, Long styleId, String sourceId, boolean proxyTiles) {
+ // First, try to get original TileJSON URL (upstream tilejson)
+ String tileJsonUrl = mapLibreMapStylesService.getOriginalTileJsonUrl(styleId, sourceId, user);
+ if (tileJsonUrl != null && !tileJsonUrl.isBlank()) {
+ return Optional.of(new TileSource(tileJsonUrl, List.of(), proxyTiles));
+ }
+ // Fallback to tile URL template
+ String originalTileUrl = mapLibreMapStylesService.getOriginalTileUrl(styleId, sourceId, user);
+ if (originalTileUrl != null) {
+ List templates = new ArrayList<>();
+ templates.add(normalizeTileTemplateForProxy(originalTileUrl));
+ return Optional.of(new TileSource(null, templates, proxyTiles));
+ }
+ throw new IllegalArgumentException("No original tile URL found for style " + styleId + " and source " + sourceId);
+ }
+
+ private String tileTemplate(TileSource source) throws Exception {
+ if (!source.tileUrlTemplates().isEmpty()) {
+ return source.tileUrlTemplates().getFirst();
+ }
+
+ if (!StringUtils.hasText(source.tileJsonUrl())) {
+ return null;
+ }
+
+ String tileJsonUrl = source.tileJsonUrl();
+ URI tileJsonUri = URI.create(tileJsonUrl);
+
+ HttpResponse response = fetchRaw(tileJsonUrl, Map.of());
+ if (response.statusCode() != 200) {
+ throw new IOException("Failed to fetch TileJSON: " + response.statusCode());
+ }
+
+ JsonNode tileJson = objectMapper.readTree(responseBody(response));
+
+ JsonNode tiles = tileJson.get("tiles");
+ if (!(tiles instanceof ArrayNode tileArray) || tileArray.isEmpty()) {
+ return null;
+ }
+
+ String tileUrl = tileArray.get(0).asText("");
+ if (tileUrl.startsWith("http://") || tileUrl.startsWith("https://")) {
+ return normalizeTileTemplateForProxy(tileUrl);
+ }
+ if (StringUtils.hasText(tileUrl)) {
+ return normalizeTileTemplateForProxy(tileJsonUri.resolve(tileUrl).toString());
+ }
+ return null;
+ }
+
+ private String styleSourceTileUrl(Long styleId, String sourceId, String tileUrl, HttpServletRequest request) {
+ String normalizedTileUrl = normalizeTileTemplateForProxy(tileUrl);
+ return RequestHelper.getBaseUrl(request) + contextPathHolder.getContextPath() + "/api/v1/tiles/styles/" + styleId + "/" + sourceId
+ + "/{z}/{x}/{y}." + TileUrlUtils.extractTileExtension(normalizedTileUrl);
+ }
+
+ private String normalizeTileTemplateForProxy(String tileUrl) {
+ return tileUrl.replace("{r}", "");
+ }
+
+ private String contentTypeForExtension(String ext) {
+ return switch (ext.toLowerCase()) {
+ case "pbf", "mvt" -> "application/x-protobuf";
+ case "png" -> MediaType.IMAGE_PNG_VALUE;
+ case "jpg", "jpeg" -> MediaType.IMAGE_JPEG_VALUE;
+ case "webp" -> "image/webp";
+ default -> MediaType.APPLICATION_OCTET_STREAM_VALUE;
+ };
+ }
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java
index c9d2021d0..1ba86bce5 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/TimelineApiController.java
@@ -1,6 +1,6 @@
package com.dedicatedcode.reitti.controller.api;
-import com.dedicatedcode.reitti.dto.TimelineEntry;
+import com.dedicatedcode.reitti.dto.timeline.SingleTimelineEntry;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.TimelineService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -25,9 +25,9 @@ public TimelineApiController(TimelineService timelineService) {
}
@GetMapping
- public List getTimeline(@AuthenticationPrincipal User user,
- @RequestParam String date,
- @RequestParam(required = false, defaultValue = "UTC") String timezone) {
+ public List getTimeline(@AuthenticationPrincipal User user,
+ @RequestParam String date,
+ @RequestParam(required = false, defaultValue = "UTC") String timezone) {
LocalDate selectedDate = LocalDate.parse(date);
ZoneId userTimezone = ZoneId.of(timezone);
@@ -35,7 +35,7 @@ public List getTimeline(@AuthenticationPrincipal User user,
Instant startOfDay = selectedDate.atStartOfDay(userTimezone).toInstant();
Instant endOfDay = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
- return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay);
+ return this.timelineService.buildTimelineEntries(user, userTimezone, selectedDate, startOfDay, endOfDay, true);
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java
index b029949dc..543a02bd9 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/gpslogger/GPSLoggerIngestionApiController.java
@@ -3,17 +3,13 @@
import com.dedicatedcode.reitti.controller.api.ingestion.owntracks.OwntracksFriendResponse;
import com.dedicatedcode.reitti.dto.LocationPoint;
import com.dedicatedcode.reitti.dto.OwntracksLocationRequest;
-import com.dedicatedcode.reitti.model.security.User;
-import com.dedicatedcode.reitti.repository.UserJdbcService;
+import com.dedicatedcode.reitti.model.security.DeviceTokenUser;
import com.dedicatedcode.reitti.service.LocationBatchingService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.security.core.userdetails.UserDetails;
-import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -27,21 +23,17 @@
@RequestMapping("/api/v1/ingest")
public class GPSLoggerIngestionApiController {
private static final Logger logger = LoggerFactory.getLogger(GPSLoggerIngestionApiController.class);
- private final UserJdbcService userJdbcService;
private final LocationBatchingService locationBatchingService;
- public GPSLoggerIngestionApiController(UserJdbcService userJdbcService,
- LocationBatchingService locationBatchingService) {
- this.userJdbcService = userJdbcService;
+ public GPSLoggerIngestionApiController(LocationBatchingService locationBatchingService) {
this.locationBatchingService = locationBatchingService;
}
@PostMapping("/gpslogger")
- public ResponseEntity> receiveData(@RequestBody OwntracksLocationRequest request) {
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- UserDetails userDetails = (UserDetails) authentication.getPrincipal();
- User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername()));
-
+ public ResponseEntity> receiveData(@AuthenticationPrincipal DeviceTokenUser user, @RequestBody OwntracksLocationRequest request) {
+ if (user.getDevice().isEmpty()) {
+ throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it.");
+ }
try {
if (!request.isLocationUpdate()) {
logger.debug("Ignoring non-location GpsLogger message of type: {}", request.getType());
@@ -58,7 +50,7 @@ public ResponseEntity> receiveData(@RequestBody OwntracksLocationRequest reque
return ResponseEntity.ok(new ArrayList());
}
- this.locationBatchingService.addLocationPoint(user, locationPoint);
+ this.locationBatchingService.addLocationPoint(user, user.getDevice().get(), locationPoint);
logger.debug("Successfully received and queued GpsLogger location point for user {}",
user.getUsername());
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java
index c02a6ce0a..090bf862b 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/overland/OverlandIngestionApiController.java
@@ -2,6 +2,7 @@
import com.dedicatedcode.reitti.dto.LocationPoint;
import com.dedicatedcode.reitti.dto.OverlandLocationRequest;
+import com.dedicatedcode.reitti.model.security.DeviceTokenUser;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.UserJdbcService;
import com.dedicatedcode.reitti.service.LocationBatchingService;
@@ -11,6 +12,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -39,11 +41,11 @@ public OverlandIngestionApiController(UserJdbcService userJdbcService,
}
@PostMapping("/overland")
- public ResponseEntity> receiveOverlandData(@RequestBody OverlandLocationRequest request) {
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- UserDetails userDetails = (UserDetails) authentication.getPrincipal();
- User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername()));
-
+ public ResponseEntity> receiveOverlandData(@AuthenticationPrincipal DeviceTokenUser user, @RequestBody OverlandLocationRequest request) {
+ if (user.getDevice().isEmpty()) {
+ throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it.");
+ }
+
try {
if (request.getLocations() == null || request.getLocations().isEmpty()) {
logger.debug("Ignoring Overland request with no locations for user {}", user.getUsername());
@@ -67,7 +69,7 @@ public ResponseEntity> receiveOverlandData(@RequestBody OverlandLocationReques
// Add each location point to the batching service
for (LocationPoint point : locationPoints) {
- this.locationBatchingService.addLocationPoint(user, point);
+ this.locationBatchingService.addLocationPoint(user, user.getDevice().get(), point);
}
logger.debug("Successfully received and queued {} Overland location points for user {}",
locationPoints.size(), user.getUsername());
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java
index dcb942450..c753934d8 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/ingestion/owntracks/OwntracksIngestionApiController.java
@@ -5,6 +5,7 @@
import com.dedicatedcode.reitti.dto.ReittiRemoteInfo;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
import com.dedicatedcode.reitti.model.integration.ReittiIntegration;
+import com.dedicatedcode.reitti.model.security.DeviceTokenUser;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.model.security.UserSharing;
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
@@ -19,10 +20,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.security.core.userdetails.UserDetails;
-import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -58,10 +56,10 @@ public OwntracksIngestionApiController(UserJdbcService userJdbcService,
}
@PostMapping("/owntracks")
- public ResponseEntity> receiveOwntracksData(@RequestBody OwntracksLocationRequest request) {
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- UserDetails userDetails = (UserDetails) authentication.getPrincipal();
- User user = this.userJdbcService.findByUsername(userDetails.getUsername()).orElseThrow(() -> new UsernameNotFoundException(userDetails.getUsername()));
+ public ResponseEntity> receiveOwntracksData(@AuthenticationPrincipal DeviceTokenUser user, @RequestBody OwntracksLocationRequest request) {
+ if (user.getDevice().isEmpty()) {
+ throw new IllegalArgumentException("Token has no device attached. Please use another token or attach a device to it.");
+ }
try {
if (!request.isLocationUpdate()) {
@@ -79,7 +77,7 @@ public ResponseEntity> receiveOwntracksData(@RequestBody OwntracksLocationRequ
return ResponseEntity.ok(new ArrayList());
}
- this.locationBatchingService.addLocationPoint(user, locationPoint);
+ this.locationBatchingService.addLocationPoint(user, user.getDevice().get(), locationPoint);
logger.debug("Successfully received and queued Owntracks location point for user {}",
user.getUsername());
@@ -136,7 +134,7 @@ private List buildFriendsData(User user) {
ReittiRemoteInfo info = reittiIntegrationService.getInfo(integration);
String tid = generateTid(info.userInfo().username());
- OwntracksFriendResponse owntracksFriendResponse = reittiIntegrationService.getAvatar(user, integration.getId())
+ OwntracksFriendResponse owntracksFriendResponse = reittiIntegrationService.getAvatar(integration.getId())
.map(avatarData -> new OwntracksFriendResponse(tid, info.userInfo().displayName(), avatarData.imageData(), avatarData.mimeType()))
.orElse(new OwntracksFriendResponse(tid, info.userInfo().displayName(), null, null));
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java
index a7b7c9405..165282256 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/LocationApiController.java
@@ -1,11 +1,11 @@
package com.dedicatedcode.reitti.controller.api.v2;
import com.dedicatedcode.reitti.dto.MapMetadata;
+import com.dedicatedcode.reitti.model.devices.Device;
import com.dedicatedcode.reitti.model.security.TokenUser;
import com.dedicatedcode.reitti.model.security.User;
-import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
-import com.dedicatedcode.reitti.repository.UserJdbcService;
-import com.dedicatedcode.reitti.repository.UserSharingJdbcService;
+import com.dedicatedcode.reitti.repository.*;
+import com.dedicatedcode.reitti.service.GeoJsonExportService;
import com.dedicatedcode.reitti.service.StreamingRawLocationPointJdbcService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -13,7 +13,12 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -27,17 +32,26 @@
public class LocationApiController {
private final UserJdbcService userJdbcService;
+ private final DeviceJdbcService deviceJdbcService;
private final UserSharingJdbcService userSharingJdbcService;
private final RawLocationPointJdbcService jdbcService;
+ private final SourceLocationPointJdbcService sourceLocationPointJdbcService;
+ private final GeoJsonExportService geoJsonExportService;
private final StreamingRawLocationPointJdbcService streamingRawLocationPointJdbcService;
public LocationApiController(UserJdbcService userJdbcService,
+ DeviceJdbcService deviceJdbcService,
UserSharingJdbcService userSharingJdbcService,
RawLocationPointJdbcService jdbcService,
+ SourceLocationPointJdbcService sourceLocationPointJdbcService,
+ GeoJsonExportService geoJsonExportService,
StreamingRawLocationPointJdbcService streamingRawLocationPointJdbcService) {
this.userJdbcService = userJdbcService;
+ this.deviceJdbcService = deviceJdbcService;
this.userSharingJdbcService = userSharingJdbcService;
this.jdbcService = jdbcService;
+ this.sourceLocationPointJdbcService = sourceLocationPointJdbcService;
+ this.geoJsonExportService = geoJsonExportService;
this.streamingRawLocationPointJdbcService = streamingRawLocationPointJdbcService;
}
@@ -53,6 +67,21 @@ public MapMetadata get(@AuthenticationPrincipal User user,
return this.jdbcService.getMetadata(userToFetchDataFrom, startInstant, endInstant);
}
+ @GetMapping("/metadata/{userId}/device/{deviceId}")
+ public MapMetadata getForDevice(@AuthenticationPrincipal User user,
+ @PathVariable Long userId,
+ @PathVariable Long deviceId,
+ @RequestParam String start,
+ @RequestParam String end,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) throws IllegalAccessException {
+ User userToFetchDataFrom = loadUserToFetchDataFrom(user, userId);
+ Device device = deviceJdbcService.find(userToFetchDataFrom, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found"));
+
+ Instant startInstant = parseInstant(start, timezone, false);
+ Instant endInstant = parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS);
+ return this.sourceLocationPointJdbcService.getMetadata(userToFetchDataFrom, device, startInstant, endInstant);
+ }
+
@GetMapping(value = "/stream/{userId}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity stream(
@AuthenticationPrincipal User user,
@@ -65,7 +94,7 @@ public ResponseEntity stream(
CompletableFuture.runAsync(() -> {
try {
- streamingRawLocationPointJdbcService.streamPoints(userToFetchDataFrom.getId(), parseInstant(start, timezone, false), parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS), emitter);
+ streamingRawLocationPointJdbcService.streamPoints(userToFetchDataFrom, parseInstant(start, timezone, false), parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS), emitter);
} catch (Exception e) {
if (e.getCause() instanceof java.io.IOException) {
try { emitter.complete(); } catch (Exception ignored) {}
@@ -80,6 +109,107 @@ public ResponseEntity stream(
.header(HttpHeaders.CONTENT_ENCODING, "identity")
.body(emitter);
}
+
+ @GetMapping(value = "/stream/{userId}/device/{deviceId}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ public ResponseEntity streamForDevice(
+ @AuthenticationPrincipal User user,
+ @PathVariable Long userId,
+ @PathVariable Long deviceId,
+ @RequestParam String start,
+ @RequestParam String end,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) throws IllegalAccessException {
+ User userToFetchDataFrom = loadUserToFetchDataFrom(user, userId);
+ Device device = deviceJdbcService.find(userToFetchDataFrom, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found"));
+
+ ResponseBodyEmitter emitter = new ResponseBodyEmitter(0L);
+
+ CompletableFuture.runAsync(() -> {
+ try {
+ streamingRawLocationPointJdbcService.streamPoints(userToFetchDataFrom, device, parseInstant(start, timezone, false), parseInstant(end, timezone, true).plus(1, ChronoUnit.SECONDS), emitter);
+ } catch (Exception e) {
+ if (e.getCause() instanceof java.io.IOException) {
+ try { emitter.complete(); } catch (Exception ignored) {}
+ } else {
+ try { emitter.completeWithError(e); } catch (Exception ignored) {}
+ }
+ }
+ });
+
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ .header(HttpHeaders.CONTENT_ENCODING, "identity")
+ .body(emitter);
+ }
+
+ @GetMapping(value = "/geojson/source", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity loadAsGeoJson(@AuthenticationPrincipal User user,
+ @RequestParam(name = "device", required = false) Long deviceId,
+ @RequestParam String start,
+ @RequestParam String end,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) {
+ try {
+ StreamingResponseBody stream = outputStream -> {
+ try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
+ geoJsonExportService.generateGeoJsonContentStreaming(
+ user,
+ parseInstant(start, timezone, false),
+ parseInstant(end, timezone, true),
+ deviceId,
+ writer);
+ } catch (Exception e) {
+ throw new RuntimeException("Error generating GeoJSON file", e);
+ }
+ };
+
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(stream);
+
+ } catch (Exception e) {
+ return ResponseEntity.badRequest()
+ .body(outputStream -> {
+ try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
+ writer.write("Error generating GeoJSON Stream: " + e.getMessage());
+ } catch (IOException ioException) {
+ throw new RuntimeException(ioException);
+ }
+ });
+ }
+ }
+
+ @GetMapping(value = "/geojson", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity loadTimelineAsGeoJson(@AuthenticationPrincipal User user,
+ @RequestParam String start,
+ @RequestParam String end,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) {
+ try {
+ StreamingResponseBody stream = outputStream -> {
+ try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
+ geoJsonExportService.generateGeoJsonContentStreaming(
+ user,
+ parseInstant(start, timezone, false),
+ parseInstant(end, timezone, true),
+ writer);
+ } catch (Exception e) {
+ throw new RuntimeException("Error generating GeoJSON file", e);
+ }
+ };
+
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(stream);
+
+ } catch (Exception e) {
+ return ResponseEntity.badRequest()
+ .body(outputStream -> {
+ try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
+ writer.write("Error generating GeoJSON Stream: " + e.getMessage());
+ } catch (IOException ioException) {
+ throw new RuntimeException(ioException);
+ }
+ });
+ }
+ }
private User loadUserToFetchDataFrom(User user, Long userId) throws IllegalAccessException {
if (user.getId().equals(userId)) {
return user;
@@ -97,22 +227,18 @@ private User loadUserToFetchDataFrom(User user, Long userId) throws IllegalAcces
.orElseThrow(() -> new RuntimeException("User not found"));
}
private Instant parseInstant(String input, ZoneId timezone, boolean end) {
- LocalDateTime dateTime = null;
try {
- dateTime = LocalDateTime.parse(input);
- } catch (Exception ignored) {}
+ return LocalDateTime.parse(input).atZone(timezone).toInstant();
+ } catch (Exception ignored) {
+ }
- if (dateTime == null) {
- try {
- dateTime = LocalDateTime.parse(input + (end ? "T23:59:59" : "T00:00:00"));
- return dateTime.atZone(timezone).toInstant();
- } catch (Exception ignored) {}
+ try {
+ return LocalDateTime.parse(input + (end ? "T23:59:59" : "T00:00:00")).atZone(timezone).toInstant();
+ } catch (Exception ignored) {
}
- if (dateTime == null) {
- try {
- dateTime = ZonedDateTime.parse(input).toLocalDateTime();
- return dateTime.atZone(timezone).toInstant();
- } catch (Exception ignored) {}
+ try {
+ return ZonedDateTime.parse(input).toInstant();
+ } catch (Exception ignored) {
}
throw new IllegalArgumentException("Invalid date format");
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/MetadataApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/MetadataApiController.java
new file mode 100644
index 000000000..b6798432c
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/MetadataApiController.java
@@ -0,0 +1,79 @@
+package com.dedicatedcode.reitti.controller.api.v2;
+
+import com.dedicatedcode.reitti.model.metadata.MemoryMetadata;
+import com.dedicatedcode.reitti.model.metadata.Mood;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
+import com.dedicatedcode.reitti.repository.TripJdbcService;
+import com.dedicatedcode.reitti.service.MetadataOverrideService;
+import com.dedicatedcode.reitti.service.processing.TimeRange;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v2/metadata")
+public class MetadataApiController {
+ private final TripJdbcService tripJdbcService;
+ private final ProcessedVisitJdbcService processedVisitJdbcService;
+ private final MetadataOverrideService metadataOverrideService;
+
+ public MetadataApiController(TripJdbcService tripJdbcService,
+ ProcessedVisitJdbcService processedVisitJdbcService,
+ MetadataOverrideService metadataOverrideService) {
+ this.tripJdbcService = tripJdbcService;
+ this.processedVisitJdbcService = processedVisitJdbcService;
+ this.metadataOverrideService = metadataOverrideService;
+ }
+
+ @GetMapping("/{type}/{id}")
+ public MemoryMetadata getMetadata(@AuthenticationPrincipal User user, @PathVariable String type, @PathVariable Long id) {
+ TimeRange timeRange = findTimeRange(type, id);
+ return this.metadataOverrideService.findOverlappingMetadata(user, timeRange.start(), timeRange.end()).orElse(null);
+ }
+
+ @PostMapping("/{type}/{id}")
+ @Transactional
+ public MemoryMetadata postMetadata(@AuthenticationPrincipal User user,
+ @RequestParam(required = false) String mood,
+ @RequestParam(required = false) String reason,
+ @RequestParam(required = false) String notes,
+ @RequestParam(required = false) List tags,
+ @PathVariable String type,
+ @PathVariable Long id) {
+
+ return switch (type) {
+ case "trip" -> this.tripJdbcService.findById(id).map(t -> {
+ MemoryMetadata memoryMetadata = new MemoryMetadata(t.getStartTime(), t.getEndTime());
+ memoryMetadata.setMood(mood != null ? Mood.valueOf(mood) : null);
+ memoryMetadata.setReason(reason);
+ memoryMetadata.setDescription(notes);
+ memoryMetadata.setTags(tags);
+ this.metadataOverrideService.saveTripMetadata(user, t, memoryMetadata);
+ return memoryMetadata;
+ }).orElseThrow(() -> new IllegalArgumentException("Trip not found"));
+ case "visit" -> this.processedVisitJdbcService.findById(id).map(p -> {
+ MemoryMetadata memoryMetadata = new MemoryMetadata(p.getStartTime(), p.getEndTime());
+ memoryMetadata.setMood(mood != null ? Mood.valueOf(mood) : null);
+ memoryMetadata.setReason(reason);
+ memoryMetadata.setDescription(notes);
+ memoryMetadata.setTags(tags);
+ this.metadataOverrideService.saveVisitMetadata(user, p, memoryMetadata);
+ return memoryMetadata;
+ }).orElseThrow(() -> new IllegalArgumentException("Visit not found"));
+ default -> throw new IllegalStateException("Unexpected value: " + type);
+ };
+ }
+
+ private TimeRange findTimeRange(String type, Long id) {
+ return switch (type) {
+ case ("trip") ->
+ this.tripJdbcService.findById(id).map(t -> TimeRange.of(t.getStartTime(), t.getEndTime())).orElseThrow(() -> new IllegalArgumentException("Trip not found"));
+ case ("visit") ->
+ this.processedVisitJdbcService.findById(id).map(p -> TimeRange.of(p.getStartTime(), p.getEndTime())).orElseThrow(() -> new IllegalArgumentException("Visit not found"));
+ default -> throw new IllegalStateException("Unexpected value: " + type);
+ };
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/v2/WorkbenchApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/WorkbenchApiController.java
new file mode 100644
index 000000000..ba2cb2b26
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/api/v2/WorkbenchApiController.java
@@ -0,0 +1,35 @@
+package com.dedicatedcode.reitti.controller.api.v2;
+
+import com.dedicatedcode.reitti.dto.workbench.WorkbenchCommitRequest;
+import com.dedicatedcode.reitti.dto.workbench.WorkbenchCommitResponse;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.service.I18nService;
+import com.dedicatedcode.reitti.service.workbench.WorkbenchService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v2/workbench")
+public class WorkbenchApiController {
+ private final WorkbenchService workbenchService;
+ private final I18nService i18n;
+ public WorkbenchApiController(WorkbenchService workbenchService, I18nService i18n) {
+ this.workbenchService = workbenchService;
+ this.i18n = i18n;
+ }
+
+ @PostMapping("/commit")
+ public ResponseEntity commit(@AuthenticationPrincipal User user,
+ @RequestBody WorkbenchCommitRequest request) {
+ try {
+ workbenchService.applyCommit(user, request);
+ return ResponseEntity.ok(new WorkbenchCommitResponse(true, i18n.translate("workbench.commit.success")));
+ } catch (Exception e) {
+ return ResponseEntity.ok(new WorkbenchCommitResponse(false, i18n.translate("workbench.commit.failure", e.getMessage())));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java
index 07bcd839d..8ebdabdc2 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/ApiTokenSettingsController.java
@@ -1,10 +1,12 @@
package com.dedicatedcode.reitti.controller.settings;
import com.dedicatedcode.reitti.model.Role;
+import com.dedicatedcode.reitti.model.devices.Device;
import com.dedicatedcode.reitti.model.security.ApiToken;
import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.ApiTokenJdbcService;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
import com.dedicatedcode.reitti.service.ApiTokenService;
-import com.dedicatedcode.reitti.service.TimeUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
@@ -15,22 +17,29 @@
import java.time.LocalDateTime;
import java.time.ZoneId;
-import java.util.List;
+import java.util.Optional;
import static com.dedicatedcode.reitti.service.TimeUtil.adjustInstant;
@Controller
@RequestMapping("/settings/api-tokens")
public class ApiTokenSettingsController {
+ private static final int MAX_TOKEN_USAGES = 10;
+
private final ApiTokenService apiTokenService;
+ private final ApiTokenJdbcService apiTokenJdbcService;
+ private final DeviceJdbcService deviceJdbcService;
private final MessageSource messageSource;
private final boolean dataManagementEnabled;
- public ApiTokenSettingsController(ApiTokenService apiTokenService,
+ public ApiTokenSettingsController(ApiTokenService apiTokenService, ApiTokenJdbcService apiTokenJdbcService,
+ DeviceJdbcService deviceJdbcService,
MessageSource messageSource,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
this.apiTokenService = apiTokenService;
+ this.apiTokenJdbcService = apiTokenJdbcService;
+ this.deviceJdbcService = deviceJdbcService;
this.messageSource = messageSource;
this.dataManagementEnabled = dataManagementEnabled;
}
@@ -39,11 +48,10 @@ public ApiTokenSettingsController(ApiTokenService apiTokenService,
public String getPage(@AuthenticationPrincipal User user,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
Model model) {
+ addCommonAttributes(timezone, user, model);
model.addAttribute("activeSection", "api-tokens");
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
- model.addAttribute("tokens", apiTokenService.getTokensForUser(user).stream()
- .map(t -> new ApiTokeDTO(t.getId(), t.getToken(), t.getName(), adjustInstant(t.getCreatedAt(), timezone), adjustInstant(t.getLastUsedAt(),timezone))).toList());
return "settings/api-tokens";
}
@@ -53,13 +61,17 @@ public String getTokenUsages(@AuthenticationPrincipal User user,
Model model) {
model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, 10)
.stream()
- .map(t -> new ApiTokenUsageDTO(t.token(), t.name(), adjustInstant(t.at(), timezone), t.endpoint(), t.ip()))
+ .map(t -> new ApiTokenUsageDTO(t.token(), t.name(), t.device(), adjustInstant(t.at(), timezone), t.endpoint(), t.ip()))
.toList());
model.addAttribute("maxUsagesToShow", 10);
return "settings/api-tokens :: api-token-usages";
}
+
@PostMapping
- public String createToken(@AuthenticationPrincipal User user, @RequestParam String name, Model model) {
+ public String createToken(@AuthenticationPrincipal User user,
+ @RequestParam String name,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
try {
apiTokenService.createToken(user, name);
model.addAttribute("successMessage", getMessage("message.success.token.created"));
@@ -67,18 +79,95 @@ public String createToken(@AuthenticationPrincipal User user, @RequestParam Stri
model.addAttribute("errorMessage", getMessage("message.error.token.creation", e.getMessage()));
}
- // Get updated token list and add to model
- List tokens = apiTokenService.getTokensForUser(user);
- model.addAttribute("tokens", tokens);
- model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, 10));
- model.addAttribute("maxUsagesToShow", 10);
+ addCommonAttributes(timezone, user, model);
+ return "settings/api-tokens :: api-tokens-content";
+ }
- // Return the api-tokens-content fragment
+ @PostMapping("/{id}/detach/{deviceId}")
+ public String detachFromDevice(@AuthenticationPrincipal User user,
+ @PathVariable Long id,
+ @PathVariable Long deviceId,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+
+ Optional tokenById = this.apiTokenService.getTokenById(user, id);
+
+ if (tokenById.isEmpty()) {
+ throw new IllegalArgumentException("Token not found");
+ }
+
+ Optional deviceById = this.deviceJdbcService.find(user, deviceId);
+ if (deviceById.isEmpty()) {
+ throw new IllegalArgumentException("Device not found");
+ }
+
+ try {
+ apiTokenJdbcService.save(tokenById.get().withDevice(null));
+ model.addAttribute("successMessage", getMessage("message.success.token.detached", deviceById.get().name()));
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", getMessage("message.error.generic", e.getMessage()));
+ }
+
+ addCommonAttributes(timezone, user, model);
+ return "settings/api-tokens :: api-tokens-content";
+ }
+
+ @PostMapping("/link/{id}")
+ public String linkToDevice(@AuthenticationPrincipal User user,
+ @PathVariable Long id,
+ @RequestParam Long deviceId,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+
+ Optional tokenById = this.apiTokenService.getTokenById(user, id);
+
+ if (tokenById.isEmpty()) {
+ throw new IllegalArgumentException("Token not found");
+ }
+
+ Optional deviceById = this.deviceJdbcService.find(user, deviceId);
+ if (deviceById.isEmpty()) {
+ throw new IllegalArgumentException("Device not found");
+ }
+
+ try {
+ apiTokenJdbcService.save(tokenById.get().withDevice(deviceById.get()));
+ model.addAttribute("successMessage", getMessage("message.success.token.attach", deviceById.get().name()));
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", getMessage("message.error.generic", e.getMessage()));
+ }
+ addCommonAttributes(timezone, user, model);
+ return "settings/api-tokens :: api-tokens-content";
+ }
+
+ @GetMapping("/{tokenId}/link-form")
+ public String linkForm(@AuthenticationPrincipal User user,
+ @PathVariable Long tokenId,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ Optional tokenById = this.apiTokenService.getTokenById(user, tokenId);
+ if (tokenById.isEmpty()) {
+ throw new IllegalArgumentException("Token not found");
+ } else {
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
+ model.addAttribute("token", toDto(timezone, tokenById.get()));
+ return "settings/fragments/api-tokens :: link-form";
+ }
+ }
+
+ @GetMapping("/tokens")
+ public String tokensContent(@AuthenticationPrincipal User user,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ addCommonAttributes(timezone, user, model);
return "settings/api-tokens :: api-tokens-content";
}
@PostMapping("/{tokenId}/delete")
- public String deleteToken(@PathVariable Long tokenId, @AuthenticationPrincipal User user, Model model) {
+ public String deleteToken(@PathVariable Long tokenId,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ @AuthenticationPrincipal User user,
+ Model model) {
try {
apiTokenService.deleteToken(tokenId);
@@ -87,22 +176,34 @@ public String deleteToken(@PathVariable Long tokenId, @AuthenticationPrincipal U
model.addAttribute("errorMessage", getMessage("message.error.token.deletion", e.getMessage()));
}
- // Get updated token list and add to model
- List tokens = apiTokenService.getTokensForUser(user);
- model.addAttribute("tokens", tokens);
- model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, 10));
- model.addAttribute("maxUsagesToShow", 10);
-
- // Return the api-tokens-content fragment
+ addCommonAttributes(timezone, user, model);
return "settings/api-tokens :: api-tokens-content";
}
- public record ApiTokeDTO(Long id, String token, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {}
+ private void addCommonAttributes(ZoneId timezone, User user, Model model) {
+ model.addAttribute("tokens", apiTokenService.getTokensForUser(user).stream()
+ .map(t -> toDto(timezone, t)).toList());
+ model.addAttribute("recentUsages", apiTokenService.getRecentUsagesForUser(user, MAX_TOKEN_USAGES));
+ model.addAttribute("maxUsagesToShow", MAX_TOKEN_USAGES);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
+ }
+
+ public record ApiTokenDto(Long id, Long deviceId, String deviceName, String token, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {}
- public record ApiTokenUsageDTO(String token, String name, LocalDateTime at, String endpoint, String ip) {
+ public record ApiTokenUsageDTO(String token, String name, String device, LocalDateTime at, String endpoint, String ip) {
}
private String getMessage(String key, Object... args) {
return messageSource.getMessage(key, args, LocaleContextHolder.getLocale());
}
+
+ private static ApiTokenDto toDto(ZoneId timezone, ApiToken t) {
+ return new ApiTokenDto(t.getId(),
+ t.getDevice() != null ? t.getDevice().id() : null,
+ t.getDevice() != null ? t.getDevice().name() : null,
+ t.getToken(),
+ t.getName(),
+ adjustInstant(t.getCreatedAt(), timezone),
+ adjustInstant(t.getLastUsedAt(), timezone));
+ }
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/DeviceSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/DeviceSettingsController.java
new file mode 100644
index 000000000..0b3db6ab3
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/DeviceSettingsController.java
@@ -0,0 +1,377 @@
+package com.dedicatedcode.reitti.controller.settings;
+
+import com.dedicatedcode.reitti.model.Role;
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.security.ApiToken;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.ApiTokenJdbcService;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
+import com.dedicatedcode.reitti.service.AvatarService;
+import com.dedicatedcode.reitti.service.ContextPathHolder;
+import com.dedicatedcode.reitti.service.I18nService;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.stereotype.Controller;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.ui.Model;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@Controller
+@RequestMapping("/settings/devices")
+public class DeviceSettingsController {
+ // Avatar constraints
+ private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
+ private static final String[] ALLOWED_CONTENT_TYPES = {
+ "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"
+ };
+ private static final List DEFAULT_AVATARS = Arrays.asList(
+ "gps.jpg", "smartwatch.jpg", "phone.jpg"
+ );
+
+ private final DeviceJdbcService deviceJdbcService;
+ private final ApiTokenJdbcService apiTokenJdbcService;
+ private final AvatarService avatarService;
+ private final I18nService i18n;
+ private final ContextPathHolder contextPathHolder;
+
+ public DeviceSettingsController(DeviceJdbcService deviceJdbcService,
+ ApiTokenJdbcService apiTokenJdbcService,
+ AvatarService avatarService,
+ I18nService i18n, ContextPathHolder contextPathHolder) {
+ this.deviceJdbcService = deviceJdbcService;
+ this.apiTokenJdbcService = apiTokenJdbcService;
+ this.avatarService = avatarService;
+ this.i18n = i18n;
+ this.contextPathHolder = contextPathHolder;
+ }
+
+ @GetMapping
+ public String getPage(@AuthenticationPrincipal User user,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ model.addAttribute("activeSection", "devices");
+ model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
+ model.addAttribute("defaultColors", getDefaultColors());
+ model.addAttribute("defaultAvatars", DEFAULT_AVATARS);
+ model.addAttribute("devices", deviceJdbcService.getAll(user).stream()
+ .map(d -> new DeviceDTO(d.id(), d.name(), d.color(),
+ createAvatarUrl(user, d), avatarService.generateInitials(d.name()), d.enabled(), d.showOnMap(), d.showAvatarOnMap(), d.defaultDevice(),
+ adjustInstant(d.createdAt(), timezone), adjustInstant(d.updatedAt(), timezone)))
+ .toList());
+ return "settings/devices";
+ }
+
+ @GetMapping("/edit/{deviceId}")
+ public String editDevice(@PathVariable Long deviceId,
+ @AuthenticationPrincipal User user,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ List devices = deviceJdbcService.getAll(user);
+ Device device = devices.stream()
+ .filter(d -> d.id().equals(deviceId))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Device not found"));
+
+ model.addAttribute("defaultColors", getDefaultColors());
+ model.addAttribute("defaultAvatars", DEFAULT_AVATARS);
+ model.addAttribute("selectedColor", device.color());
+ model.addAttribute("device",
+ new DeviceDTO(device.id(), device.name(), device.color(),
+ createAvatarUrl(user, device), avatarService.generateInitials(device.name()),
+ device.enabled(), device.showOnMap(), device.showAvatarOnMap(), device.defaultDevice(),
+ adjustInstant(device.createdAt(), timezone), adjustInstant(device.updatedAt(), timezone)));
+ boolean hasAvatar = this.avatarService.getInfo(user.getId(), device.id()).isPresent();
+ model.addAttribute("hasAvatar", hasAvatar);
+
+ return "settings/devices :: device-edit-form";
+ }
+
+ @PostMapping
+ public String createDevice(@AuthenticationPrincipal User user,
+ @RequestParam String name,
+ @RequestParam String color,
+ @RequestParam(required = false, defaultValue = "false") boolean enabled,
+ @RequestParam(required = false, defaultValue = "false") boolean showOnMap,
+ @RequestParam(required = false, defaultValue = "false") boolean showAvatarOnMap,
+ @RequestParam(required = false) String defaultAvatar,
+ @RequestParam(required = false) MultipartFile avatar,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ try {
+ Instant now = Instant.now();
+ Device device = new Device(
+ null,
+ name,
+ enabled,
+ showOnMap,
+ showAvatarOnMap,
+ color,
+ false,
+ now,
+ now,
+ 1L
+ );
+ Device saved = deviceJdbcService.save(device, user);
+ ApiToken apiToken = new ApiToken(user, saved.name(), saved);
+ this.apiTokenJdbcService.save(apiToken);
+
+ // Handle avatar operations
+ if (avatar != null && !avatar.isEmpty()) {
+ handleAvatarUpload(avatar, user.getId(), saved.id(), model);
+ } else if (StringUtils.hasText(defaultAvatar)) {
+ handleDefaultAvatarSelection(defaultAvatar, user.getId(), saved.id(), model);
+ }
+
+ model.addAttribute("successMessage", i18n.translate("message.success.device.created"));
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", i18n.translate("message.error.device.creation", e.getMessage()));
+ }
+
+ addDevicesToModel(user, timezone, model);
+
+ return "settings/devices :: devices-content";
+ }
+
+ @PostMapping("/{deviceId}")
+ public String updateDevice(@PathVariable Long deviceId,
+ @AuthenticationPrincipal User user,
+ @RequestParam String name,
+ @RequestParam String color,
+ @RequestParam(required = false, defaultValue = "false") boolean enabled,
+ @RequestParam(required = false, defaultValue = "false") boolean showOnMap,
+ @RequestParam(required = false, defaultValue = "false") boolean showAvatarOnMap,
+ @RequestParam(required = false) String defaultAvatar,
+ @RequestParam(required = false) String removeAvatar,
+ @RequestParam(required = false) MultipartFile avatar,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ try {
+ List devices = deviceJdbcService.getAll(user);
+ Device existingDevice = devices.stream()
+ .filter(d -> d.id().equals(deviceId))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Device not found"));
+
+ Device updatedDevice = new Device(
+ deviceId,
+ name,
+ enabled,
+ showOnMap,
+ showAvatarOnMap,
+ color,
+ existingDevice.defaultDevice(),
+ existingDevice.createdAt(),
+ Instant.now(),
+ existingDevice.version() + 1
+ );
+ deviceJdbcService.update(updatedDevice, user);
+
+
+ // Handle avatar operations
+ if ("true".equals(removeAvatar)) {
+ avatarService.deleteAvatar(user.getId(), deviceId);
+ } else if (avatar != null && !avatar.isEmpty()) {
+ handleAvatarUpload(avatar, user.getId(), deviceId, model);
+ } else if (StringUtils.hasText(defaultAvatar)) {
+ handleDefaultAvatarSelection(defaultAvatar, user.getId(), deviceId, model);
+ }
+
+ model.addAttribute("successMessage", i18n.translate("message.success.device.updated"));
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", i18n.translate("message.error.device.update", e.getMessage()));
+ }
+
+ addDevicesToModel(user, timezone, model);
+
+ return "settings/devices :: devices-content";
+ }
+
+ @PostMapping("/{deviceId}/toggle")
+ public String toggleDevice(@PathVariable Long deviceId,
+ @AuthenticationPrincipal User user,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ try {
+ Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found"));
+ Device updatedDevice = new Device(
+ device.id(),
+ device.name(),
+ !device.enabled(),
+ device.showOnMap(),
+ device.showAvatarOnMap(),
+ device.color(),
+ device.defaultDevice(),
+ device.createdAt(),
+ Instant.now(),
+ device.version() + 1
+ );
+ this.deviceJdbcService.update(updatedDevice, user);
+ model.addAttribute("successMessage", i18n.translate("message.success.device.toggled"));
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", i18n.translate("message.error.device.toggle", e.getMessage()));
+ }
+
+ addDevicesToModel(user, timezone, model);
+
+ return "settings/devices :: devices-content";
+ }
+
+ @PostMapping("/{deviceId}/set-default")
+ @Transactional
+ public String setToDefault(@PathVariable Long deviceId,
+ @AuthenticationPrincipal User user,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ try {
+
+ Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found"));
+ Device oldDefaultDevice = this.deviceJdbcService.getDefaultDevice(user);
+
+ oldDefaultDevice = oldDefaultDevice.withDefaultDevice(false);
+ device = device.withDefaultDevice(true);
+ this.deviceJdbcService.update(oldDefaultDevice, user);
+ this.deviceJdbcService.update(device, user);
+ model.addAttribute("successMessage", i18n.translate("message.success.device.default-device", device.name()));
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", i18n.translate("message.error.device.toggle", e.getMessage()));
+ }
+
+ addDevicesToModel(user, timezone, model);
+
+ return "settings/devices :: devices-content";
+ }
+
+ @PostMapping("/{deviceId}/delete")
+ public String deleteDevice(@PathVariable Long deviceId, @AuthenticationPrincipal User user,
+ @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ try {
+ Device deviceToDelete = this.deviceJdbcService.find(user, deviceId).orElseThrow(() -> new IllegalArgumentException("Device not found"));
+
+ if (deviceToDelete.defaultDevice()) {
+ model.addAttribute("errorMessage", i18n.translate("message.error.device.deletion.default"));
+ } else {
+ deviceJdbcService.delete(deviceToDelete, user);
+ model.addAttribute("successMessage", i18n.translate("message.success.device.deleted"));
+ }
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", i18n.translate("message.error.device.deletion", e.getMessage()));
+ }
+
+ addDevicesToModel(user, timezone, model);
+
+ // Return the devices-content fragment
+ return "settings/devices :: devices-content";
+ }
+
+ private void handleAvatarUpload(MultipartFile avatar, Long userId, Long deviceId, Model model) {
+ if (avatar != null && !avatar.isEmpty()) {
+ try {
+ // Validate file size
+ if (avatar.getSize() > MAX_AVATAR_SIZE) {
+ model.addAttribute("avatarError", i18n.translate("users.avatar.error.to-large"));
+ return;
+ }
+
+ // Validate content type
+ String contentType = avatar.getContentType();
+ if (contentType == null || !isAllowedContentType(contentType)) {
+ model.addAttribute("avatarError", i18n.translate("users.avatar.error.invalid-file-type"));
+ return;
+ }
+
+ byte[] imageData = avatar.getBytes();
+ this.avatarService.updateAvatar(userId, deviceId, contentType, imageData);
+
+ } catch (IOException e) {
+ model.addAttribute("avatarError", i18n.translate("devices.avatar.error.generic", e.getMessage()));
+ }
+ }
+ }
+
+ private void handleDefaultAvatarSelection(String defaultAvatar, Long userId, Long deviceId, Model model) {
+ try {
+ if (!DEFAULT_AVATARS.contains(defaultAvatar)) {
+ model.addAttribute("avatarError", "Invalid default avatar selection.");
+ return;
+ }
+ ClassPathResource resource = new ClassPathResource("static/img/avatars/default/" + defaultAvatar);
+ if (!resource.exists()) {
+ model.addAttribute("avatarError", "Default avatar file not found.");
+ return;
+ }
+
+ byte[] imageData = resource.getInputStream().readAllBytes();
+ String mimeType = "image/jpeg";
+
+ this.avatarService.updateAvatar(userId, deviceId, mimeType, imageData);
+
+ } catch (IOException e) {
+ model.addAttribute("avatarError", "Error processing default avatar: " + e.getMessage());
+ }
+ }
+
+ private String createAvatarUrl(User user, Device device) {
+ if (this.avatarService.getInfo(user.getId(), device.id()).isPresent()) {
+ return String.format(contextPathHolder.getContextPath() + "/avatars/%d/%d?ts=%s", user.getId(), device.id(), Instant.now().toEpochMilli());
+ } else {
+ return null;
+ }
+ }
+
+ private boolean isAllowedContentType(String contentType) {
+ for (String allowed : ALLOWED_CONTENT_TYPES) {
+ if (allowed.equals(contentType)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public record DeviceDTO(Long id, String name, String color, String avatarUrl, String avatarFallback,
+ boolean enabled,
+ boolean showOnMap,
+ boolean showAvatar,
+ boolean defaultDevice,
+ LocalDateTime createdAt, LocalDateTime updatedAt) {
+ }
+
+ private void addDevicesToModel(User user, ZoneId timezone, Model model) {
+ List devices = deviceJdbcService.getAll(user);
+ model.addAttribute("devices", devices.stream()
+ .map(d -> new DeviceDTO(d.id(), d.name(), d.color(), createAvatarUrl(user, d), avatarService.generateInitials(d.name()), d.enabled(), d.showOnMap(), d.showAvatarOnMap(), d.defaultDevice(),
+ adjustInstant(d.createdAt(), timezone), adjustInstant(d.updatedAt(), timezone)))
+ .toList());
+ model.addAttribute("defaultColors", getDefaultColors());
+ }
+
+ private Map getDefaultColors() {
+ return Map.of(
+ "#f1ba63", "Orange",
+ "#ff6b6b", "Red",
+ "#4ecdc4", "Teal",
+ "#45b7d1", "Blue",
+ "#96ceb4", "Green",
+ "#ffeaa7", "Yellow",
+ "#dfe6e9", "Gray",
+ "#a29bfe", "Purple"
+ );
+ }
+
+ private LocalDateTime adjustInstant(Instant instant, ZoneId zoneId) {
+ if (instant == null || zoneId == null) {
+ return null;
+ }
+ return instant.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId).toLocalDateTime();
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java
index bb10f3a16..d5fd2c0ce 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/ExportDataController.java
@@ -1,9 +1,11 @@
package com.dedicatedcode.reitti.controller.settings;
import com.dedicatedcode.reitti.model.Role;
-import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.geo.SourceLocationPoint;
import com.dedicatedcode.reitti.model.security.User;
-import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
+import com.dedicatedcode.reitti.repository.SourceLocationPointJdbcService;
import com.dedicatedcode.reitti.service.GpxExportService;
import com.dedicatedcode.reitti.service.TimeUtil;
import org.springframework.beans.factory.annotation.Value;
@@ -35,14 +37,17 @@
@RequestMapping("/settings/export-data")
public class ExportDataController {
- private final RawLocationPointJdbcService rawLocationPointJdbcService;
+ private final SourceLocationPointJdbcService sourceLocationPointJdbcService;
+ private final DeviceJdbcService deviceJdbcService;
private final GpxExportService gpxExportService;
private final boolean dataManagementEnabled;
- public ExportDataController(RawLocationPointJdbcService rawLocationPointJdbcService,
+ public ExportDataController(SourceLocationPointJdbcService sourceLocationPointJdbcService,
+ DeviceJdbcService deviceJdbcService,
GpxExportService gpxExportService,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
- this.rawLocationPointJdbcService = rawLocationPointJdbcService;
+ this.sourceLocationPointJdbcService = sourceLocationPointJdbcService;
+ this.deviceJdbcService = deviceJdbcService;
this.gpxExportService = gpxExportService;
this.dataManagementEnabled = dataManagementEnabled;
}
@@ -54,6 +59,7 @@ public void getExportDataContent(@AuthenticationPrincipal User user, Model model
java.time.LocalDate today = java.time.LocalDate.now();
model.addAttribute("startDate", today);
model.addAttribute("endDate", today);
+ model.addAttribute("devices", deviceJdbcService.getAll(user));
// Get raw location points for today by default
model.addAttribute("rawLocationPoints", Collections.emptyList());
@@ -66,11 +72,12 @@ public void getExportDataContent(@AuthenticationPrincipal User user, Model model
public String getExportDataContent(@AuthenticationPrincipal User user,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
+ @RequestParam(required = false) String deviceId,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "100") int size,
Model model) {
-
+ Device device = findDevice(user, deviceId);
LocalDate start = StringUtils.hasText(startDate) ? LocalDate.parse(startDate) : LocalDate.now();
LocalDate end = StringUtils.hasText(endDate) ? LocalDate.parse(endDate) : LocalDate.now();
ZonedDateTime startDateTime = start.atStartOfDay(timezone);
@@ -79,14 +86,14 @@ public String getExportDataContent(@AuthenticationPrincipal User user,
model.addAttribute("endDate", end);
// Get raw location points for the date range
- List allPoints = rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startDateTime.toInstant(), endDateTime.toInstant(), false, true, true, page, size);
+ List allPoints = sourceLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, device, startDateTime.toInstant(), endDateTime.toInstant(), true, true, page, size);
- long totalElements = rawLocationPointJdbcService.countByUserAndTimestampBetweenOrderByTimestampAsc(user, startDateTime.toInstant(), endDateTime.toInstant(), false, true, true);
+ long totalElements = sourceLocationPointJdbcService.countByUserAndTimestampBetween(user, device, startDateTime.toInstant(), endDateTime.toInstant(), true, true);
int totalPages = (int) Math.ceil((double) totalElements / size);
List paginatedData = allPoints.stream()
.map(p -> new DataLine(TimeUtil.adjustInstant(p.getTimestamp(), timezone),
- p.getLatitude(), p.getLongitude(), p.getAccuracyMeters(), p.isProcessed()))
+ p.getLatitude(), p.getLongitude(), p.getAccuracyMeters()))
.toList();
model.addAttribute("rawLocationPoints", paginatedData);
@@ -99,14 +106,24 @@ public String getExportDataContent(@AuthenticationPrincipal User user,
return "settings/export-data :: data-content";
}
-
+
+ private Device findDevice(User user, String deviceId) {
+ if (!StringUtils.hasText(deviceId) ) {
+ return null;
+ } else {
+ return deviceJdbcService.find(user, Long.valueOf(deviceId)).orElseThrow(IllegalArgumentException::new);
+ }
+ }
+
@GetMapping("/gpx")
public ResponseEntity exportGpx(@AuthenticationPrincipal User user,
+ @RequestParam(required = false) String deviceId,
@RequestParam String startDate,
@RequestParam String endDate,
@RequestParam boolean relevantDataOnly,
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone) {
try {
+ Device device = findDevice(user, deviceId);
LocalDate start = LocalDate.parse(startDate);
LocalDate end = LocalDate.parse(endDate);
ZonedDateTime startDateTime = start.atStartOfDay(timezone);
@@ -118,7 +135,7 @@ public ResponseEntity exportGpx(@AuthenticationPrincipal
StreamingResponseBody stream = outputStream -> {
try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
- gpxExportService.generateGpxContentStreaming(user, startDateTime.toInstant(), endDateTime.toInstant(), writer, relevantDataOnly);
+ gpxExportService.generateGpxContentStreaming(user, device, startDateTime.toInstant(), endDateTime.toInstant(), writer, relevantDataOnly);
} catch (Exception e) {
throw new RuntimeException("Error generating GPX file", e);
}
@@ -141,6 +158,6 @@ public ResponseEntity exportGpx(@AuthenticationPrincipal
}
}
- public record DataLine(LocalDateTime timestamp, double latitude, double longitude, double accuracyMeters, boolean processed) {}
+ public record DataLine(LocalDateTime timestamp, double latitude, double longitude, double accuracyMeters) {}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java
index e77ccbb75..981652cfd 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/FileImportController.java
@@ -1,67 +1,81 @@
package com.dedicatedcode.reitti.controller.settings;
import com.dedicatedcode.reitti.model.Role;
+import com.dedicatedcode.reitti.model.devices.Device;
import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
+import com.dedicatedcode.reitti.service.I18nService;
import com.dedicatedcode.reitti.service.importer.*;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.apache.logging.log4j.util.Strings;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
+import java.util.Arrays;
import java.util.Map;
+import java.util.stream.Collectors;
@Controller
@RequestMapping("/settings/import")
public class FileImportController {
- private static final Logger logger = LoggerFactory.getLogger(FileImportController.class);
-
private final GpxImporter gpxImporter;
private final GoogleRecordsImporter googleRecordsImporter;
private final GoogleAndroidTimelineImporter googleAndroidTimelineImporter;
private final GoogleIOSTimelineImporter googleTimelineIOSImporter;
private final GeoJsonImporter geoJsonImporter;
+ private final DeviceJdbcService deviceJdbcService;
+ private final I18nService i18n;
private final boolean dataManagementEnabled;
+ private final int maxFileSupported;
+ private final String maxFileSize;
public FileImportController(GpxImporter gpxImporter,
GoogleRecordsImporter googleRecordsImporter,
GoogleAndroidTimelineImporter googleAndroidTimelineImporter,
GoogleIOSTimelineImporter googleTimelineIOSImporter,
- GeoJsonImporter geoJsonImporter,
- @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
+ GeoJsonImporter geoJsonImporter, DeviceJdbcService deviceJdbcService,
+ I18nService i18n,
+ @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
+ @Value("${server.tomcat.max-part-count}") int maxFileSupported,
+ @Value("${spring.servlet.multipart.max-file-size}") String maxFileSize) {
this.gpxImporter = gpxImporter;
this.googleRecordsImporter = googleRecordsImporter;
this.googleAndroidTimelineImporter = googleAndroidTimelineImporter;
this.googleTimelineIOSImporter = googleTimelineIOSImporter;
this.geoJsonImporter = geoJsonImporter;
+ this.deviceJdbcService = deviceJdbcService;
+ this.i18n = i18n;
this.dataManagementEnabled = dataManagementEnabled;
+ this.maxFileSupported = maxFileSupported;
+ this.maxFileSize = maxFileSize;
}
-
@GetMapping
public String getFileUploadPage(@AuthenticationPrincipal User user, Model model) {
model.addAttribute("activeSection", "file-upload");
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
return "settings/import-data";
}
@PostMapping("/gpx")
public String importGpx(@RequestParam("files") MultipartFile[] files,
+ @RequestParam("device") Long deviceId,
Authentication authentication,
Model model) {
User user = (User) authentication.getPrincipal();
-
+ Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
if (files.length == 0) {
model.addAttribute("uploadErrorMessage", "No files selected");
return "settings/import-data :: file-upload-content";
@@ -72,29 +86,21 @@ public String importGpx(@RequestParam("files") MultipartFile[] files,
StringBuilder errorMessages = new StringBuilder();
for (MultipartFile file : files) {
- if (file.isEmpty() || file.getOriginalFilename() == null) {
- errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
- continue;
- }
-
- if (!file.getOriginalFilename().endsWith(".gpx")) {
- errorMessages.append("File ").append(file.getOriginalFilename()).append(" is not a GPX file. ");
- continue;
- }
-
- try (InputStream inputStream = file.getInputStream()) {
- Map result = this.gpxImporter.importGpx(inputStream, user);
-
- if ((Boolean) result.get("success")) {
- totalProcessed += (Integer) result.get("pointsReceived");
- successCount++;
- } else {
+ if (validateFile(file, errorMessages, "gpx")) {
+ try (InputStream inputStream = file.getInputStream()) {
+ Map result = this.gpxImporter.importGpx(inputStream, user, device, file.getOriginalFilename());
+
+ if ((Boolean) result.get("success")) {
+ totalProcessed += (Integer) result.get("pointsReceived");
+ successCount++;
+ } else {
+ errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
+ .append(result.get("error")).append(". ");
+ }
+ } catch (IOException e) {
errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
- .append(result.get("error")).append(". ");
+ .append(e.getMessage()).append(". ");
}
- } catch (IOException e) {
- errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
- .append(e.getMessage()).append(". ");
}
}
@@ -113,22 +119,21 @@ public String importGpx(@RequestParam("files") MultipartFile[] files,
@PostMapping("/google-records")
public String importGoogleRecords(@RequestParam("file") MultipartFile file,
- Authentication authentication,
- Model model) {
+ @RequestParam(name = "device") Long deviceId,
+ Authentication authentication,
+ Model model) {
User user = (User) authentication.getPrincipal();
+ Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
- if (file.isEmpty() || file.getOriginalFilename() == null) {
- model.addAttribute("uploadErrorMessage", "File is empty");
- return "settings/import-data :: file-upload-content";
- }
-
- if (!file.getOriginalFilename().endsWith(".json")) {
- model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
+ StringBuilder errorMessages = new StringBuilder();
+ if (!validateFile(file, errorMessages, "json")) {
+ model.addAttribute("uploadErrorMessage", errorMessages.toString());
return "settings/import-data :: file-upload-content";
}
try (InputStream inputStream = file.getInputStream()) {
- Map result = this.googleRecordsImporter.importGoogleRecords(inputStream, user);
+ Map result = this.googleRecordsImporter.importGoogleRecords(inputStream, user, device, file.getOriginalFilename());
if ((Boolean) result.get("success")) {
model.addAttribute("uploadSuccessMessage", result.get("message"));
@@ -145,22 +150,21 @@ public String importGoogleRecords(@RequestParam("file") MultipartFile file,
@PostMapping("/google-timeline-android")
public String importGoogleTimelineAndroid(@RequestParam("file") MultipartFile file,
- Authentication authentication,
- Model model) {
+ @RequestParam(name = "device") Long deviceId,
+ Authentication authentication,
+ Model model) {
User user = (User) authentication.getPrincipal();
+ Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
- if (file.isEmpty() || file.getOriginalFilename() == null) {
- model.addAttribute("uploadErrorMessage", "File is empty");
- return "settings/import-data :: file-upload-content";
- }
-
- if (!file.getOriginalFilename().endsWith(".json")) {
- model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
+ StringBuilder errorMessages = new StringBuilder();
+ if (!validateFile(file, errorMessages, "json")) {
+ model.addAttribute("uploadErrorMessage", errorMessages.toString());
return "settings/import-data :: file-upload-content";
}
try (InputStream inputStream = file.getInputStream()) {
- Map result = this.googleAndroidTimelineImporter.importTimeline(inputStream, user);
+ Map result = this.googleAndroidTimelineImporter.importTimeline(inputStream, user, device, file.getOriginalFilename());
if ((Boolean) result.get("success")) {
model.addAttribute("uploadSuccessMessage", result.get("message"));
@@ -177,22 +181,21 @@ public String importGoogleTimelineAndroid(@RequestParam("file") MultipartFile fi
@PostMapping("/google-timeline-ios")
public String importGoogleTimelineIOS(@RequestParam("file") MultipartFile file,
- Authentication authentication,
- Model model) {
+ @RequestParam("device") Long deviceId,
+ Authentication authentication,
+ Model model) {
User user = (User) authentication.getPrincipal();
+ Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
- if (file.isEmpty() || file.getOriginalFilename() == null) {
- model.addAttribute("uploadErrorMessage", "File is empty");
- return "settings/import-data :: file-upload-content";
- }
-
- if (!file.getOriginalFilename().endsWith(".json")) {
- model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
+ StringBuilder errorMessages = new StringBuilder();
+ if (!validateFile(file, errorMessages, "json")) {
+ model.addAttribute("uploadErrorMessage", errorMessages.toString());
return "settings/import-data :: file-upload-content";
}
try (InputStream inputStream = file.getInputStream()) {
- Map result = this.googleTimelineIOSImporter.importTimeline(inputStream, user);
+ Map result = this.googleTimelineIOSImporter.importTimeline(inputStream, user, device, file.getOriginalFilename());
if ((Boolean) result.get("success")) {
model.addAttribute("uploadSuccessMessage", result.get("message"));
@@ -209,9 +212,12 @@ public String importGoogleTimelineIOS(@RequestParam("file") MultipartFile file,
@PostMapping("/geojson")
public String importGeoJson(@RequestParam("files") MultipartFile[] files,
+ @RequestParam("device") Long deviceId,
Authentication authentication,
Model model) {
User user = (User) authentication.getPrincipal();
+ Device device = this.deviceJdbcService.find(user, deviceId).orElseThrow(IllegalArgumentException::new);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(user));
if (files.length == 0) {
model.addAttribute("uploadErrorMessage", "No files selected");
@@ -223,19 +229,14 @@ public String importGeoJson(@RequestParam("files") MultipartFile[] files,
StringBuilder errorMessages = new StringBuilder();
for (MultipartFile file : files) {
- if (file.isEmpty()) {
- errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
+ if (!validateFile(file, errorMessages, "json", "geojson")) {
+ model.addAttribute("uploadErrorMessage", errorMessages.toString());
continue;
}
String filename = file.getOriginalFilename();
- if (filename == null || (!filename.endsWith(".geojson") && !filename.endsWith(".json"))) {
- errorMessages.append("File ").append(filename).append(" is not a GeoJSON file. ");
- continue;
- }
-
try (InputStream inputStream = file.getInputStream()) {
- Map result = this.geoJsonImporter.importGeoJson(inputStream, user);
+ Map result = this.geoJsonImporter.importGeoJson(inputStream, user, device, filename);
if ((Boolean) result.get("success")) {
totalProcessed += (Integer) result.get("pointsReceived");
@@ -251,9 +252,9 @@ public String importGeoJson(@RequestParam("files") MultipartFile[] files,
}
if (successCount > 0) {
- String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points";
+ String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points.";
if (!errorMessages.isEmpty()) {
- message += ". Errors: " + errorMessages;
+ message += " Errors: " + errorMessages;
}
model.addAttribute("uploadSuccessMessage", message);
} else {
@@ -262,4 +263,26 @@ public String importGeoJson(@RequestParam("files") MultipartFile[] files,
return "settings/import-data :: file-upload-content";
}
+
+
+ private boolean validateFile(MultipartFile file, StringBuilder errorMessages, String... expectedExtension) {
+ if (file.isEmpty() || file.getOriginalFilename() == null) {
+ errorMessages.append(i18n.translate("upload.error.file.empty", file.getOriginalFilename()));
+ return false;
+ }
+
+ if (Arrays.stream(expectedExtension).noneMatch(ext -> file.getOriginalFilename().toLowerCase().endsWith(ext.toLowerCase()))) {
+ errorMessages.append(i18n.translate("upload.error.file.wrong_extension", file.getOriginalFilename(), String.join(",", expectedExtension)));
+ return false;
+ }
+
+ return true;
+ }
+
+ @ExceptionHandler(MaxUploadSizeExceededException.class)
+ @ResponseStatus(HttpStatus.OK)
+ public String handleMaxUploadSizeExceededException(Model model) {
+ model.addAttribute("uploadErrorMessage", i18n.translate("upload.error.max_upload_size_exceeded", maxFileSupported, maxFileSize));
+ return "settings/import-data :: file-upload-content";
+ }
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java
index 7ecf644d5..7aea0ca6e 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/GeoCodingSettingsController.java
@@ -10,10 +10,11 @@
import com.dedicatedcode.reitti.repository.SignificantPlaceOverrideJdbcService;
import com.dedicatedcode.reitti.repository.UserJdbcService;
import com.dedicatedcode.reitti.service.I18nService;
-import com.dedicatedcode.reitti.service.geocoding.GeocodeResult;
import com.dedicatedcode.reitti.service.geocoding.GeocodeService;
import com.dedicatedcode.reitti.service.geocoding.GeocodeServiceManager;
-import com.dedicatedcode.reitti.service.queue.RedisQueueService;
+import com.dedicatedcode.reitti.service.jobs.JobSchedulingService;
+import com.dedicatedcode.reitti.service.jobs.JobType;
+import com.github.kagkarlsson.scheduler.task.Task;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -26,7 +27,6 @@
import java.util.*;
import static com.dedicatedcode.reitti.model.geocoding.GeocoderType.GEO_APIFY;
-import static com.dedicatedcode.reitti.service.MessageDispatcherService.PLACE_CREATED_QUEUE;
@Controller
@RequestMapping("/settings/geocode-services")
@@ -37,7 +37,8 @@ public class GeoCodingSettingsController {
private final SignificantPlaceJdbcService placeJdbcService;
private final SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService;
private final UserJdbcService userJdbcService;
- private final RedisQueueService messageEnqueuer;
+ private final JobSchedulingService jobScheduler;
+ private final Task reverseGeocodingTask;
private final I18nService i18n;
private final boolean dataManagementEnabled;
private final int maxErrors;
@@ -49,7 +50,8 @@ public GeoCodingSettingsController(GeocodeServiceJdbcService geocodeServiceJdbcS
SignificantPlaceJdbcService placeJdbcService,
SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService,
UserJdbcService userJdbcService,
- RedisQueueService messageEnqueuer,
+ JobSchedulingService jobScheduler,
+ Task reverseGeocodingTask,
I18nService i18n,
@Value("${reitti.geocoding.photon.base-url:}") String photonBaseUrl,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
@@ -59,7 +61,8 @@ public GeoCodingSettingsController(GeocodeServiceJdbcService geocodeServiceJdbcS
this.placeJdbcService = placeJdbcService;
this.significantPlaceOverrideJdbcService = significantPlaceOverrideJdbcService;
this.userJdbcService = userJdbcService;
- this.messageEnqueuer = messageEnqueuer;
+ this.jobScheduler = jobScheduler;
+ this.reverseGeocodingTask = reverseGeocodingTask;
this.i18n = i18n;
this.dataManagementEnabled = dataManagementEnabled;
this.maxErrors = maxErrors;
@@ -120,7 +123,7 @@ public String testConfiguration(@RequestParam GeocoderType type,
Map result = geocodeServiceManager.test(tmpService, testLat, testLng);
model.addAttribute("testResult", result);
- }catch (Exception e) {
+ } catch (Exception e) {
model.addAttribute("testResult", Map.of("success", false, "message", e.getMessage()));
}
return "settings/fragments/geocoding :: test-result-display";
@@ -157,15 +160,15 @@ private GeocodeService verifySelection(GeocoderType type, String url, String api
@PostMapping
public String saveGeocodeService(@RequestParam(required = false) Long id,
- @RequestParam String name,
- @RequestParam(required = false) String url,
- @RequestParam GeocoderType type,
- @RequestParam(required = false) String apiKey,
- @RequestParam(required = false) String language,
- @RequestParam(required = false) Integer limit,
- @RequestParam(required = false) Double radius,
- @RequestParam int priority,
- Model model) {
+ @RequestParam String name,
+ @RequestParam(required = false) String url,
+ @RequestParam GeocoderType type,
+ @RequestParam(required = false) String apiKey,
+ @RequestParam(required = false) String language,
+ @RequestParam(required = false) Integer limit,
+ @RequestParam(required = false) Double radius,
+ @RequestParam int priority,
+ Model model) {
try {
Map params = new HashMap<>();
if (language != null && !language.isEmpty()) {
@@ -258,7 +261,11 @@ public String runGeocoding(Authentication authentication, Model model) {
place.getLongitudeCentroid(),
UUID.randomUUID().toString()
);
- messageEnqueuer.enqueue(PLACE_CREATED_QUEUE, event);
+ this.jobScheduler.enqueueTask(reverseGeocodingTask, event,
+ JobSchedulingService.Metadata.builder()
+ .user(currentUser)
+ .friendlyName("Manual reverse geocoding")
+ .jobType(JobType.REVERSE_GEOCODE).build());
}
model.addAttribute("successMessage", i18n.translate("geocoding.run.success", nonGeocodedPlaces.size()));
@@ -299,7 +306,11 @@ public String clearAndRerunGeocoding(Authentication authentication, Model model)
place.getLongitudeCentroid(),
UUID.randomUUID().toString()
);
- messageEnqueuer.enqueue(PLACE_CREATED_QUEUE, event);
+ this.jobScheduler.enqueueTask(reverseGeocodingTask, event,
+ JobSchedulingService.Metadata.builder()
+ .user(currentUser)
+ .friendlyName("Manual reverse geocoding")
+ .jobType(JobType.REVERSE_GEOCODE).build());
}
model.addAttribute("successMessage", i18n.translate("geocoding.clear.success", allPlaces.size()));
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java
index fdd563538..7351d69cd 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/IntegrationsSettingsController.java
@@ -2,11 +2,13 @@
import com.dedicatedcode.reitti.model.IntegrationTestResult;
import com.dedicatedcode.reitti.model.Role;
+import com.dedicatedcode.reitti.model.devices.Device;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
import com.dedicatedcode.reitti.model.integration.OwnTracksRecorderIntegration;
import com.dedicatedcode.reitti.model.security.ApiToken;
import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.DeviceJdbcService;
import com.dedicatedcode.reitti.repository.MqttIntegrationJdbcService;
import com.dedicatedcode.reitti.repository.OptimisticLockException;
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
@@ -36,6 +38,7 @@ public class IntegrationsSettingsController {
private final ContextPathHolder contextPathHolder;
private final ApiTokenService apiTokenService;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
+ private final DeviceJdbcService deviceJdbcService;
private final ImmichIntegrationService immichIntegrationService;
private final OwnTracksRecorderIntegrationService ownTracksRecorderIntegrationService;
private final DynamicMqttProvider mqttProvider;
@@ -45,7 +48,7 @@ public class IntegrationsSettingsController {
public IntegrationsSettingsController(ContextPathHolder contextPathHolder,
ApiTokenService apiTokenService,
- RawLocationPointJdbcService rawLocationPointJdbcService,
+ RawLocationPointJdbcService rawLocationPointJdbcService, DeviceJdbcService deviceJdbcService,
ImmichIntegrationService immichIntegrationService,
OwnTracksRecorderIntegrationService ownTracksRecorderIntegrationService,
DynamicMqttProvider mqttProvider,
@@ -55,6 +58,7 @@ public IntegrationsSettingsController(ContextPathHolder contextPathHolder,
this.contextPathHolder = contextPathHolder;
this.apiTokenService = apiTokenService;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
+ this.deviceJdbcService = deviceJdbcService;
this.immichIntegrationService = immichIntegrationService;
this.ownTracksRecorderIntegrationService = ownTracksRecorderIntegrationService;
this.mqttProvider = mqttProvider;
@@ -64,9 +68,7 @@ public IntegrationsSettingsController(ContextPathHolder contextPathHolder,
}
@GetMapping
- public String getPage(@AuthenticationPrincipal User user,
- HttpServletRequest request,
- Model model) {
+ public String getPage(@AuthenticationPrincipal User user, Model model) {
model.addAttribute("activeSection", "integrations");
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
@@ -79,7 +81,7 @@ public String getIntegrationsContent(@AuthenticationPrincipal User currentUser,
HttpServletRequest request,
Model model,
@RequestParam(required = false) String openSection) {
- List tokens = apiTokenService.getTokensForUser(currentUser);
+ List tokens = getUsableTokens(currentUser);
// Determine the token to use
String tokenToUse = null;
@@ -97,6 +99,7 @@ public String getIntegrationsContent(@AuthenticationPrincipal User currentUser,
}
model.addAttribute("selectedToken", tokenToUse);
+ model.addAttribute("devices", this.deviceJdbcService.getAll(currentUser).stream().toList());
model.addAttribute("tokens", tokens);
Optional recorderIntegration = ownTracksRecorderIntegrationService.getIntegrationForUser(currentUser);
@@ -130,6 +133,10 @@ public String getIntegrationsContent(@AuthenticationPrincipal User currentUser,
return "settings/fragments/integrations :: integrations-content";
}
+ private List getUsableTokens(User currentUser) {
+ return apiTokenService.getTokensForUser(currentUser).stream().filter(t -> t.getDevice() != null).toList();
+ }
+
private String calculateServerUrl(HttpServletRequest request) {
// Build the server URL
String scheme = request.getScheme();
@@ -213,10 +220,12 @@ public String saveOwnTracksRecorderIntegration(@RequestParam String baseUrl,
@RequestParam(defaultValue = "false") boolean enabled,
@RequestParam String authUsername,
@RequestParam String authPassword,
+ @RequestParam Long reittiDeviceId,
@AuthenticationPrincipal User currentUser,
RedirectAttributes redirectAttributes) {
try {
- ownTracksRecorderIntegrationService.saveIntegration(currentUser, baseUrl, username, authUsername, authPassword, deviceId, enabled);
+ Device device = this.deviceJdbcService.find(currentUser, reittiDeviceId).orElseThrow(() -> new IllegalArgumentException("Device not found"));
+ ownTracksRecorderIntegrationService.saveIntegration(currentUser, device, baseUrl, username, authUsername, authPassword, deviceId, enabled);
redirectAttributes.addFlashAttribute("successMessage", i18n.translate("integrations.owntracks.recorder.config.saved"));
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", i18n.translate("integrations.owntracks.recorder.config.error", e.getMessage()));
@@ -253,7 +262,7 @@ public Map testOwnTracksRecorderConnection(@RequestParam String
}
@PostMapping("/owntracks-recorder-integration/load-historical")
- public String loadOwnTracksRecorderHistoricalData(@AuthenticationPrincipal User currentUser, RedirectAttributes redirectAttributes, HttpServletRequest request) {
+ public String loadOwnTracksRecorderHistoricalData(@AuthenticationPrincipal User currentUser, RedirectAttributes redirectAttributes, HttpServletRequest request) {
try {
ownTracksRecorderIntegrationService.loadHistoricalData(currentUser);
redirectAttributes.addFlashAttribute("successMessage", i18n.translate("integrations.owntracks.recorder.load.historical.success"));
@@ -274,6 +283,7 @@ public String saveMqttIntegration(
@RequestParam(name = "mqtt_username", required = false) String username,
@RequestParam(name = "mqtt_password", required = false) String password,
@RequestParam(name = "mqtt_payloadType") PayloadType payloadType,
+ @RequestParam(name = "mqtt_deviceId") Long deviceId,
@RequestParam(name = "mqtt_enabled",defaultValue = "false") boolean enabled,
RedirectAttributes redirectAttributes) {
@@ -288,7 +298,11 @@ public String saveMqttIntegration(
if (port < 1 || port > 65535) {
redirectAttributes.addFlashAttribute("errorMessage", i18n.translate("integration.mqtt.error.port_range"));
return "redirect:/settings/integrations/integrations-content?openSection=mqtt";
+ }
+ if (this.deviceJdbcService.find(user, deviceId).isEmpty()) {
+ redirectAttributes.addFlashAttribute("errorMessage", i18n.translate("integration.mqtt.error.unknown_device"));
+ return "redirect:/settings/integrations/integrations-content?openSection=mqtt";
}
MqttIntegration mqttIntegration = this.mqttIntegrationJdbcService.findByUser(user).orElse(MqttIntegration.empty());
@@ -302,6 +316,7 @@ public String saveMqttIntegration(
.withUsername(username)
.withPassword(password)
.withPayloadType(payloadType)
+ .withDeviceId(deviceId)
.withEnabled(enabled);
mqttIntegrationJdbcService.save(user, updatedIntegration);
if (wasEnabled && !updatedIntegration.isEnabled()) {
@@ -374,6 +389,7 @@ public ResponseEntity> testMqttConnection(
null,
null,
null,
+ null,
null));
// Wait for the test result and handle it
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java
index 4e3a9221f..45f47c883 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/JobStatusController.java
@@ -1,26 +1,39 @@
package com.dedicatedcode.reitti.controller.settings;
-
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.security.User;
-import com.dedicatedcode.reitti.service.QueueStatsService;
+import com.dedicatedcode.reitti.repository.JobMetadataRepository;
+import com.dedicatedcode.reitti.service.jobs.JobInfo;
+import com.dedicatedcode.reitti.service.jobs.JobSchedulingService;
+import com.dedicatedcode.reitti.service.jobs.JobState;
+import com.dedicatedcode.reitti.service.jobs.JobType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.*;
+import java.util.stream.Collectors;
@Controller
@RequestMapping("/settings")
public class JobStatusController {
- private final QueueStatsService queueStatsService;
+
private final boolean dataManagementEnabled;
+ private final JobMetadataRepository jobMetadataRepository;
+ private final JobSchedulingService jobSchedulingService;
- public JobStatusController(QueueStatsService queueStatsService,
- @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
- this.queueStatsService = queueStatsService;
+ public JobStatusController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
+ JobMetadataRepository jobMetadataRepository,
+ JobSchedulingService jobSchedulingService) {
this.dataManagementEnabled = dataManagementEnabled;
+ this.jobMetadataRepository = jobMetadataRepository;
+ this.jobSchedulingService = jobSchedulingService;
}
@GetMapping("/job-status")
@@ -28,13 +41,214 @@ public String getJobStatus(@AuthenticationPrincipal User user, Model model) {
model.addAttribute("activeSection", "job-status");
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
- model.addAttribute("queueStats", queueStatsService.getQueueStats());
return "settings/job-status";
}
@GetMapping("/queue-stats-content")
- public String getQueueStatsContent(Model model) {
- model.addAttribute("queueStats", queueStatsService.getQueueStats());
+ public String getQueueStatsContent(@RequestParam(defaultValue = "UTC") ZoneId timezone, Model model) {
+ // Fetch all non-SSE jobs in active states
+ List activeJobs = filterNonSSE(
+ jobMetadataRepository.findByStates(
+ List.of(JobState.PREPARING, JobState.AWAITING, JobState.RUNNING)
+ )
+ );
+ // Fetch all non-SSE jobs in terminal states (completed/failed)
+ List terminalJobs = filterNonSSE(
+ jobMetadataRepository.findByStates(
+ List.of(JobState.COMPLETED, JobState.FAILED)
+ )
+ );
+
+ // Combine to get full picture of parents and children
+ List allJobs = new ArrayList<>(activeJobs);
+ allJobs.addAll(terminalJobs);
+
+ // Separate parent and child jobs
+ Map> partitioned = allJobs.stream()
+ .collect(Collectors.partitioningBy(job -> job.getParentJobId() == null));
+ List parentJobs = partitioned.get(true);
+ List childJobs = partitioned.get(false);
+
+ // Group children by parent ID
+ Map> childrenByParent = childJobs.stream()
+ .collect(Collectors.groupingBy(JobMetadataRepository.JobMetadata::getParentJobId));
+
+ // Separate parent jobs into pending and fully complete (past)
+ List pendingParents = new ArrayList<>();
+ List pastParents = new ArrayList<>();
+
+ for (JobMetadataRepository.JobMetadata parent : parentJobs) {
+ List children = childrenByParent.getOrDefault(parent.getId(), List.of());
+ boolean hasActiveChildren = children.stream()
+ .anyMatch(child -> !isTerminal(child.getState()));
+
+ if (!isTerminal(parent.getState()) || hasActiveChildren) {
+ pendingParents.add(parent);
+ } else {
+ pastParents.add(parent);
+ }
+ }
+
+ // Build pending job info (with children details)
+ Map averageRuntimes = calculateAverageRuntimes(pastParents);
+ List pendingJobs = pendingParents.stream()
+ .map(parent -> buildPendingJobInfo(timezone, parent, childrenByParent, averageRuntimes))
+ .collect(Collectors.toList());
+
+ // Build past job info (with duration)
+ List pastJobs = pastParents.stream()
+ .map(j -> mapToJobInfo(timezone, j))
+ .sorted(Comparator.comparing(JobInfo::finishedAt).reversed())
+ .limit(25)
+ .collect(Collectors.toList());
+
+ model.addAttribute("pendingJobs", pendingJobs);
+ model.addAttribute("pastJobs", pastJobs);
return "settings/job-status :: queue-stats-content";
}
-}
+
+ @DeleteMapping("/job/{id}")
+ public String cancelJob(@PathVariable UUID id,
+ @RequestParam(defaultValue = "UTC") ZoneId timezone,
+ Model model) {
+ jobSchedulingService.cancel(id);
+ // Re-fetch and render the current status
+ return getQueueStatsContent(timezone, model);
+ }
+
+ private boolean isTerminal(JobState state) {
+ return state == JobState.COMPLETED || state == JobState.FAILED;
+ }
+
+ private List filterNonSSE(List jobs) {
+ return jobs.stream()
+ .filter(job -> job.getJobType() != JobType.SSE_EVENT)
+ .toList();
+ }
+
+ private JobInfo buildPendingJobInfo(ZoneId timezone, JobMetadataRepository.JobMetadata parent,
+ Map> childrenByParent,
+ Map averageRuntimes) {
+ List childrenMeta = childrenByParent.getOrDefault(parent.getId(), List.of());
+ List children = childrenMeta.stream()
+ .map(j -> mapToJobInfo(timezone, j))
+ .toList();
+ long completedChildren = children.stream()
+ .filter(j -> isTerminal(j.state()))
+ .count();
+ long totalChildren = children.size();
+
+ // Basic info from the parent itself
+ JobInfo base = mapToJobInfo(timezone, parent);
+
+ // Average runtime estimate
+ AverageRuntime avgRuntime = averageRuntimes.get(parent.getJobType());
+ Long estimatedDuration = avgRuntime != null ? avgRuntime.getEstimatedSeconds() : null;
+
+ return new JobInfo(
+ base.id(),
+ base.name(),
+ base.description(),
+ base.state(),
+ base.enqueuedAt(),
+ base.scheduledAt(),
+ base.processingAt(),
+ base.finishedAt(),
+ base.canCancel(),
+ children,
+ completedChildren,
+ totalChildren,
+ estimatedDuration,
+ 0, // no progress for parent grouping
+ null
+ );
+ }
+
+ private Map calculateAverageRuntimes(List fullyCompleteParents) {
+ Map> durationsByType = new HashMap<>();
+ for (JobMetadataRepository.JobMetadata job : fullyCompleteParents) {
+ if (job.getFinishedAt() != null && job.getEnqueuedAt() != null) {
+ long durationSeconds = Duration.between(job.getEnqueuedAt(), job.getFinishedAt()).getSeconds();
+ if (durationSeconds > 0) {
+ durationsByType.computeIfAbsent(job.getJobType(), k -> new ArrayList<>()).add(durationSeconds);
+ }
+ }
+ }
+ Map result = new HashMap<>();
+ for (Map.Entry> entry : durationsByType.entrySet()) {
+ List durations = entry.getValue();
+ if (!durations.isEmpty()) {
+ long average = durations.stream().mapToLong(Long::longValue).sum() / durations.size();
+ result.put(entry.getKey(), new AverageRuntime(average, durations.size()));
+ }
+ }
+ return result;
+ }
+
+ private JobInfo mapToJobInfo(ZoneId timezone, JobMetadataRepository.JobMetadata metadata) {
+ JobState state = metadata.getState();
+ String jobName = metadata.getFriendlyName();
+ String jobDescription = String.format("User ID: %s, Type: %s", metadata.getUserId(), metadata.getJobType());
+ boolean canCancel = state == JobState.AWAITING;
+
+ return new JobInfo(
+ metadata.getId(),
+ jobName,
+ jobDescription,
+ state,
+ toLocalDateTime(metadata.getEnqueuedAt(), timezone),
+ toLocalDateTime(metadata.getScheduledAt(), timezone),
+ toLocalDateTime(metadata.getProcessingAt(), timezone),
+ toLocalDateTime(metadata.getFinishedAt(), timezone),
+ canCancel,
+ List.of(),
+ 0,
+ 0,
+ null,
+ progressPercent(metadata),
+ metadata.getProgressMessage()
+ );
+ }
+
+ private LocalDateTime toLocalDateTime(Instant instant, ZoneId timezone) {
+ if (instant == null || timezone == null) {
+ return null;
+ } else {
+ return instant.atZone(timezone).toLocalDateTime();
+ }
+ }
+ private float progressPercent(JobMetadataRepository.JobMetadata metadata) {
+ if (metadata.getMaxProgress() == null || metadata.getCurrentProgress() == null || metadata.getMaxProgress() == 0) return 0f;
+ return ((float) metadata.getCurrentProgress() / metadata.getMaxProgress()) * 100f;
+ }
+
+ public record AverageRuntime(long averageSeconds, int sampleCount) {
+ public String formattedDuration() {
+ long hours = averageSeconds / 3600;
+ long minutes = (averageSeconds % 3600) / 60;
+ long seconds = averageSeconds % 60;
+ if (hours > 0) {
+ return String.format("%dh %dm %ds", hours, minutes, seconds);
+ } else if (minutes > 0) {
+ return String.format("%dm %ds", minutes, seconds);
+ } else {
+ return String.format("%ds", seconds);
+ }
+ }
+
+ public long getEstimatedSeconds() {
+ return (long) (averageSeconds * 1.2);
+ }
+
+ public String getEstimatedDuration() {
+ long estimated = getEstimatedSeconds();
+ long hours = estimated / 3600;
+ long minutes = (estimated % 3600) / 60;
+ if (hours > 0) {
+ return String.format("%dh %dm", hours, minutes);
+ } else {
+ return String.format("%d min", minutes);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java
index 72f8f46a8..b2bf02107 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/ManageDataController.java
@@ -1,10 +1,14 @@
package com.dedicatedcode.reitti.controller.settings;
+import com.dedicatedcode.reitti.event.TriggerProcessingEvent;
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.*;
import com.dedicatedcode.reitti.service.I18nService;
-import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger;
+import com.dedicatedcode.reitti.service.jobs.JobSchedulingService;
+import com.dedicatedcode.reitti.service.jobs.JobType;
+import com.github.kagkarlsson.scheduler.task.Task;
+import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
@@ -13,7 +17,7 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
-import jakarta.servlet.http.HttpServletRequest;
+import java.util.UUID;
@Controller
public class ManageDataController {
@@ -22,27 +26,32 @@ public class ManageDataController {
private final boolean deleteAllHostnameVerificationEnabled;
private final TripJdbcService tripJdbcService;
private final ProcessedVisitJdbcService processedVisitJdbcService;
- private final ProcessingPipelineTrigger processingPipelineTrigger;
+ private final Task processingTask;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
private final UserSettingsJdbcService userSettingsJdbcService;
private final I18nService i18n;
+ private final JobSchedulingService jobScheduler;
+ private final SourceLocationPointJdbcService sourceLocationPointJdbcService;
public ManageDataController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
@Value("${reitti.data-management.delete-all.hostname-verification.enabled:true}") boolean deleteAllHostnameVerificationEnabled,
TripJdbcService tripJdbcService,
ProcessedVisitJdbcService processedVisitJdbcService,
- ProcessingPipelineTrigger processingPipelineTrigger,
+ Task processingTask,
RawLocationPointJdbcService rawLocationPointJdbcService,
UserSettingsJdbcService userSettingsJdbcService,
- I18nService i18nService) {
+ I18nService i18nService,
+ JobSchedulingService jobScheduler, SourceLocationPointJdbcService sourceLocationPointJdbcService) {
this.dataManagementEnabled = dataManagementEnabled;
this.deleteAllHostnameVerificationEnabled = deleteAllHostnameVerificationEnabled;
this.tripJdbcService = tripJdbcService;
this.processedVisitJdbcService = processedVisitJdbcService;
- this.processingPipelineTrigger = processingPipelineTrigger;
+ this.processingTask = processingTask;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
this.userSettingsJdbcService = userSettingsJdbcService;
this.i18n = i18nService;
+ this.jobScheduler = jobScheduler;
+ this.sourceLocationPointJdbcService = sourceLocationPointJdbcService;
}
@GetMapping("/settings/manage-data")
@@ -77,13 +86,17 @@ public String getManageDataContent(HttpServletRequest request, Model model) {
}
@PostMapping("/settings/manage-data/process-visits-trips")
- public String processVisitsTrips(Model model) {
+ public String processVisitsTrips(@AuthenticationPrincipal User user, Model model) {
if (!dataManagementEnabled) {
throw new RuntimeException("Data management is not enabled");
}
try {
- processingPipelineTrigger.start();
+ jobScheduler.enqueueTask(processingTask, new TriggerProcessingEvent(user.getUsername(), null, null),
+ JobSchedulingService.Metadata.builder()
+ .user(user)
+ .friendlyName("Manual processing")
+ .jobType(JobType.LOCATION_PROCESSING).build());
model.addAttribute("successMessage", i18n.translate("data.process.success"));
} catch (Exception e) {
model.addAttribute("errorMessage", i18n.translate("data.process.error", e.getMessage()));
@@ -101,7 +114,11 @@ public String clearAndReprocess(@AuthenticationPrincipal User user, Model model)
try {
clearProcessedDataExceptPlaces(user);
markRawLocationPointsAsUnprocessed(user);
- processingPipelineTrigger.start();
+ this.jobScheduler.enqueueTask(processingTask, new TriggerProcessingEvent(user.getUsername(), null, null),
+ JobSchedulingService.Metadata.builder()
+ .user(user)
+ .friendlyName("Manual processing")
+ .jobType(JobType.LOCATION_PROCESSING).build());
model.addAttribute("successMessage", i18n.translate("data.clear.reprocess.success"));
} catch (Exception e) {
model.addAttribute("errorMessage", i18n.translate("data.clear.reprocess.error", e.getMessage()));
@@ -158,6 +175,7 @@ private void removeAllDataExceptPlaces(User user) {
tripJdbcService.deleteAllForUser(user);
processedVisitJdbcService.deleteAllForUser(user);
rawLocationPointJdbcService.deleteAllForUser(user);
+ sourceLocationPointJdbcService.deleteAllForUser(user);
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java
new file mode 100644
index 000000000..ec27d6967
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/MapStylesSettingsController.java
@@ -0,0 +1,233 @@
+package com.dedicatedcode.reitti.controller.settings;
+
+import com.dedicatedcode.reitti.dto.map.MapStyleConfigDTO;
+import com.dedicatedcode.reitti.model.Role;
+import com.dedicatedcode.reitti.model.map.MapStyleDataSource;
+import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions;
+import com.dedicatedcode.reitti.model.map.UserMapStyle;
+import com.dedicatedcode.reitti.model.security.User;
+import com.dedicatedcode.reitti.repository.UserMapStyleJdbcService;
+import com.dedicatedcode.reitti.service.I18nService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Controller
+@RequestMapping("/settings/map-styles")
+public class MapStylesSettingsController {
+ private final boolean dataManagementEnabled;
+ private final UserMapStyleJdbcService userMapStyleJdbcService;
+ private final I18nService i18n;
+ private final ObjectMapper objectMapper;
+
+ public MapStylesSettingsController(
+ @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
+ UserMapStyleJdbcService userMapStyleJdbcService,
+ I18nService i18n, ObjectMapper objectMapper) {
+ this.dataManagementEnabled = dataManagementEnabled;
+ this.userMapStyleJdbcService = userMapStyleJdbcService;
+ this.i18n = i18n;
+ this.objectMapper = objectMapper;
+ }
+
+ @GetMapping
+ public String getPage(@AuthenticationPrincipal User user, Model model) {
+ model.addAttribute("activeSection", "map-styles");
+ model.addAttribute("dataManagementEnabled", dataManagementEnabled);
+ model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
+
+ List persisted = userMapStyleJdbcService.findAll(user).stream().map(s -> s.toDto(user)).toList();
+ model.addAttribute("styles", persisted);
+ model.addAttribute("activeMapStyleId", userMapStyleJdbcService.getActiveStyleId(user));
+ return "settings/map-styles";
+ }
+
+ @GetMapping("/clear")
+ public String clearStyles() {
+ return "settings/map-styles :: empty-form-state";
+ }
+
+ @GetMapping("/form")
+ public String addFragment(@AuthenticationPrincipal User user,
+ @RequestParam(required = false) Long id,
+ Model model) {
+ if (id != null) {
+ model.addAttribute("style", userMapStyleJdbcService.findById(user, id).map(s -> s.toDto(user)).orElseThrow(() -> new IllegalArgumentException("Unknown style id: " + id)));
+ }
+ model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
+
+ return "settings/fragments/map-styles :: style-form";
+ }
+
+ @PostMapping("/activate")
+ public String activateStyle(@AuthenticationPrincipal User user, @RequestParam Long id, Model model) {
+ if (this.userMapStyleJdbcService.findById(user, id).isEmpty()) {
+ throw new IllegalStateException("Not allowed to use style with id [" + id + "]");
+ }
+ this.userMapStyleJdbcService.setActiveStyleId(user, id);
+
+ List persisted = userMapStyleJdbcService.findAll(user).stream().map(s -> s.toDto(user)).toList();
+ model.addAttribute("styles", persisted);
+ model.addAttribute("activeMapStyleId", userMapStyleJdbcService.getActiveStyleId(user));
+ model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
+
+ return "settings/map-styles :: styles-table";
+ }
+
+ @PostMapping
+ public String saveMapStyle(@AuthenticationPrincipal User user, @RequestParam Map params, Model model, HttpServletResponse response) {
+ Long id = params.get("id") != null ? Long.parseLong(params.get("id")) : null;
+ if (id != null && this.userMapStyleJdbcService.findById(user, id).isEmpty()) {
+ throw new IllegalStateException("Not allowed to use style with id [" + id + "]");
+ }
+ String mapType = params.get("mapType");
+ String name = params.get("name");
+
+ List errors = new ArrayList<>();
+
+ if (name == null || name.isBlank()) {
+ errors.add(i18n.translate("map.settings.dialog.map-styles.error-name-required"));
+ }
+
+ if ("vector".equals(mapType)) {
+ String styleInputType = params.get("styleInputType");
+ if ("url".equals(styleInputType)) {
+ String url = params.get("vectorStyleUrl");
+ if (url == null || url.isBlank()) {
+ errors.add(i18n.translate("map.settings.dialog.map-styles.error-style-url-required"));
+ }
+ } else if ("json".equals(styleInputType)) {
+ String json = params.get("vectorStyleJson");
+ if (json == null || json.isBlank()) {
+ errors.add(i18n.translate("map.settings.dialog.map-styles.error-style-json-required"));
+ }
+ try {
+ objectMapper.readTree(json);
+ } catch (Exception e) {
+ errors.add(i18n.translate("map.settings.dialog.map-styles.error-json"));
+ }
+ }
+ } else if ("raster".equals(mapType)) {
+ String rasterSourceInputType = params.get("rasterSourceInputType");
+ if ("url-template".equals(rasterSourceInputType)) {
+ String template = params.get("rasterTileTemplate");
+ if (template == null || template.isBlank()) {
+ errors.add(i18n.translate("js.map.settings.dialog.map-styles.error-tile-template-required"));
+ }
+ } else if ("json-url".equals(rasterSourceInputType)) {
+ String tileJsonUrl = params.get("rasterTileJsonUrl");
+ if (tileJsonUrl == null || tileJsonUrl.isBlank()) {
+ errors.add(i18n.translate("js.map.settings.dialog.map-styles.error-tilejson-required"));
+ }
+ }
+ }
+
+ if (!errors.isEmpty()) {
+ model.addAttribute("error", String.join(" ", errors));
+ response.setHeader("HX-Retarget", "#errors");
+ return "settings/fragments/map-styles :: errors";
+ }
+
+ UserMapStyle mapStyle = buildFromParams(user, params);
+ userMapStyleJdbcService.save(user, mapStyle);
+
+ return getPage(user, model);
+ }
+
+ private UserMapStyle buildFromParams(User user, Map params) {
+ String id = params.get("id");
+ String name = params.get("name");
+ String mapType = params.get("mapType");
+ String styleInputType = params.get("styleInputType");
+ String rasterSourceInputType = params.get("rasterSourceInputType");
+ String vectorStyleUrl = params.get("vectorStyleUrl");
+ String vectorStyleJson = params.get("vectorStyleJson");
+ String rasterTileTemplate = params.get("rasterTileTemplate");
+ String rasterTileJsonUrl = params.get("rasterTileJsonUrl");
+ String attributionOverride = params.get("attributionOverride");
+ String glyphsUrlOverride = params.get("glyphsUrlOverride");
+ String spriteUrlOverride = params.get("spriteUrlOverride");
+ String minzoom = params.get("minzoom");
+ String maxzoom = params.get("maxzoom");
+ String tileSize = params.get("tileSize");
+ String scheme = params.get("scheme");
+ boolean proxyTiles = "on".equals(params.get("proxyTiles"));
+ boolean shared = "on".equals(params.get("shared"));
+
+ // Build the data source
+ MapStyleDataSource dataSource = new MapStyleDataSource(
+ null,
+ mapType,
+ rasterTileJsonUrl,
+ rasterTileTemplate,
+ attributionOverride,
+ hasValue(minzoom) ? Integer.parseInt(minzoom) : null,
+ hasValue(maxzoom) ? Integer.parseInt(maxzoom) : null,
+ hasValue(tileSize) ? Integer.parseInt(tileSize) : null,
+ scheme,
+ proxyTiles
+ );
+
+ // Build vector options
+ MapStyleVectorOptions vectorOptions = new MapStyleVectorOptions(
+ attributionOverride,
+ glyphsUrlOverride,
+ spriteUrlOverride
+ );
+
+ // Determine the style URL and input based on map type
+ String styleUrl = null;
+ String styleInput = null;
+
+ if ("vector".equals(mapType)) {
+ if ("url".equals(styleInputType)) {
+ styleUrl = vectorStyleUrl;
+ } else {
+ styleInput = vectorStyleJson;
+ }
+ }
+
+ return new UserMapStyle(
+ id != null ? Long.parseLong(id) : null,
+ user.getId(),
+ name,
+ mapType,
+ styleInputType,
+ rasterSourceInputType,
+ styleInput,
+ styleUrl,
+ dataSource,
+ vectorOptions,
+ false,
+ shared,
+ null);
+ }
+
+ @DeleteMapping
+ public String deleteMapStyle(@AuthenticationPrincipal User user, @RequestParam Long id, Model model) {
+ if (this.userMapStyleJdbcService.findById(user, id).isEmpty()) {
+ throw new IllegalStateException("Not allowed to use style with id [" + id + "]");
+ }
+
+ this.userMapStyleJdbcService.delete(id);
+ return getPage(user, model);
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity handleInvalidMapStyle(IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(e.getMessage());
+ }
+
+ private boolean hasValue(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java
index 66a2ed8dd..e6d5be006 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/PlacesSettingsController.java
@@ -1,7 +1,6 @@
package com.dedicatedcode.reitti.controller.settings;
import com.dedicatedcode.reitti.dto.PlaceInfo;
-import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
import com.dedicatedcode.reitti.model.AvailableCountry;
import com.dedicatedcode.reitti.model.Page;
import com.dedicatedcode.reitti.model.PageRequest;
@@ -13,7 +12,6 @@
import com.dedicatedcode.reitti.model.geocoding.GeocodingResponse;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.GeocodingResponseJdbcService;
-import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
import com.dedicatedcode.reitti.repository.SignificantPlaceJdbcService;
import com.dedicatedcode.reitti.repository.SignificantPlaceOverrideJdbcService;
import com.dedicatedcode.reitti.service.DataCleanupService;
@@ -22,9 +20,11 @@
import com.dedicatedcode.reitti.service.PlaceService;
import com.dedicatedcode.reitti.service.geocoding.GeocodeResult;
import com.dedicatedcode.reitti.service.geocoding.GeocodeServiceManager;
-import com.dedicatedcode.reitti.service.queue.RedisQueueService;
+import com.dedicatedcode.reitti.service.jobs.JobSchedulingService;
+import com.dedicatedcode.reitti.service.jobs.JobType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.kagkarlsson.scheduler.task.Task;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
@@ -40,54 +40,49 @@
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
-import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
-import java.util.UUID;
import java.util.stream.Collectors;
-import static com.dedicatedcode.reitti.service.MessageDispatcherService.PLACE_CREATED_QUEUE;
-
@Controller
@RequestMapping("/settings/places")
public class PlacesSettingsController {
private static final Logger log = LoggerFactory.getLogger(PlacesSettingsController.class);
private final PlaceService placeService;
private final SignificantPlaceJdbcService placeJdbcService;
- private final ProcessedVisitJdbcService processedVisitJdbcService;
private final SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService;
private final GeocodingResponseJdbcService geocodingResponseJdbcService;
private final GeocodeServiceManager geocodeServiceManager;
private final GeometryFactory geometryFactory;
private final I18nService i18nService;
private final PlaceChangeDetectionService placeChangeDetectionService;
- private final DataCleanupService dataCleanupService;
+ private final JobSchedulingService jobSchedulingService;
+ private final Task cleanupTask;
private final boolean dataManagementEnabled;
private final ObjectMapper objectMapper;
public PlacesSettingsController(PlaceService placeService,
SignificantPlaceJdbcService placeJdbcService,
- ProcessedVisitJdbcService processedVisitJdbcService,
SignificantPlaceOverrideJdbcService significantPlaceOverrideJdbcService,
GeocodingResponseJdbcService geocodingResponseJdbcService,
GeocodeServiceManager geocodeServiceManager,
GeometryFactory geometryFactory,
I18nService i18nService,
- PlaceChangeDetectionService placeChangeDetectionService,
- DataCleanupService dataCleanupService,
+ PlaceChangeDetectionService placeChangeDetectionService, JobSchedulingService jobSchedulingService,
+ Task cleanupTask,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
ObjectMapper objectMapper) {
this.placeService = placeService;
this.placeJdbcService = placeJdbcService;
- this.processedVisitJdbcService = processedVisitJdbcService;
this.significantPlaceOverrideJdbcService = significantPlaceOverrideJdbcService;
this.geocodingResponseJdbcService = geocodingResponseJdbcService;
this.geocodeServiceManager = geocodeServiceManager;
this.geometryFactory = geometryFactory;
this.i18nService = i18nService;
this.placeChangeDetectionService = placeChangeDetectionService;
- this.dataCleanupService = dataCleanupService;
+ this.jobSchedulingService = jobSchedulingService;
+ this.cleanupTask = cleanupTask;
this.dataManagementEnabled = dataManagementEnabled;
this.objectMapper = objectMapper;
}
@@ -207,11 +202,13 @@ public String updatePlace(@PathVariable Long placeId,
placeJdbcService.update(updatedPlace);
log.info("Significant change detected for place [{}]. Will issue a recalculation of all affected dates", significantPlace);
- List placesToRemove = placeJdbcService.findPlacesOverlappingWithPolygon(user.getId(), placeId, updatedPlace.getPolygon());
- List placesToCheck = new ArrayList<>(placesToRemove);
- placesToCheck.add(updatedPlace);
- List affectedDays = this.processedVisitJdbcService.getAffectedDays(placesToCheck);
- this.dataCleanupService.cleanupForGeometryChange(user, placesToRemove, affectedDays);
+ this.jobSchedulingService.enqueueTask(cleanupTask, new DataCleanupService.TaskData(user, updatedPlace),
+ JobSchedulingService.Metadata.builder()
+ .user(user)
+ .friendlyName("Update Polygon of " + significantPlace.getName())
+ .jobType(JobType.DATA_RECALCULATION)
+ .build());
+
} else {
placeJdbcService.update(updatedPlace);
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java
index 2dee29012..e79c4cad7 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsController.java
@@ -1,12 +1,6 @@
package com.dedicatedcode.reitti.controller.settings;
-import com.dedicatedcode.reitti.model.Role;
-import com.dedicatedcode.reitti.model.security.User;
-import com.dedicatedcode.reitti.service.QueueStatsService;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
-import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java
index 174c10d46..91d592fb2 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/SettingsVisitSensitivityController.java
@@ -1,13 +1,17 @@
package com.dedicatedcode.reitti.controller.settings;
import com.dedicatedcode.reitti.dto.ConfigurationForm;
+import com.dedicatedcode.reitti.event.TriggerProcessingEvent;
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.processing.DetectionParameter;
import com.dedicatedcode.reitti.model.processing.RecalculationState;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.repository.*;
import com.dedicatedcode.reitti.service.VisitDetectionPreviewService;
-import com.dedicatedcode.reitti.service.processing.ProcessingPipelineTrigger;
+import com.dedicatedcode.reitti.service.jobs.VisitSensitivityConfigurationRecalculationTask;
+import com.dedicatedcode.reitti.service.jobs.JobSchedulingService;
+import com.dedicatedcode.reitti.service.jobs.JobType;
+import com.github.kagkarlsson.scheduler.task.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -25,7 +29,7 @@
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
-import java.util.concurrent.CompletableFuture;
+import java.util.UUID;
@Controller
@RequestMapping("/settings/visit-sensitivity")
@@ -34,30 +38,27 @@ public class SettingsVisitSensitivityController {
private static final Logger log = LoggerFactory.getLogger(SettingsVisitSensitivityController.class);
private final VisitDetectionParametersJdbcService configurationService;
private final VisitDetectionPreviewService visitDetectionPreviewService;
- private final ProcessingPipelineTrigger processingPipelineTrigger;
- private final TripJdbcService tripJdbcService;
- private final ProcessedVisitJdbcService processedVisitJdbcService;
+
private final MessageSource messageSource;
private final boolean dataManagementEnabled;
+ private final JobSchedulingService jobScheduler;
private final RawLocationPointJdbcService rawLocationPointJdbcService;
- private final SignificantPlaceJdbcService significantPlaceJdbcService;
+ private final Task recalculationJobTask;
public SettingsVisitSensitivityController(VisitDetectionParametersJdbcService configurationService,
VisitDetectionPreviewService visitDetectionPreviewService,
- ProcessingPipelineTrigger processingPipelineTrigger,
- TripJdbcService tripJdbcService,
- ProcessedVisitJdbcService processedVisitJdbcService,
MessageSource messageSource,
- @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, RawLocationPointJdbcService rawLocationPointJdbcService, SignificantPlaceJdbcService significantPlaceJdbcService) {
+ @Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
+ JobSchedulingService jobScheduler,
+ RawLocationPointJdbcService rawLocationPointJdbcService,
+ Task recalculationJobTask) {
this.configurationService = configurationService;
this.visitDetectionPreviewService = visitDetectionPreviewService;
- this.processingPipelineTrigger = processingPipelineTrigger;
- this.tripJdbcService = tripJdbcService;
- this.processedVisitJdbcService = processedVisitJdbcService;
this.messageSource = messageSource;
this.dataManagementEnabled = dataManagementEnabled;
+ this.jobScheduler = jobScheduler;
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
- this.significantPlaceJdbcService = significantPlaceJdbcService;
+ this.recalculationJobTask = recalculationJobTask;
}
@GetMapping
@@ -263,20 +264,15 @@ private void clearTimeRange(User user) {
}
needsRecalculation.forEach(dp -> this.configurationService.updateConfiguration(dp.withRecalculationState(RecalculationState.RUNNING)));
- CompletableFuture.runAsync(() -> {
- log.debug("Clearing all time range");
- try {
- tripJdbcService.deleteAllForUser(user);
- processedVisitJdbcService.deleteAllForUser(user);
- significantPlaceJdbcService.deleteForUser(user);
- rawLocationPointJdbcService.markAllAsUnprocessedForUser(user);
- allConfigurationsForUser.forEach(config -> this.configurationService.updateConfiguration(config.withRecalculationState(RecalculationState.DONE)));
- log.debug("Starting recalculation of all configurations");
- processingPipelineTrigger.start(user);
- } catch (Exception e) {
- log.error("Error clearing time range", e);
- }
- });
+
+ log.debug("Scheduling recalculation task");
+ this.jobScheduler.enqueueTask(recalculationJobTask,
+ new VisitSensitivityConfigurationRecalculationTask.TaskData(user),
+ JobSchedulingService.Metadata.builder()
+ .user(user)
+ .friendlyName("Recalculation for changed VisitSensitivity settings")
+ .jobType(JobType.DATA_RECALCULATION).build());
+
log.debug("Recalculation of all configurations triggered");
}
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java
index f1f792939..d4f6829f8 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/UserSettingsController.java
@@ -123,7 +123,6 @@ private String getUserContent(Model model, User currentUser) {
model.addAttribute("availableLanguages", Language.values());
model.addAttribute("selectedLanguage", userSettings.getSelectedLanguage());
model.addAttribute("selectedUnitSystem", userSettings.getUnitSystem().name());
- model.addAttribute("preferColoredMap", userSettings.isPreferColoredMap());
model.addAttribute("homeLatitude", userSettings.getHomeLatitude());
model.addAttribute("homeLongitude", userSettings.getHomeLongitude());
model.addAttribute("unitSystems", UnitSystem.values());
@@ -193,7 +192,6 @@ public String createUser(@RequestParam(required = false) String username,
@RequestParam(defaultValue = "USER") Role role,
@RequestParam(name = "preferred_language") Language preferredLanguage,
@RequestParam(name = "unit_system", defaultValue = "METRIC") String unitSystem,
- @RequestParam(defaultValue = "false") boolean preferColoredMap,
@RequestParam(required = false) Double homeLatitude,
@RequestParam(required = false) Double homeLongitude,
@RequestParam(name = "timezone_override", required = false) String timezoneOverride,
@@ -222,7 +220,6 @@ public String createUser(@RequestParam(required = false) String username,
displayName,
password,
role, UnitSystem.valueOf(unitSystem),
- preferColoredMap,
preferredLanguage,
homeLatitude,
homeLongitude,
@@ -246,7 +243,6 @@ public String createUser(@RequestParam(required = false) String username,
.orElse(UserSettings.defaultSettings(createdUser.getId()));
UserSettings updatedSettings = new UserSettings(
existingSettings.getUserId(),
- existingSettings.isPreferColoredMap(),
existingSettings.getSelectedLanguage(),
existingSettings.getUnitSystem(),
existingSettings.getHomeLatitude(),
@@ -289,7 +285,6 @@ public String updateUser(@RequestParam Long userId,
@RequestParam(defaultValue = "USER") Role role,
@RequestParam Language preferred_language,
@RequestParam(defaultValue = "METRIC") String unit_system,
- @RequestParam(defaultValue = "false") boolean preferColoredMap,
@RequestParam(required = false) Double homeLatitude,
@RequestParam(required = false) Double homeLongitude,
@RequestParam(required = false) MultipartFile avatar,
@@ -353,7 +348,6 @@ public String updateUser(@RequestParam Long userId,
UnitSystem unitSystem = UnitSystem.valueOf(unit_system);
UserSettings updatedSettings = new UserSettings(userId,
- preferColoredMap,
preferred_language,
unitSystem,
homeLatitude,
@@ -441,7 +435,6 @@ public String getUserForm(@RequestParam(required = false) Long userId,
UserSettings userSettings = userSettingsJdbcService.findByUserId(userId).orElse(UserSettings.defaultSettings(userId));
model.addAttribute("selectedLanguage", userSettings.getSelectedLanguage());
model.addAttribute("selectedUnitSystem", userSettings.getUnitSystem().name());
- model.addAttribute("preferColoredMap", userSettings.isPreferColoredMap());
model.addAttribute("homeLatitude", userSettings.getHomeLatitude());
model.addAttribute("homeLongitude", userSettings.getHomeLongitude());
model.addAttribute("timeZoneOverride", userSettings.getTimeZoneOverride());
@@ -452,7 +445,6 @@ public String getUserForm(@RequestParam(required = false) Long userId,
// Default values for new users
model.addAttribute("selectedLanguage", Language.EN);
model.addAttribute("selectedUnitSystem", "METRIC");
- model.addAttribute("preferColoredMap", false);
model.addAttribute("selectedRole", "USER");
model.addAttribute("homeLatitude", null);
model.addAttribute("homeLongitude", null);
@@ -479,11 +471,9 @@ public String getUserForm(@RequestParam(required = false) Long userId,
boolean hasCustomCss = userSettings != null && StringUtils.hasText(userSettings.getCustomCss());
model.addAttribute("hasCustomCss", hasCustomCss);
}
-
- // Add default avatars to model
+
model.addAttribute("defaultAvatars", DEFAULT_AVATARS);
-
- // Add admin status to model
+
model.addAttribute("isAdmin", ADMIN == currentUser.getRole());
return "fragments/user-management :: user-form-page";
diff --git a/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java b/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java
index a613f62ca..f18e35102 100644
--- a/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java
+++ b/src/main/java/com/dedicatedcode/reitti/controller/translations/I18NController.java
@@ -42,50 +42,97 @@ public ResponseEntity> getMessages(Locale locale, @RequestParam("v") String ve
// The logic is bundled with the data
String script = """
- (function() {
- window.I18N = window.I18N || {};
- // Merge new messages into the existing global object
- Object.assign(window.I18N, %s);
-
- if (!window.t) {
- window.t = function(key, params = []) {
- const internalKey = "js." + key; // Automatically adds the prefix
- if (!window.I18N || !(internalKey in window.I18N)) {
- console.error('I18N Error: Key [' + internalKey + '] not found.');
- return '??' + internalKey + '??';
- }
-
- let msg = window.I18N[internalKey];
-
- // 1. Handle ChoiceFormat (Plurals)
- const choiceRegex = /\\{(\\d+),choice,([^}]+)\\}/g;
- msg = msg.replace(choiceRegex, (match, paramIndex, choicesStr) => {
- const value = parseFloat(params[paramIndex]);
- if (isNaN(value)) return match;
- const choices = choicesStr.split('|');
- let selectedChoice = "";
- for (const choice of choices) {
- const separatorIndex = choice.search(/[#<]/);
- const limit = parseFloat(choice.substring(0, separatorIndex));
- const separator = choice.charAt(separatorIndex);
- const text = choice.substring(separatorIndex + 1);
- if ((separator === '#' && value >= limit) || (separator === '<' && value > limit)) {
- selectedChoice = text;
- }
+ (function() {
+ window.I18N = window.I18N || {};
+ Object.assign(window.I18N, %s);
+
+ if (!window.t) {
+ window.t = function(key, params = []) {
+ const internalKey = "js." + key;
+ if (!window.I18N || !(internalKey in window.I18N)) {
+ console.error('I18N Error: Key [' + internalKey + '] not found.');
+ return '??' + internalKey + '??';
+ }
+
+ let msg = window.I18N[internalKey];
+
+ // Helper: replace standard {N} and {N,number(,integer)?} placeholders
+ const applyParams = (str) => {
+ params.forEach((param, index) => {
+ str = str.replace(
+ new RegExp('\\\\{' + index + '(?:,number(?:,integer)?)?\\\\}', 'g'),
+ param
+ );
+ });
+ return str;
+ };
+
+ // 1. Handle ChoiceFormat (Plurals) with brace-depth-aware scanning
+ const choiceStart = /\\{(\\d+),choice,/g;
+ let result = '';
+ let lastIndex = 0;
+ let m;
+ while ((m = choiceStart.exec(msg)) !== null) {
+ // Find the matching closing brace, respecting nested {...}
+ let depth = 1;
+ let i = choiceStart.lastIndex;
+ while (i < msg.length && depth > 0) {
+ const ch = msg[i];
+ if (ch === '{') depth++;
+ else if (ch === '}') depth--;
+ if (depth === 0) break;
+ i++;
+ }
+ if (depth !== 0) {
+ // Unbalanced; bail out and leave the rest as-is
+ break;
+ }
+
+ const paramIndex = m[1];
+ const choicesStr = msg.substring(choiceStart.lastIndex, i);
+ const value = parseFloat(params[paramIndex]);
+
+ let replacement;
+ if (isNaN(value)) {
+ replacement = msg.substring(m.index, i + 1);
+ } else {
+ const choices = choicesStr.split('|');
+ let selectedChoice = null;
+ for (const choice of choices) {
+ const separatorIndex = choice.search(/[#<]/);
+ if (separatorIndex < 0) continue;
+ const limit = parseFloat(choice.substring(0, separatorIndex));
+ const separator = choice.charAt(separatorIndex);
+ const text = choice.substring(separatorIndex + 1);
+ if ((separator === '#' && value >= limit) ||
+ (separator === '<' && value > limit)) {
+ selectedChoice = text;
+ }
+ }
+ if (selectedChoice === null) {
+ const first = choices[0];
+ const sepIdx = first.search(/[#<]/);
+ selectedChoice = sepIdx >= 0 ? first.substring(sepIdx + 1) : first;
+ }
+ // Resolve nested {N}/{N,number,integer} inside the chosen branch
+ replacement = applyParams(selectedChoice);
+ }
+
+ result += msg.substring(lastIndex, m.index) + replacement;
+ lastIndex = i + 1;
+ choiceStart.lastIndex = i + 1;
+ }
+ result += msg.substring(lastIndex);
+ msg = result;
+
+ // 2. Handle remaining standard params {0}, {0,number}, {0,number,integer}
+ msg = applyParams(msg);
+
+ return msg;
+ };
}
- return selectedChoice || choices[0].substring(choices[0].search(/[#<]/) + 1);
- });
-
- // 2. Handle standard params {0}
- params.forEach((param, index) => {
- msg = msg.replace(new RegExp('\\\\{' + index + '(,number,integer|,number)?\\\\}', 'g'), param);
- });
-
- return msg;
- };
- }
- })();
- """.formatted(json);
+ })();
+ """.formatted(json);
ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok();
if ("development".equalsIgnoreCase(version)) {
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/MapLibreStyleDefinition.java b/src/main/java/com/dedicatedcode/reitti/dto/MapLibreStyleDefinition.java
new file mode 100644
index 000000000..a9db129d5
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/MapLibreStyleDefinition.java
@@ -0,0 +1,13 @@
+package com.dedicatedcode.reitti.dto;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.io.Serializable;
+
+public record MapLibreStyleDefinition(
+ Long id,
+ String label,
+ String mapType,
+ String styleInputType,
+ String styleUrl,
+ Object capabilities) implements Serializable {}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/TimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/TimelineData.java
deleted file mode 100644
index 4fe980f24..000000000
--- a/src/main/java/com/dedicatedcode/reitti/dto/TimelineData.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.dedicatedcode.reitti.dto;
-
-import java.util.List;
-
-public record TimelineData(
- List users
-) {
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java b/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java
index a351ce912..1058544d3 100644
--- a/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java
+++ b/src/main/java/com/dedicatedcode/reitti/dto/UserSettingsDTO.java
@@ -10,20 +10,21 @@
import java.util.Locale;
public record UserSettingsDTO(
- boolean preferColoredMap,
Language selectedLanguage,
String selectedLocale,
Instant newestData,
UnitSystem unitSystem,
Double homeLatitude,
Double homeLongitude,
+ @Deprecated(forRemoval = true)
TilesCustomizationDTO tiles,
UIMode uiMode,
PhotoMode photoMode,
TimeDisplayMode displayMode,
TimeMode timeMode,
ZoneId timezoneOverride,
- String customCssUrl
+ String customCssUrl,
+ String timelineColor
) {
public enum UIMode {
@@ -37,6 +38,7 @@ public enum PhotoMode {
DISABLED
}
+ @Deprecated(forRemoval = true)
public record TilesCustomizationDTO(String service, String attribution){}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java
new file mode 100644
index 000000000..733280186
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/map/MapStyleConfigDTO.java
@@ -0,0 +1,20 @@
+package com.dedicatedcode.reitti.dto.map;
+
+import com.dedicatedcode.reitti.model.map.MapStyleDataSource;
+import com.dedicatedcode.reitti.model.map.MapStyleVectorOptions;
+
+public record MapStyleConfigDTO(
+ Long id,
+ String label,
+ String mapType,
+ String styleInputType,
+ String rasterSourceInputType,
+ String styleUrl,
+ String styleInput,
+ boolean custom,
+ boolean shared,
+ boolean editable,
+ MapStyleDataSource dataSource,
+ MapStyleVectorOptions vectorOptions
+) {
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/DeviceTimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/DeviceTimelineData.java
new file mode 100644
index 000000000..3c6fc1874
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/DeviceTimelineData.java
@@ -0,0 +1,4 @@
+package com.dedicatedcode.reitti.dto.timeline;
+
+public record DeviceTimelineData(Long id, String name, String avatarUrl, String avatarFallback, boolean showAvatarOnMap, String color, String metadataUrl, String streamUrl) {
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/GroupedTimelineEntry.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/GroupedTimelineEntry.java
new file mode 100644
index 000000000..9ae3f48b3
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/GroupedTimelineEntry.java
@@ -0,0 +1,46 @@
+package com.dedicatedcode.reitti.dto.timeline;
+
+import com.dedicatedcode.reitti.model.geo.TransportMode;
+import com.dedicatedcode.reitti.model.metadata.Mood;
+
+import java.time.LocalDate;
+import java.util.*;
+
+public record GroupedTimelineEntry(UUID syntheticId, String name, String subHeadline, String href, List overview, Long visits, Long trips, List visitMoods, List tripMoods, List transportEntries, List visitEntries) implements TimelineEntry {
+
+ @Override
+ public boolean isAggregated() {
+ return true;
+ }
+
+ public record OverviewEntry(LocalDate slot, Long visits, Long trips) {
+ }
+
+ public record TransportEntry(TransportMode transportMode, List parts) {
+ public long durationSeconds() {
+ return parts.stream().mapToLong(TransportModePart::durationSeconds).sum();
+ }
+
+ public Optional dominantMood() {
+ return parts.stream().sorted(Comparator.comparing(TransportModePart::percent)).filter(p -> p.percent > 0.5).map(TransportModePart::mood).filter(Objects::nonNull).findFirst();
+ }
+ }
+
+ public record VisitEntry(String name, List parts) {
+ public long durationSeconds() {
+ return parts.stream().mapToLong(VisitPart::durationSeconds).sum();
+ }
+
+ public Optional dominantMood() {
+ return parts.stream().sorted(Comparator.comparing(VisitPart::percent)).filter(p -> p.percent > 0.5).map(VisitPart::mood).findFirst();
+ }
+ }
+
+ public record MoodValue(Mood mood, long amount, long durationSeconds) {
+ }
+
+ public record TransportModePart(TransportMode transportMode, Mood mood, long durationSeconds, double percent) {
+ }
+ public record VisitPart(Long placeId, String placeName, Mood mood, long durationSeconds, double percent) {
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/SingleTimelineEntry.java
similarity index 89%
rename from src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java
rename to src/main/java/com/dedicatedcode/reitti/dto/timeline/SingleTimelineEntry.java
index 2ad90115a..19fad281b 100644
--- a/src/main/java/com/dedicatedcode/reitti/dto/TimelineEntry.java
+++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/SingleTimelineEntry.java
@@ -1,16 +1,13 @@
-package com.dedicatedcode.reitti.dto;
+package com.dedicatedcode.reitti.dto.timeline;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.geo.TransportMode;
+import java.sql.Time;
import java.time.Instant;
import java.time.ZoneId;
-/**
- * Inner class to represent timeline entries for the template
- */
-public class TimelineEntry {
-
+public class SingleTimelineEntry implements TimelineEntry {
public enum Type {VISIT, TRIP;}
@@ -29,8 +26,8 @@ public enum Type {VISIT, TRIP;}
private Double distanceMeters;
private String formattedDistance;
private TransportMode transportMode;
+ private boolean editable;
- // Getters and setters
public String getId() {
return id;
}
@@ -151,4 +148,16 @@ public void setPath(String path) {
this.path = path;
}
+ public boolean isEditable() {
+ return editable;
+ }
+
+ public void setEditable(boolean editable) {
+ this.editable = editable;
+ }
+
+ @Override
+ public boolean isAggregated() {
+ return false;
+ }
}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineData.java
new file mode 100644
index 000000000..1782d6876
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineData.java
@@ -0,0 +1,5 @@
+package com.dedicatedcode.reitti.dto.timeline;
+
+import java.util.List;
+
+public record TimelineData(List users) { }
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineEntry.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineEntry.java
new file mode 100644
index 000000000..8a0bcf9c6
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/TimelineEntry.java
@@ -0,0 +1,5 @@
+package com.dedicatedcode.reitti.dto.timeline;
+
+public interface TimelineEntry {
+ boolean isAggregated();
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java b/src/main/java/com/dedicatedcode/reitti/dto/timeline/UserTimelineData.java
similarity index 63%
rename from src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java
rename to src/main/java/com/dedicatedcode/reitti/dto/timeline/UserTimelineData.java
index 3ecd4990e..d3253e272 100644
--- a/src/main/java/com/dedicatedcode/reitti/dto/UserTimelineData.java
+++ b/src/main/java/com/dedicatedcode/reitti/dto/timeline/UserTimelineData.java
@@ -1,4 +1,4 @@
-package com.dedicatedcode.reitti.dto;
+package com.dedicatedcode.reitti.dto.timeline;
import java.util.List;
@@ -8,9 +8,10 @@ public record UserTimelineData(
String avatarFallback,
String userAvatarUrl,
String baseColor,
- List entries,
+ List extends TimelineEntry> entries,
String rawLocationPointsUrl,
String processedVisitsUrl,
String mapMetaDataUrl,
- String mapStreamDataUrl) {
+ String mapStreamDataUrl,
+ List devices) {
}
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/ActionDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/ActionDto.java
new file mode 100644
index 000000000..7f678230b
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/ActionDto.java
@@ -0,0 +1,52 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class ActionDto {
+ private int seq;
+ private String type; // "copy" | "delete" | "move"
+ private String at; // ISO‑8601 instant string
+
+ private Map extra = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ public void setExtra(String key, Object value) {
+ extra.put(key, value);
+ }
+
+ @JsonAnyGetter
+ public Map getExtra() {
+ return extra;
+ }
+
+ public int getSeq() {
+ return seq;
+ }
+
+ public void setSeq(int seq) {
+ this.seq = seq;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public String getAt() {
+ return at;
+ }
+
+ public void setAt(String at) {
+ this.at = at;
+ }
+
+ public void setExtra(Map extra) {
+ this.extra = extra;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/DeletedPointDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/DeletedPointDto.java
new file mode 100644
index 000000000..f2445a195
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/DeletedPointDto.java
@@ -0,0 +1,13 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+public class DeletedPointDto {
+ private Long sourceId; // original point identifier (from database)
+
+ public Long getSourceId() {
+ return sourceId;
+ }
+
+ public void setSourceId(Long sourceId) {
+ this.sourceId = sourceId;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/EditStoreDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/EditStoreDto.java
new file mode 100644
index 000000000..a4189ebea
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/EditStoreDto.java
@@ -0,0 +1,33 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+import java.util.List;
+
+public class EditStoreDto {
+ private List patches;
+ private List deletedPoints;
+ private List movedPoints;
+
+ public List getPatches() {
+ return patches;
+ }
+
+ public void setPatches(List patches) {
+ this.patches = patches;
+ }
+
+ public List getDeletedPoints() {
+ return deletedPoints;
+ }
+
+ public void setDeletedPoints(List deletedPoints) {
+ this.deletedPoints = deletedPoints;
+ }
+
+ public List getMovedPoints() {
+ return movedPoints;
+ }
+
+ public void setMovedPoints(List movedPoints) {
+ this.movedPoints = movedPoints;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/FinalStateDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/FinalStateDto.java
new file mode 100644
index 000000000..139552f82
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/FinalStateDto.java
@@ -0,0 +1,23 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+public class FinalStateDto {
+ private long tStart;
+ private long tEnd;
+
+
+ public long gettStart() {
+ return tStart;
+ }
+
+ public void settStart(long tStart) {
+ this.tStart = tStart;
+ }
+
+ public long gettEnd() {
+ return tEnd;
+ }
+
+ public void settEnd(long tEnd) {
+ this.tEnd = tEnd;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/MovedPointDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/MovedPointDto.java
new file mode 100644
index 000000000..18f228b7d
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/MovedPointDto.java
@@ -0,0 +1,31 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+public class MovedPointDto {
+ private Long sourceId;
+ private double lat;
+ private double lng;
+
+ public Long getSourceId() {
+ return sourceId;
+ }
+
+ public void setSourceId(Long sourceId) {
+ this.sourceId = sourceId;
+ }
+
+ public double getLat() {
+ return lat;
+ }
+
+ public void setLat(double lat) {
+ this.lat = lat;
+ }
+
+ public double getLng() {
+ return lng;
+ }
+
+ public void setLng(double lng) {
+ this.lng = lng;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/PatchDto.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/PatchDto.java
new file mode 100644
index 000000000..c44cf0103
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/PatchDto.java
@@ -0,0 +1,50 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+public class PatchDto {
+ private int seq;
+ private long tStart; // epoch milliseconds
+ private long tEnd; // epoch milliseconds
+ private String deviceId; // nullable – null for default device, non‑null for specific device IDs
+
+ public int getSeq() {
+ return seq;
+ }
+
+ public void setSeq(int seq) {
+ this.seq = seq;
+ }
+
+ public long gettStart() {
+ return tStart;
+ }
+
+ public void settStart(long tStart) {
+ this.tStart = tStart;
+ }
+
+ public long gettEnd() {
+ return tEnd;
+ }
+
+ public void settEnd(long tEnd) {
+ this.tEnd = tEnd;
+ }
+
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ public void setDeviceId(String deviceId) {
+ this.deviceId = deviceId;
+ }
+
+ @Override
+ public String toString() {
+ return "PatchDto{" +
+ "seq=" + seq +
+ ", tStart=" + tStart +
+ ", tEnd=" + tEnd +
+ ", deviceId='" + deviceId + '\'' +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitRequest.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitRequest.java
new file mode 100644
index 000000000..8dcd3a13f
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitRequest.java
@@ -0,0 +1,36 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.util.List;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class WorkbenchCommitRequest {
+
+ private EditStoreDto editStore;
+ private List actions;
+ private FinalStateDto finalState;
+
+ public EditStoreDto getEditStore() {
+ return editStore;
+ }
+
+ public void setEditStore(EditStoreDto editStore) {
+ this.editStore = editStore;
+ }
+
+ public List getActions() {
+ return actions;
+ }
+
+ public void setActions(List actions) {
+ this.actions = actions;
+ }
+
+ public FinalStateDto getFinalState() {
+ return finalState;
+ }
+
+ public void setFinalState(FinalStateDto finalState) {
+ this.finalState = finalState;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitResponse.java b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitResponse.java
new file mode 100644
index 000000000..fd8822386
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/dto/workbench/WorkbenchCommitResponse.java
@@ -0,0 +1,24 @@
+package com.dedicatedcode.reitti.dto.workbench;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class WorkbenchCommitResponse {
+ private final boolean success;
+ private final String message;
+ private final String errorCode;
+
+ public WorkbenchCommitResponse(boolean success, String message) {
+ this(success, message, null);
+ }
+
+ public WorkbenchCommitResponse(boolean success, String message, String errorCode) {
+ this.success = success;
+ this.message = message;
+ this.errorCode = errorCode;
+ }
+
+ public boolean isSuccess() { return success; }
+ public String getMessage() { return message; }
+ public String getErrorCode() { return errorCode; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java b/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java
index 74b8a40da..4ed645c0a 100644
--- a/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java
+++ b/src/main/java/com/dedicatedcode/reitti/event/LocationProcessEvent.java
@@ -5,7 +5,6 @@
import java.io.Serializable;
import java.time.Instant;
-import java.util.Objects;
import java.util.UUID;
public class LocationProcessEvent implements Serializable {
@@ -14,6 +13,7 @@ public class LocationProcessEvent implements Serializable {
private final Instant latest;
private final String previewId;
private final String traceId;
+ private final UUID parentJobId;
@JsonCreator
public LocationProcessEvent(
@@ -21,12 +21,14 @@ public LocationProcessEvent(
@JsonProperty("earliest") Instant earliest,
@JsonProperty("latest") Instant latest,
@JsonProperty("previewId") String previewId,
- @JsonProperty("trace-id") String traceId) {
+ @JsonProperty("trace-id") String traceId,
+ @JsonProperty("parent-job-id") UUID parentJobId) {
this.username = username;
this.earliest = earliest;
this.latest = latest;
this.previewId = previewId;
this.traceId = traceId;
+ this.parentJobId = parentJobId;
}
public String getUsername() {
@@ -49,6 +51,10 @@ public String getTraceId() {
return traceId;
}
+ public UUID getParentJobId() {
+ return parentJobId;
+ }
+
@Override
public String toString() {
return "LocationProcessEvent{" +
diff --git a/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java b/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java
index eb904517e..ac082b02e 100644
--- a/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java
+++ b/src/main/java/com/dedicatedcode/reitti/event/SignificantPlaceCreatedEvent.java
@@ -1,17 +1,28 @@
package com.dedicatedcode.reitti.event;
-import com.fasterxml.jackson.annotation.JsonProperty;
+import com.dedicatedcode.reitti.service.JobContext;
import java.io.Serializable;
+import java.util.Objects;
+import java.util.UUID;
+
+public final class SignificantPlaceCreatedEvent extends JobContext implements Serializable {
+ private final String username;
+ private final String previewId;
+ private final Long placeId;
+ private final Double latitude;
+ private final Double longitude;
+ private final String traceId;
-public record SignificantPlaceCreatedEvent(String username, String previewId, Long placeId, Double latitude,
- Double longitude, String traceId) implements Serializable {
public SignificantPlaceCreatedEvent(String username,
String previewId,
Long placeId,
Double latitude,
Double longitude,
- @JsonProperty("trace-id") String traceId) {
+ String traceId,
+ UUID jobId,
+ UUID parentJobId) {
+ super(jobId, parentJobId);
this.username = username;
this.previewId = previewId;
this.placeId = placeId;
@@ -20,6 +31,15 @@ public SignificantPlaceCreatedEvent(String username,
this.traceId = traceId;
}
+ public SignificantPlaceCreatedEvent(String username,
+ String previewId,
+ Long placeId,
+ Double latitude,
+ Double longitude,
+ String traceId) {
+ this(username, previewId, placeId, latitude, longitude, traceId, null, null);
+ }
+
@Override
public String toString() {
return "SignificantPlaceCreatedEvent{" +
@@ -31,4 +51,38 @@ public String toString() {
", traceId='" + traceId + '\'' +
'}';
}
+
+ public String username() {
+ return username;
+ }
+
+ public String previewId() {
+ return previewId;
+ }
+
+ public Long placeId() {
+ return placeId;
+ }
+
+ public Double latitude() {
+ return latitude;
+ }
+
+ public Double longitude() {
+ return longitude;
+ }
+
+ public String traceId() {
+ return traceId;
+ }
+
+ @Override
+ public SignificantPlaceCreatedEvent withJobId(UUID jobId) {
+ return new SignificantPlaceCreatedEvent(username, previewId, placeId, latitude, longitude, traceId, jobId, parentJobId);
+ }
+
+ @Override
+ public SignificantPlaceCreatedEvent withParentJobId(UUID parentJobId) {
+ return new SignificantPlaceCreatedEvent(username, previewId, placeId, latitude, longitude, traceId, jobId, parentJobId);
+ }
}
diff --git a/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java b/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java
index 7ad579254..6873c77a3 100644
--- a/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java
+++ b/src/main/java/com/dedicatedcode/reitti/event/TriggerProcessingEvent.java
@@ -1,23 +1,31 @@
package com.dedicatedcode.reitti.event;
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonProperty;
+import com.dedicatedcode.reitti.service.JobContext;
import java.io.Serializable;
import java.time.Instant;
import java.util.UUID;
-public class TriggerProcessingEvent implements Serializable {
+public class TriggerProcessingEvent extends JobContext implements Serializable {
private final String username;
private final String previewId;
private final Instant receivedAt;
private final String traceId;
- @JsonCreator
public TriggerProcessingEvent(
- @JsonProperty("username") String username,
+ String username,
String previewId,
- @JsonProperty("trace-id") String traceId) {
+ String traceId) {
+ this(username, previewId, traceId, null, null);
+ }
+
+ public TriggerProcessingEvent(
+ String username,
+ String previewId,
+ String traceId,
+ UUID jobId,
+ UUID parentJobId) {
+ super(jobId, parentJobId);
this.username = username;
this.previewId = previewId;
this.traceId = traceId;
@@ -40,6 +48,16 @@ public String getTraceId() {
return traceId;
}
+ @Override
+ public TriggerProcessingEvent withJobId(UUID jobId) {
+ return new TriggerProcessingEvent(username, previewId, traceId, jobId, parentJobId);
+ }
+
+ @Override
+ public TriggerProcessingEvent withParentJobId(UUID parentJobId) {
+ return new TriggerProcessingEvent(username, previewId, traceId, jobId, parentJobId);
+ }
+
@Override
public String toString() {
return "TriggerProcessingEvent{" +
diff --git a/src/main/java/com/dedicatedcode/reitti/model/ClusteredPoint.java b/src/main/java/com/dedicatedcode/reitti/model/ClusteredPoint.java
deleted file mode 100644
index c51c390bd..000000000
--- a/src/main/java/com/dedicatedcode/reitti/model/ClusteredPoint.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.dedicatedcode.reitti.model;
-
-import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
-
-public class ClusteredPoint {
- private final RawLocationPoint point;
- private final Integer clusterId;
-
- public ClusteredPoint(RawLocationPoint point, Integer clusterId) {
- this.point = point;
- this.clusterId = clusterId;
- }
-
- public RawLocationPoint getPoint() {
- return point;
- }
-
- public Integer getClusterId() {
- return clusterId;
- }
-}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/Language.java b/src/main/java/com/dedicatedcode/reitti/model/Language.java
index 2032f2be8..39ec4d8a8 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/Language.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/Language.java
@@ -12,9 +12,12 @@ public enum Language {
RU(Locale.of("ru"), "language.russian", "\uD83C\uDDF7\uD83C\uDDFA"),
JA(Locale.of("ja"), "language.japanese", "\uD83C\uDDEF\uD83C\uDDF5"),
PT_BR(Locale.of("pt", "br"), "language.brazilian_portuguese", "\uD83C\uDDE7\uD83C\uDDF7"),
+ PT(Locale.of("pt"), "language.portuguese", "\uD83C\uDDF5\uD83C\uDDF9"),
+ KO(Locale.of("ko"), "language.korean", "\uD83C\uDDF0\uD83C\uDDF7"),
TR(Locale.of("tr"), "language.turkish", "\uD83C\uDDF9\uD83C\uDDF7"),
UK(Locale.of("uk"), "language.ukrainian", "\uD83C\uDDFA\uD83C\uDDE6"),
ZH_CN(Locale.of("zh", "cn"), "language.chinese", "\uD83C\uDDE8\uD83C\uDDF3"),
+ TW_CN(Locale.of("tw", "cn"), "language.chinese_traditional", "\uD83C\uDDE8\uD83C\uDDF3"),
ES(Locale.of("es"), "language.spanish", "\uD83C\uDDEA\uD83C\uDDF8");
private final Locale locale;
diff --git a/src/main/java/com/dedicatedcode/reitti/model/devices/Device.java b/src/main/java/com/dedicatedcode/reitti/model/devices/Device.java
new file mode 100644
index 000000000..23a4b3203
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/devices/Device.java
@@ -0,0 +1,12 @@
+package com.dedicatedcode.reitti.model.devices;
+
+import java.io.Serializable;
+import java.time.Instant;
+
+public record Device(Long id, String name, boolean enabled, boolean showOnMap, boolean showAvatarOnMap, String color, boolean defaultDevice, Instant createdAt,
+ Instant updatedAt, Long version) implements Serializable {
+
+ public Device withDefaultDevice(boolean defaultDevice) {
+ return new Device(id, name, enabled, showOnMap, showAvatarOnMap, color, defaultDevice, createdAt, updatedAt, version);
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java
index 7978ed635..ad0e039c6 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/geo/GeoUtils.java
@@ -48,6 +48,12 @@ public static double distanceInMeters(RawLocationPoint p1, RawLocationPoint p2)
p2.getLatitude(), p2.getLongitude());
}
+ public static double distanceInMeters(SourceLocationPoint p1, SourceLocationPoint p2) {
+ return distanceInMeters(
+ p1.getLatitude(), p1.getLongitude(),
+ p2.getLatitude(), p2.getLongitude());
+ }
+
/**
* Converts a distance in meters to degrees of latitude and longitude at a given position.
* The conversion varies based on the latitude because longitude degrees get closer together as you move away from the equator.
diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java b/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java
index a6cf99b9e..1f0e86478 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/geo/ProcessedVisit.java
@@ -1,6 +1,7 @@
package com.dedicatedcode.reitti.model.geo;
import java.time.Instant;
+import java.util.Map;
import java.util.Objects;
public class ProcessedVisit {
@@ -10,18 +11,20 @@ public class ProcessedVisit {
private final Instant startTime;
private final Instant endTime;
private final Long durationSeconds;
+ private final Map metadata;
private final Long version;
- public ProcessedVisit(SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds) {
- this(null, place, startTime, endTime, durationSeconds, 1L);
+ public ProcessedVisit(SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds, Map metadata) {
+ this(null, place, startTime, endTime, durationSeconds, metadata, 1L);
}
- public ProcessedVisit(Long id, SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds, Long version) {
+ public ProcessedVisit(Long id, SignificantPlace place, Instant startTime, Instant endTime, Long durationSeconds, Map metadata, Long version) {
this.id = id;
this.place = place;
this.startTime = startTime;
this.endTime = endTime;
this.durationSeconds = durationSeconds;
+ this.metadata = metadata;
this.version = version;
}
@@ -49,12 +52,20 @@ public Long getVersion() {
return this.version;
}
+ public Map getMetadata() {
+ return metadata;
+ }
+
public ProcessedVisit withId(Long id) {
- return new ProcessedVisit(id, this.place, this.startTime, this.endTime, this.durationSeconds, this.version);
+ return new ProcessedVisit(id, this.place, this.startTime, this.endTime, this.durationSeconds, metadata, this.version);
}
public ProcessedVisit withVersion(long version) {
- return new ProcessedVisit(this.id, this.place, this.startTime, this.endTime, this.durationSeconds, version);
+ return new ProcessedVisit(this.id, this.place, this.startTime, this.endTime, this.durationSeconds, metadata, version);
+ }
+
+ public ProcessedVisit withMetadata(Map metadata) {
+ return new ProcessedVisit(this.id, this.place, this.startTime, this.endTime, this.durationSeconds, metadata, this.version);
}
@Override
diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java b/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java
index d6ca51850..b06e2ca4f 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/geo/RawLocationPoint.java
@@ -6,39 +6,37 @@
public class RawLocationPoint {
private final Long id;
+ private final Long sourceId;
private final Instant timestamp;
private final Double accuracyMeters;
private final Double elevationMeters;
private final GeoPoint geom;
private final boolean processed;
private final boolean synthetic;
- private final boolean invalid;
- private final boolean ignored;
private final Long version;
public RawLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters) {
- this(null, timestamp, geom, accuracyMeters, null, false, false, false, false, null);
+ this(null, null, timestamp, geom, accuracyMeters, null, false, false, null);
}
public RawLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters) {
- this(null, timestamp, geom, accuracyMeters, elevationMeters, false, false, false, false, null);
+ this(null, null, timestamp, geom, accuracyMeters, elevationMeters, false, false, null);
}
public RawLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, boolean processed, Long version) {
- this(id, timestamp, geom, accuracyMeters, elevationMeters, processed, false, false, false, version);
+ this(id, null, timestamp, geom, accuracyMeters, elevationMeters, processed, false, version);
}
- public RawLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, boolean processed, boolean synthetic, boolean ignored, boolean invalid, Long version) {
+ public RawLocationPoint(Long id, Long sourceId, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, boolean processed, boolean synthetic, Long version) {
this.id = id;
+ this.sourceId = sourceId;
this.timestamp = timestamp;
this.geom = geom;
this.accuracyMeters = accuracyMeters;
this.elevationMeters = elevationMeters;
this.processed = processed;
this.synthetic = synthetic;
- this.invalid = invalid;
- this.ignored = ignored;
this.version = version;
}
@@ -46,6 +44,10 @@ public Long getId() {
return id;
}
+ public Long getSourceId() {
+ return sourceId;
+ }
+
public Instant getTimestamp() {
return timestamp;
}
@@ -78,32 +80,16 @@ public boolean isSynthetic() {
return synthetic;
}
- public boolean isInvalid() {
- return invalid;
- }
-
- public boolean isIgnored() {
- return ignored;
- }
-
public RawLocationPoint markProcessed() {
- return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, true, synthetic, ignored, invalid, version);
+ return new RawLocationPoint(id, sourceId, timestamp, geom, accuracyMeters, elevationMeters, true, synthetic, version);
}
public RawLocationPoint markAsSynthetic() {
- return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, true, ignored, invalid, version);
+ return new RawLocationPoint(id, sourceId, timestamp, geom, accuracyMeters, elevationMeters, processed, true, version);
}
- public RawLocationPoint markAsIgnored() {
- return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, true, invalid, version);
- }
-
- public RawLocationPoint markAsInvalid() {
- return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, true, invalid, version);
- }
-
public RawLocationPoint withId(Long id) {
- return new RawLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, ignored, invalid, version);
+ return new RawLocationPoint(id, sourceId, timestamp, geom, accuracyMeters, elevationMeters, processed, synthetic, version);
}
public Long getVersion() {
diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/SourceLocationPoint.java b/src/main/java/com/dedicatedcode/reitti/model/geo/SourceLocationPoint.java
new file mode 100644
index 000000000..5c0adb8c4
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/geo/SourceLocationPoint.java
@@ -0,0 +1,122 @@
+package com.dedicatedcode.reitti.model.geo;
+
+import java.time.Instant;
+import java.util.Objects;
+
+public class SourceLocationPoint {
+
+ private final Long id;
+ private final Instant timestamp;
+ private final Double accuracyMeters;
+ private final Double elevationMeters;
+ private final GeoPoint geom;
+ private final boolean invalid;
+ private final Status status;
+
+ public SourceLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters) {
+ this(null, timestamp, geom, accuracyMeters, null, Status.VALID, false);
+ }
+
+ public SourceLocationPoint(Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters) {
+ this(null, timestamp, geom, accuracyMeters, elevationMeters, Status.VALID, false);
+ }
+
+ public SourceLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters) {
+ this(id, timestamp, geom, accuracyMeters, elevationMeters, Status.VALID, false);
+ }
+
+ public SourceLocationPoint(Long id, Instant timestamp, GeoPoint geom, Double accuracyMeters, Double elevationMeters, Status status, boolean invalid) {
+ this.id = id;
+ this.timestamp = timestamp;
+ this.geom = geom;
+ this.accuracyMeters = accuracyMeters;
+ this.elevationMeters = elevationMeters;
+ this.invalid = invalid;
+ this.status = status;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public Instant getTimestamp() {
+ return timestamp;
+ }
+
+ public Double getLatitude() {
+ return this.geom.latitude();
+ }
+
+ public Double getLongitude() {
+ return this.geom.longitude();
+ }
+
+ public Double getAccuracyMeters() {
+ return accuracyMeters;
+ }
+
+ public Double getElevationMeters() {
+ return elevationMeters;
+ }
+
+ public GeoPoint getGeom() {
+ return geom;
+ }
+
+ public boolean isInvalid() {
+ return invalid;
+ }
+
+ public Status getStatus() {
+ return status;
+ }
+
+ public SourceLocationPoint markAsIgnored() {
+ return new SourceLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, Status.IGNORED_BY_SYSTEM, invalid);
+ }
+
+ public SourceLocationPoint markAsInvalid() {
+ return new SourceLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, status, true);
+ }
+
+ public SourceLocationPoint withId(Long id) {
+ return new SourceLocationPoint(id, timestamp, geom, accuracyMeters, elevationMeters, status, invalid);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ SourceLocationPoint that = (SourceLocationPoint) o;
+ return Objects.equals(id, that.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(id);
+ }
+
+ public enum Status {
+ VALID(0L),
+ IGNORED_BY_USER(1L),
+ IGNORED_BY_SYSTEM(2L);
+
+ private final long dbValue;
+
+ Status(long dbValue) {
+ this.dbValue = dbValue;
+ }
+
+ public static Status fromDbValue(long dbValue) {
+ for (Status status : Status.values()) {
+ if (status.dbValue == dbValue) {
+ return status;
+ }
+ }
+ return null;
+ }
+
+ public long getDbValue() {
+ return dbValue;
+ }
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java b/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java
index 93e017000..dc29ecd80 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/geo/Trip.java
@@ -1,6 +1,7 @@
package com.dedicatedcode.reitti.model.geo;
import java.time.Instant;
+import java.util.Map;
import java.util.Objects;
public class Trip {
@@ -14,13 +15,14 @@ public class Trip {
private final TransportMode transportModeInferred;
private final ProcessedVisit startVisit;
private final ProcessedVisit endVisit;
+ private final Map metadata;
private final Long version;
- public Trip(Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit) {
- this(null, startTime, endTime, durationSeconds, estimatedDistanceMeters, travelledDistanceMeters, transportModeInferred, startVisit, endVisit, 1L);
+ public Trip(Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Map metadata) {
+ this(null, startTime, endTime, durationSeconds, estimatedDistanceMeters, travelledDistanceMeters, transportModeInferred, startVisit, endVisit, metadata, 1L);
}
- public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Long version) {
+ public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, Double estimatedDistanceMeters, Double travelledDistanceMeters, TransportMode transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit, Map metadata, Long version) {
this.id = id;
this.startTime = startTime;
this.endTime = endTime;
@@ -30,6 +32,7 @@ public Trip(Long id, Instant startTime, Instant endTime, Long durationSeconds, D
this.transportModeInferred = transportModeInferred;
this.startVisit = startVisit;
this.endVisit = endVisit;
+ this.metadata = metadata;
this.version = version;
}
@@ -73,16 +76,24 @@ public Long getVersion() {
return version;
}
+ public Map getMetadata() {
+ return metadata;
+ }
+
public Trip withId(Long id) {
- return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, this.version);
+ return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, metadata, this.version);
}
public Trip withTransportMode(TransportMode mode) {
- return new Trip(this.id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, mode, this.startVisit, this.endVisit, this.version);
+ return new Trip(this.id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, mode, this.startVisit, this.endVisit, metadata, this.version);
}
public Trip withVersion(long version) {
- return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, version);
+ return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, metadata, version);
+ }
+
+ public Trip withMetadata(Map metadata) {
+ return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, metadata, this.version);
}
@Override
@@ -96,5 +107,4 @@ public boolean equals(Object o) {
public int hashCode() {
return Objects.hashCode(id);
}
-
}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java b/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java
index 0059a5005..54341bbdf 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/geo/Visit.java
@@ -1,5 +1,7 @@
package com.dedicatedcode.reitti.model.geo;
+import com.dedicatedcode.reitti.model.metadata.MemoryMetadata;
+
import java.time.Instant;
import java.util.Objects;
diff --git a/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java b/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java
index e56658fad..f9bcaf54a 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/integration/OwnTracksRecorderIntegration.java
@@ -10,21 +10,23 @@ public class OwnTracksRecorderIntegration {
private final String deviceId;
private final String authUsername;
private final String authPassword;
+ private final Long reittiDeviceId;
private final boolean enabled;
private final Instant lastSuccessfulFetch;
private final Long version;
- public OwnTracksRecorderIntegration(String baseUrl, String username, String deviceId, boolean enabled, String authUsername, String authPassword) {
- this(null, baseUrl, username, deviceId, authPassword, authUsername, enabled, null, null);
+ public OwnTracksRecorderIntegration(String baseUrl, String username, String deviceId, boolean enabled, String authUsername, String authPassword, Long reittiDeviceId) {
+ this(null, baseUrl, username, deviceId, authPassword, authUsername, reittiDeviceId, enabled, null, null);
}
- public OwnTracksRecorderIntegration(Long id, String baseUrl, String username, String deviceId, String authUsername, String authPassword, boolean enabled, Instant lastSuccessfulFetch, Long version) {
+ public OwnTracksRecorderIntegration(Long id, String baseUrl, String username, String deviceId, String authUsername, String authPassword, Long reittiDeviceId, boolean enabled, Instant lastSuccessfulFetch, Long version) {
this.id = id;
this.baseUrl = baseUrl;
this.username = username;
this.deviceId = deviceId;
this.authUsername = authUsername;
this.authPassword = authPassword;
+ this.reittiDeviceId = reittiDeviceId;
this.enabled = enabled;
this.lastSuccessfulFetch = lastSuccessfulFetch;
this.version = version;
@@ -54,6 +56,10 @@ public String getAuthPassword() {
return authPassword;
}
+ public Long getReittiDeviceId() {
+ return reittiDeviceId;
+ }
+
public boolean isEnabled() {
return enabled;
}
@@ -67,18 +73,22 @@ public Long getVersion() {
}
public OwnTracksRecorderIntegration withEnabled(boolean enabled) {
- return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, enabled, this.lastSuccessfulFetch, this.version);
+ return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, enabled, this.lastSuccessfulFetch, this.version);
}
public OwnTracksRecorderIntegration withId(Long id) {
- return new OwnTracksRecorderIntegration(id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, this.enabled, this.lastSuccessfulFetch, this.version);
+ return new OwnTracksRecorderIntegration(id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, this.lastSuccessfulFetch, this.version);
}
public OwnTracksRecorderIntegration withVersion(Long version) {
- return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, this.enabled, this.lastSuccessfulFetch, version);
+ return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, this.lastSuccessfulFetch, version);
}
public OwnTracksRecorderIntegration withLastSuccessfulFetch(Instant lastSuccessfulFetch) {
- return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, this.enabled, lastSuccessfulFetch, this.version);
+ return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, lastSuccessfulFetch, this.version);
+ }
+
+ public OwnTracksRecorderIntegration withReittiDeviceId(Long reittiDeviceId) {
+ return new OwnTracksRecorderIntegration(this.id, this.baseUrl, this.username, this.deviceId, this.authUsername, this.authPassword, reittiDeviceId, this.enabled, this.lastSuccessfulFetch, this.version);
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java
new file mode 100644
index 000000000..1cf1dcccd
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleDataSource.java
@@ -0,0 +1,19 @@
+package com.dedicatedcode.reitti.model.map;
+
+public record MapStyleDataSource(
+ String sourceId,
+ String type,
+ String tileJsonUrl,
+ String tileUrlTemplate,
+ String attribution,
+ Integer minzoom,
+ Integer maxzoom,
+ Integer tileSize,
+ String scheme,
+ boolean proxyTiles
+) {
+ public MapStyleDataSource withProxyTiles(boolean proxyTiles) {
+ return new MapStyleDataSource(sourceId, type, tileJsonUrl, tileUrlTemplate, attribution,
+ minzoom, maxzoom, tileSize, scheme, proxyTiles);
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java
new file mode 100644
index 000000000..a1b894876
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/map/MapStyleVectorOptions.java
@@ -0,0 +1,8 @@
+package com.dedicatedcode.reitti.model.map;
+
+public record MapStyleVectorOptions(
+ String attributionOverride,
+ String glyphsUrlOverride,
+ String spriteUrlOverride
+) {
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java b/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java
new file mode 100644
index 000000000..00639db47
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/map/UserMapStyle.java
@@ -0,0 +1,40 @@
+package com.dedicatedcode.reitti.model.map;
+
+import com.dedicatedcode.reitti.dto.map.MapStyleConfigDTO;
+import com.dedicatedcode.reitti.model.security.User;
+
+public record UserMapStyle(
+ Long id,
+ Long userId,
+ String name,
+ String mapType,
+ String styleInputType,
+ String rasterSourceInputType,
+ String styleJson,
+ String styleUrl,
+ MapStyleDataSource dataSource,
+ MapStyleVectorOptions vectorOptions,
+ boolean defaultStyle,
+ boolean shared,
+ Long version
+) {
+ public String styleInput() {
+ return styleJson != null ? styleJson : styleUrl;
+ }
+
+ public MapStyleConfigDTO toDto(User user) {
+ return new MapStyleConfigDTO(
+ id,
+ name(),
+ mapType(),
+ styleInputType(),
+ rasterSourceInputType(),
+ styleUrl,
+ styleInput(),
+ !defaultStyle,
+ shared(),
+ !defaultStyle && userId().equals(user.getId()),
+ dataSource(),
+ vectorOptions());
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java b/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java
index e1c1ef369..fef54910b 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/memory/MemoryVisit.java
@@ -2,6 +2,7 @@
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
+import org.springframework.context.i18n.LocaleContextHolder;
import java.time.Duration;
import java.time.Instant;
@@ -25,9 +26,9 @@ public static MemoryVisit create(ProcessedVisit visit) {
if (place.getCity() != null && !place.getCity().isBlank()) {
name = place.getCity();
} else {
- name = String.format("%.4f, %.4f",
- place.getLatitudeCentroid(),
- place.getLongitudeCentroid());
+ name = String.format(LocaleContextHolder.getLocale(), "%.4f, %.4f",
+ place.getLatitudeCentroid(),
+ place.getLongitudeCentroid());
}
}
return new MemoryVisit(null, true, name, visit.getStartTime(), visit.getEndTime(), visit.getPlace().getLatitudeCentroid(), visit.getPlace().getLongitudeCentroid(), visit.getPlace().getTimezone());
diff --git a/src/main/java/com/dedicatedcode/reitti/model/metadata/MemoryMetadata.java b/src/main/java/com/dedicatedcode/reitti/model/metadata/MemoryMetadata.java
new file mode 100644
index 000000000..4ecddda69
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/metadata/MemoryMetadata.java
@@ -0,0 +1,76 @@
+package com.dedicatedcode.reitti.model.metadata;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class MemoryMetadata {
+
+ private final Map properties = new HashMap<>();
+ private final Instant startTime;
+ private final Instant endTime;
+
+ public static MemoryMetadata empty() {
+ return new MemoryMetadata(null, null);
+ }
+
+ public MemoryMetadata(Instant startTime, Instant endTime) {
+ this.startTime = startTime;
+ this.endTime = endTime;
+ }
+
+ // --- Time Envelope Getters/Setters ---
+ public Instant getStartTime() { return startTime; }
+
+ public Instant getEndTime() { return endTime; }
+
+ public Mood getMood() {
+ Object val = properties.get("mood");
+ return val == null ? null : Mood.fromString(val.toString());
+ }
+
+ public void setMood(Mood mood) {
+ if (mood == null) properties.remove("mood");
+ else properties.put("mood", mood.name());
+ }
+
+ public String getDescription() { return (String) properties.get("description"); }
+ public void setDescription(String d) {
+ if (d == null) properties.remove("description");
+ else properties.put("description", d);
+ }
+
+ public String getReason() { return (String) properties.get("reason"); }
+ public void setReason(String r) {
+ if (r == null) properties.remove("reason");
+ else properties.put("reason", r);
+ }
+
+ @SuppressWarnings("unchecked")
+ public List getTags() {
+ Object tags = properties.get("tags");
+ if (!(tags instanceof List)) {
+ List newList = new ArrayList<>();
+ properties.put("tags", newList);
+ return newList;
+ }
+ return (List) tags;
+ }
+
+ public void setTags(List tags) {
+ if (tags == null) properties.remove("tags");
+ else properties.put("tags", tags);
+ }
+
+ public Map getProperties() { return properties; }
+
+ public void setProperty(String key, Object value) { this.properties.put(key, value); }
+
+ public void setProperties(Map properties) {
+ this.properties.clear();
+ this.properties.putAll(properties);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/model/metadata/Mood.java b/src/main/java/com/dedicatedcode/reitti/model/metadata/Mood.java
new file mode 100644
index 000000000..16a1261a3
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/metadata/Mood.java
@@ -0,0 +1,24 @@
+package com.dedicatedcode.reitti.model.metadata;
+
+public enum Mood {
+ HAPPY("\uD83D\uDE0A"),
+ RELAXED("\uD83D\uDE0C"),
+ ADVENTUROUS("\uD83E\uDD20"),
+ TIRED("\uD83D\uDE34"),
+ STRESSED("\uD83D\uDE2B");
+
+ private final String icon;
+
+ Mood(String icon) {
+ this.icon = icon;
+ }
+
+ public String getIcon() {
+ return icon;
+ }
+
+ public static Mood fromString(String value) {
+ if (value == null || value.trim().isEmpty()) return null;
+ return Mood.valueOf(value.toUpperCase());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java b/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java
index 0bca12bcd..49819b024 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/security/ApiToken.java
@@ -1,5 +1,7 @@
package com.dedicatedcode.reitti.model.security;
+import com.dedicatedcode.reitti.model.devices.Device;
+
import java.time.Instant;
import java.util.UUID;
@@ -8,7 +10,9 @@ public class ApiToken {
private final Long id;
private final String token;
-
+
+ private final Device device;
+
private final User user;
private final String name;
@@ -18,19 +22,23 @@ public class ApiToken {
private final Instant lastUsedAt;
public ApiToken(User user, String name) {
- this(null, null, user, name, null, null);
+ this(user, name, null);
}
-
- public ApiToken(Long id, String token, User user, String name, Instant createdAt, Instant lastUsedAt) {
+
+ public ApiToken(User user, String name, Device device) {
+ this(null, null, user, device, name, null, null);
+ }
+
+ public ApiToken(Long id, String token, User user, Device device, String name, Instant createdAt, Instant lastUsedAt) {
this.id = id;
this.token = token != null ? token : UUID.randomUUID().toString();
this.user = user;
+ this.device = device;
this.name = name;
this.createdAt = createdAt != null ? createdAt : Instant.now();
this.lastUsedAt = lastUsedAt;
}
-
- // Getters
+
public Long getId() {
return id;
}
@@ -42,7 +50,11 @@ public String getToken() {
public User getUser() {
return user;
}
-
+
+ public Device getDevice() {
+ return device;
+ }
+
public String getName() {
return name;
}
@@ -57,6 +69,10 @@ public Instant getLastUsedAt() {
// Wither method
public ApiToken withLastUsedAt(Instant lastUsedAt) {
- return new ApiToken(this.id, this.token, this.user, this.name, this.createdAt, lastUsedAt);
+ return new ApiToken(this.id, this.token, this.user, this.device, this.name, this.createdAt, lastUsedAt);
+ }
+
+ public ApiToken withDevice(Device device) {
+ return new ApiToken(this.id, this.token, this.user, device, this.name, this.createdAt, this.lastUsedAt);
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java b/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java
index 6353a0958..2a0d45656 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/security/ApiTokenUsage.java
@@ -2,5 +2,5 @@
import java.time.Instant;
-public record ApiTokenUsage(String token, String name, Instant at, String endpoint, String ip) {
+public record ApiTokenUsage(String token, String name, String device, Instant at, String endpoint, String ip) {
}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/DeviceTokenUser.java b/src/main/java/com/dedicatedcode/reitti/model/security/DeviceTokenUser.java
new file mode 100644
index 000000000..d71235487
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/model/security/DeviceTokenUser.java
@@ -0,0 +1,17 @@
+package com.dedicatedcode.reitti.model.security;
+
+import com.dedicatedcode.reitti.model.devices.Device;
+
+import java.util.Optional;
+
+public class DeviceTokenUser extends User {
+ private final Device device;
+ public DeviceTokenUser(User user, Device device) {
+ super(user.getId(), user.getUsername(), user.getPassword(), user.getDisplayName(), user.getProfileUrl(), user.getExternalId(), user.getRole(), user.getVersion());
+ this.device = device;
+ }
+
+ public Optional getDevice() {
+ return Optional.ofNullable(device);
+ }
+}
diff --git a/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java b/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java
index 3efe0891a..f47cac155 100644
--- a/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java
+++ b/src/main/java/com/dedicatedcode/reitti/model/security/UserSettings.java
@@ -13,7 +13,6 @@
public class UserSettings implements Serializable {
private final Long userId;
- private final boolean preferColoredMap;
private final Language selectedLanguage;
private final UnitSystem unitSystem;
private final Double homeLatitude;
@@ -26,9 +25,8 @@ public class UserSettings implements Serializable {
private final String color;
private final Long version;
- public UserSettings(Long userId, boolean preferColoredMap, Language selectedLanguage, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, ZoneId timeZoneOverride, TimeDisplayMode timeDisplayMode, TimeMode timeMode, String customCss, Instant latestData, String color, Long version) {
+ public UserSettings(Long userId, Language selectedLanguage, UnitSystem unitSystem, Double homeLatitude, Double homeLongitude, ZoneId timeZoneOverride, TimeDisplayMode timeDisplayMode, TimeMode timeMode, String customCss, Instant latestData, String color, Long version) {
this.userId = userId;
- this.preferColoredMap = preferColoredMap;
this.selectedLanguage = selectedLanguage;
this.unitSystem = unitSystem;
this.homeLatitude = homeLatitude;
@@ -43,16 +41,12 @@ public UserSettings(Long userId, boolean preferColoredMap, Language selectedLang
}
public static UserSettings defaultSettings(Long userId) {
- return new UserSettings(userId, false, Language.EN, UnitSystem.METRIC, null, null, null, TimeDisplayMode.DEFAULT, TimeMode.TWENTY_FOUR_HOUR, null, null, "#f1ba63", null);
+ return new UserSettings(userId, Language.EN, UnitSystem.METRIC, null, null, null, TimeDisplayMode.DEFAULT, TimeMode.TWENTY_FOUR_HOUR, null, null, "#f1ba63", null);
}
public Long getUserId() {
return userId;
}
- public boolean isPreferColoredMap() {
- return preferColoredMap;
- }
-
public Language getSelectedLanguage() {
return selectedLanguage;
}
@@ -94,7 +88,7 @@ public String getCustomCss() {
}
public UserSettings withHomeCoordinates(Double homeLatitude, Double homeLongitude) {
- return new UserSettings(userId, preferColoredMap, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, this.timeMode, customCss, latestData, color, version);
+ return new UserSettings(userId, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, this.timeMode, customCss, latestData, color, version);
}
public String getColor() {
@@ -106,8 +100,7 @@ public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserSettings that = (UserSettings) o;
- return preferColoredMap == that.preferColoredMap &&
- Objects.equals(userId, that.userId) &&
+ return Objects.equals(userId, that.userId) &&
Objects.equals(selectedLanguage, that.selectedLanguage) &&
Objects.equals(unitSystem, that.unitSystem) &&
Objects.equals(homeLatitude, that.homeLatitude) &&
@@ -122,14 +115,13 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
- return Objects.hash(userId, preferColoredMap, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, version);
+ return Objects.hash(userId, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, version);
}
@Override
public String toString() {
return "UserSettings{" +
"userId=" + userId +
- ", preferColoredMap=" + preferColoredMap +
", selectedLanguage='" + selectedLanguage + '\'' +
", unitSystem=" + unitSystem +
", homeLatitude=" + homeLatitude +
@@ -144,6 +136,6 @@ public String toString() {
}
public UserSettings withVersion(long version) {
- return new UserSettings(userId, preferColoredMap, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, color, version);
+ return new UserSettings(userId, selectedLanguage, unitSystem, homeLatitude, homeLongitude, timeZoneOverride, timeDisplayMode, timeMode, customCss, latestData, color, version);
}
}
diff --git a/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java
index c903c7f3a..3bfe037bc 100644
--- a/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java
+++ b/src/main/java/com/dedicatedcode/reitti/repository/ApiTokenJdbcService.java
@@ -1,8 +1,9 @@
package com.dedicatedcode.reitti.repository;
+import com.dedicatedcode.reitti.model.Role;
+import com.dedicatedcode.reitti.model.devices.Device;
import com.dedicatedcode.reitti.model.security.ApiToken;
import com.dedicatedcode.reitti.model.security.ApiTokenUsage;
-import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -16,7 +17,6 @@
import java.util.Optional;
@Service
-@Transactional
public class ApiTokenJdbcService {
private final JdbcTemplate jdbcTemplate;
@@ -25,13 +25,14 @@ public ApiTokenJdbcService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
- @Transactional(readOnly = true)
public Optional findByToken(String token) {
String sql = """
- SELECT at.id, at.token, at.name, at.created_at, at.last_used_at,
- u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version
+ SELECT at.id, at.token, at.name, at.device_id, at.created_at, at.last_used_at,
+ u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version,
+ d.id as device_id, d.name as device_name, d.default_device as default_device, d.enabled as device_enabled, d.color as device_color, d.show_on_map as device_show_on_map, d.show_avatar_on_map as device_show_avatar_on_map, d.version as device_version, d.created_at as device_created_at, d.updated_at as device_updated_at, d.version as device_version
FROM api_tokens at
JOIN users u ON at.user_id = u.id
+ LEFT JOIN devices d ON at.device_id = d.id
WHERE at.token = ?
""";
try {
@@ -42,26 +43,28 @@ public Optional findByToken(String token) {
}
}
- @Transactional(readOnly = true)
public List findByUser(User user) {
String sql = """
- SELECT at.id, at.token, at.name, at.created_at, at.last_used_at,
- u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version
+ SELECT at.id, at.token, at.name, at.device_id, at.created_at, at.last_used_at,
+ u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version,
+ d.id as device_id, d.name as device_name, d.default_device as default_device, d.enabled as device_enabled, d.color as device_color, d.show_on_map as device_show_on_map, d.show_avatar_on_map as device_show_avatar_on_map, d.version as device_version, d.created_at as device_created_at, d.updated_at as device_updated_at, d.version as device_version
FROM api_tokens at
JOIN users u ON at.user_id = u.id
+ LEFT JOIN devices d ON at.device_id = d.id
WHERE at.user_id = ?
ORDER BY at.created_at DESC
""";
return jdbcTemplate.query(sql, this::mapRowToApiToken, user.getId());
}
- @Transactional(readOnly = true)
public Optional findById(Long id) {
String sql = """
- SELECT at.id, at.token, at.name, at.created_at, at.last_used_at,
- u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version
+ SELECT at.id, at.token, at.name, at.device_id, at.created_at, at.last_used_at,
+ u.id as user_id, u.username, u.password, u.display_name, u.profile_url, u.external_id, u.role, u.version as user_version,
+ d.id as device_id, d.name as device_name, d.default_device as default_device, d.enabled as device_enabled, d.color as device_color, d.show_on_map as device_show_on_map, d.show_avatar_on_map as device_show_avatar_on_map, d.version as device_version, d.created_at as device_created_at, d.updated_at as device_updated_at, d.version as device_version
FROM api_tokens at
JOIN users u ON at.user_id = u.id
+ LEFT JOIN devices d ON at.device_id = d.id
WHERE at.id = ?
""";
try {
@@ -81,27 +84,29 @@ public ApiToken save(ApiToken apiToken) {
}
private ApiToken insert(ApiToken apiToken) {
- String sql = "INSERT INTO api_tokens (token, user_id, name, created_at, last_used_at) VALUES (?, ?, ?, ?, ?) RETURNING id";
+ String sql = "INSERT INTO api_tokens (token, user_id, device_id, name, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id";
Long id = jdbcTemplate.queryForObject(sql, Long.class,
apiToken.getToken(),
apiToken.getUser().getId(),
+ apiToken.getDevice() != null ? apiToken.getDevice().id() : null,
apiToken.getName(),
Timestamp.from(apiToken.getCreatedAt()),
apiToken.getLastUsedAt() != null ? Timestamp.from(apiToken.getLastUsedAt()) : null
);
- return new ApiToken(id, apiToken.getToken(), apiToken.getUser(), apiToken.getName(),
+ return new ApiToken(id, apiToken.getToken(), apiToken.getUser(), apiToken.getDevice(), apiToken.getName(),
apiToken.getCreatedAt(), apiToken.getLastUsedAt());
}
private ApiToken update(ApiToken apiToken) {
- String sql = "UPDATE api_tokens SET token = ?, name = ?, last_used_at = ? WHERE id = ?";
+ String sql = "UPDATE api_tokens SET token = ?, name = ?, last_used_at = ?, device_id = ? WHERE id = ?";
int rowsAffected = jdbcTemplate.update(sql,
apiToken.getToken(),
apiToken.getName(),
apiToken.getLastUsedAt() != null ? Timestamp.from(apiToken.getLastUsedAt()) : null,
+ apiToken.getDevice() != null ? apiToken.getDevice().id() : null,
apiToken.getId()
);
@@ -124,13 +129,6 @@ public void delete(ApiToken apiToken) {
deleteById(apiToken.getId());
}
- @Transactional(readOnly = true)
- public boolean existsById(Long id) {
- String sql = "SELECT COUNT(*) FROM api_tokens WHERE id = ?";
- Integer count = jdbcTemplate.queryForObject(sql, Integer.class, id);
- return count != null && count > 0;
- }
-
@Transactional(readOnly = true)
public long count() {
String sql = "SELECT COUNT(*) FROM api_tokens";
@@ -150,10 +148,27 @@ private ApiToken mapRowToApiToken(ResultSet rs, int rowNum) throws SQLException
rs.getLong("user_version")
);
+
+ Device device = null;
+ if (rs.getObject("device_id") != null) {
+ device = new Device(
+ rs.getLong("device_id"),
+ rs.getString("device_name"),
+ rs.getBoolean("device_enabled"),
+ rs.getBoolean("device_show_on_map"),
+ rs.getBoolean("device_show_avatar_on_map"),
+ rs.getString("device_color"),
+ rs.getBoolean("default_device"),
+ rs.getTimestamp("device_created_at").toInstant(),
+ rs.getTimestamp("device_updated_at").toInstant(),
+ rs.getLong("device_version"));
+ }
+
return new ApiToken(
rs.getLong("id"),
rs.getString("token"),
user,
+ device,
rs.getString("name"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("last_used_at") != null ? rs.getTimestamp("last_used_at").toInstant() : null
@@ -163,13 +178,22 @@ private ApiToken mapRowToApiToken(ResultSet rs, int rowNum) throws SQLException
private ApiTokenUsage mapRowToApiUsage(ResultSet rs, int rowNum) throws SQLException {
return new ApiTokenUsage(rs.getString("token"),
rs.getString("name"),
+ rs.getString("device_name"),
rs.getTimestamp("at").toInstant(),
rs.getString("endpoint"),
rs.getString("ip"));
}
public List getUsages(User user, int maxRows) {
- return this.jdbcTemplate.query("SELECT t.token, t.name, au.at, au.endpoint, au.ip FROM api_tokens t RIGHT JOIN api_token_usages au on t.id = au.token_id WHERE t.user_id = ? ORDER BY au.at DESC LIMIT ?", this::mapRowToApiUsage, user.getId(), maxRows);
+ return this.jdbcTemplate.query("""
+ SELECT t.token, t.name, t.device_id, au.at, au.endpoint, au.ip, d.name as device_name
+ FROM api_token_usages au
+ LEFT JOIN api_tokens t ON t.id = au.token_id
+ LEFT JOIN devices d ON t.device_id = d.id
+ WHERE t.user_id = ?
+ ORDER BY au.at DESC LIMIT ?;
+ """,
+ this::mapRowToApiUsage, user.getId(), maxRows);
}
public void trackUsage(String token, String requestPath, String remoteIp) {
diff --git a/src/main/java/com/dedicatedcode/reitti/repository/DeviceJdbcService.java b/src/main/java/com/dedicatedcode/reitti/repository/DeviceJdbcService.java
new file mode 100644
index 000000000..6687c1221
--- /dev/null
+++ b/src/main/java/com/dedicatedcode/reitti/repository/DeviceJdbcService.java
@@ -0,0 +1,168 @@
+package com.dedicatedcode.reitti.repository;
+
+import com.dedicatedcode.reitti.model.devices.Device;
+import com.dedicatedcode.reitti.model.security.User;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Service;
+
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+@Service
+public class DeviceJdbcService {
+
+ private final JdbcTemplate jdbcTemplate;
+
+ private final RowMapper deviceRowMapper = (rs, rowNum) -> new Device(
+ rs.getLong("id"),
+ rs.getString("name"),
+ rs.getBoolean("enabled"),
+ rs.getBoolean("show_on_map"),
+ rs.getBoolean("show_avatar_on_map"),
+ rs.getString("color"),
+ rs.getBoolean("default_device"),
+ rs.getTimestamp("created_at").toInstant(),
+ rs.getTimestamp("updated_at").toInstant(),
+ rs.getLong("version")
+ );
+
+ public DeviceJdbcService(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ @CacheEvict(value = "devices", allEntries = true)
+ public Device save(Device device, User user) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+
+ jdbcTemplate.update(connection -> {
+ PreparedStatement ps = connection.prepareStatement(
+ "INSERT INTO devices (user_id, name, color, enabled, show_on_map, show_avatar_on_map, default_device, created_at, updated_at, version) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
+ Statement.RETURN_GENERATED_KEYS
+ );
+ ps.setLong(1, user.getId());
+ ps.setString(2, device.name());
+ ps.setString(3, device.color());
+ ps.setBoolean(4, device.enabled());
+ ps.setBoolean(5, device.showOnMap());
+ ps.setBoolean(6, device.showAvatarOnMap());
+ ps.setBoolean(7, device.defaultDevice());
+ ps.setTimestamp(8, Timestamp.from(device.createdAt()));
+ ps.setTimestamp(9, Timestamp.from(device.updatedAt()));
+ ps.setLong(10, 1L);
+ return ps;
+ }, keyHolder);
+
+ Number key = keyHolder.getKey();
+ return new Device(
+ key.longValue(),
+ device.name(),
+ device.enabled(),
+ device.showOnMap(),
+ device.showAvatarOnMap(),
+ device.color(),
+ device.defaultDevice(),
+ device.createdAt(),
+ device.updatedAt(),
+ 1L
+ );
+ }
+
+ @CacheEvict(value = "devices", allEntries = true)
+ public Device update(Device device, User user) {
+ int updated = jdbcTemplate.update(
+ "UPDATE devices SET name = ?, color = ?, default_device = ?, enabled = ?, show_on_map = ?, show_avatar_on_map = ?, updated_at = ?, version = version + 1 " +
+ "WHERE id = ? AND user_id = ?",
+ device.name(),
+ device.color(),
+ device.defaultDevice(),
+ device.enabled(),
+ device.showOnMap(),
+ device.showAvatarOnMap(),
+ Timestamp.from(Instant.now()),
+ device.id(),
+ user.getId()
+ );
+
+ if (updated == 0) {
+ throw new IllegalArgumentException("Device not found or not owned by user");
+ }
+
+ return new Device(
+ device.id(),
+ device.name(),
+ device.enabled(),
+ device.showOnMap(),
+ device.showAvatarOnMap(),
+ device.color(),
+ device.defaultDevice(),
+ device.createdAt(),
+ Instant.now(),
+ device.version() + 1
+ );
+ }
+
+ @CacheEvict(value = "devices", allEntries = true)
+ public void delete(Device device, User user) {
+ jdbcTemplate.update(
+ "DELETE FROM devices WHERE id = ? AND user_id = ?",
+ device.id(),
+ user.getId()
+ );
+ }
+
+ public List getAll(User user) {
+ return jdbcTemplate.query(
+ "SELECT * FROM devices WHERE user_id = ? ORDER BY default_device DESC, name",
+ deviceRowMapper,
+ user.getId()
+ );
+ }
+
+ public List getAllEnabled(User user) {
+ return jdbcTemplate.query(
+ "SELECT * FROM devices WHERE user_id = ? AND enabled = TRUE ORDER BY default_device DESC, name",
+ deviceRowMapper,
+ user.getId()
+ );
+ }
+
+ @Cacheable(value = "devices", key = "#token")
+ public Optional findByApiToken(String token) {
+ List results = jdbcTemplate.query(
+ "SELECT d.* FROM devices d " +
+ "JOIN api_tokens t ON t.device_id = d.id " +
+ "WHERE t.token = ?",
+ deviceRowMapper,
+ token
+ );
+ return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
+ }
+
+ public Optional