298 lines
6.7 KiB
JavaScript
298 lines
6.7 KiB
JavaScript
// api_v1/scanner/parsers/parseTcx.js
|
|
const fsp = require('fs/promises');
|
|
const xml2js = require('xml2js');
|
|
|
|
function toMillis(date) {
|
|
if (!date) return null;
|
|
const d = new Date(date);
|
|
return isNaN(d.getTime()) ? null : d.getTime();
|
|
}
|
|
|
|
function buildTrackPreviewFromTcxPoints(points, maxPoints = 300) {
|
|
if (!Array.isArray(points)) return [];
|
|
|
|
const clean = [];
|
|
|
|
for (const p of points) {
|
|
const pos = p.Position || {};
|
|
|
|
const lat = pos.LatitudeDegrees != null
|
|
? Number(pos.LatitudeDegrees)
|
|
: null;
|
|
|
|
const lng = pos.LongitudeDegrees != null
|
|
? Number(pos.LongitudeDegrees)
|
|
: null;
|
|
|
|
const alt = p.AltitudeMeters != null
|
|
? Number(p.AltitudeMeters)
|
|
: null;
|
|
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
|
|
|
const point = { lat, lng };
|
|
|
|
if (Number.isFinite(alt)) {
|
|
point.alt = alt;
|
|
}
|
|
|
|
clean.push(point);
|
|
}
|
|
|
|
if (clean.length <= maxPoints) return clean;
|
|
|
|
const step = Math.ceil(clean.length / maxPoints);
|
|
const preview = [];
|
|
|
|
for (let i = 0; i < clean.length; i += step) {
|
|
preview.push(clean[i]);
|
|
}
|
|
|
|
const last = clean[clean.length - 1];
|
|
const currentLast = preview[preview.length - 1];
|
|
|
|
if (
|
|
!currentLast ||
|
|
currentLast.lat !== last.lat ||
|
|
currentLast.lng !== last.lng
|
|
) {
|
|
preview.push(last);
|
|
}
|
|
|
|
return preview;
|
|
}
|
|
|
|
async function parseTcx(absPath) {
|
|
const xml = await fsp.readFile(absPath, 'utf8');
|
|
|
|
const parser = new xml2js.Parser({ explicitArray: false });
|
|
const tcx = await parser.parseStringPromise(xml);
|
|
|
|
const root = tcx.TrainingCenterDatabase || tcx;
|
|
|
|
const activities = root.Activities?.Activity
|
|
? (
|
|
Array.isArray(root.Activities.Activity)
|
|
? root.Activities.Activity
|
|
: [root.Activities.Activity]
|
|
)
|
|
: [];
|
|
|
|
const activity = activities[0] || {};
|
|
const sport = activity.$?.Sport || null;
|
|
|
|
const laps = activity.Lap
|
|
? (
|
|
Array.isArray(activity.Lap)
|
|
? activity.Lap
|
|
: [activity.Lap]
|
|
)
|
|
: [];
|
|
|
|
const tracks = [];
|
|
|
|
for (const lap of laps) {
|
|
const track = lap.Track
|
|
? (
|
|
Array.isArray(lap.Track)
|
|
? lap.Track
|
|
: [lap.Track]
|
|
)
|
|
: [];
|
|
|
|
tracks.push(...track);
|
|
}
|
|
|
|
const points = [];
|
|
|
|
for (const t of tracks) {
|
|
const tp = t.Trackpoint
|
|
? (
|
|
Array.isArray(t.Trackpoint)
|
|
? t.Trackpoint
|
|
: [t.Trackpoint]
|
|
)
|
|
: [];
|
|
|
|
points.push(...tp);
|
|
}
|
|
|
|
let minLat = null;
|
|
let maxLat = null;
|
|
let minLon = null;
|
|
let maxLon = null;
|
|
let minElev = null;
|
|
let maxElev = null;
|
|
|
|
let startTime = null;
|
|
let endTime = null;
|
|
|
|
let totalDistance = 0;
|
|
let totalCalories = 0;
|
|
|
|
let avgHr = null;
|
|
let maxHr = null;
|
|
|
|
for (const lap of laps) {
|
|
if (!startTime && lap.StartTime) {
|
|
startTime = lap.StartTime;
|
|
}
|
|
|
|
if (lap.TotalTimeSeconds && lap.StartTime) {
|
|
const lapEnd = new Date(lap.StartTime);
|
|
lapEnd.setSeconds(lapEnd.getSeconds() + Number(lap.TotalTimeSeconds));
|
|
endTime = lapEnd.toISOString();
|
|
}
|
|
|
|
if (lap.DistanceMeters) {
|
|
totalDistance += Number(lap.DistanceMeters);
|
|
}
|
|
|
|
if (lap.Calories) {
|
|
totalCalories += Number(lap.Calories);
|
|
}
|
|
|
|
const lapAvgHr = lap.AverageHeartRateBpm?.Value
|
|
? Number(lap.AverageHeartRateBpm.Value)
|
|
: null;
|
|
|
|
const lapMaxHr = lap.MaximumHeartRateBpm?.Value
|
|
? Number(lap.MaximumHeartRateBpm.Value)
|
|
: null;
|
|
|
|
if (Number.isFinite(lapAvgHr)) {
|
|
avgHr = avgHr == null ? lapAvgHr : (avgHr + lapAvgHr) / 2;
|
|
}
|
|
|
|
if (Number.isFinite(lapMaxHr)) {
|
|
maxHr = maxHr == null ? lapMaxHr : Math.max(maxHr, lapMaxHr);
|
|
}
|
|
}
|
|
|
|
for (const p of points) {
|
|
const pos = p.Position || {};
|
|
|
|
const lat = pos.LatitudeDegrees != null
|
|
? Number(pos.LatitudeDegrees)
|
|
: null;
|
|
|
|
const lon = pos.LongitudeDegrees != null
|
|
? Number(pos.LongitudeDegrees)
|
|
: null;
|
|
|
|
const ele = p.AltitudeMeters != null
|
|
? Number(p.AltitudeMeters)
|
|
: null;
|
|
|
|
const time = p.Time || null;
|
|
|
|
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
minLat = minLat == null ? lat : Math.min(minLat, lat);
|
|
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
|
|
minLon = minLon == null ? lon : Math.min(minLon, lon);
|
|
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
|
|
}
|
|
|
|
if (Number.isFinite(ele)) {
|
|
minElev = minElev == null ? ele : Math.min(minElev, ele);
|
|
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
|
|
}
|
|
|
|
if (time) {
|
|
if (!startTime) startTime = time;
|
|
endTime = time;
|
|
}
|
|
}
|
|
|
|
const start_time_millis = toMillis(startTime);
|
|
const end_time_millis = toMillis(endTime);
|
|
|
|
const duration_millis =
|
|
start_time_millis && end_time_millis
|
|
? end_time_millis - start_time_millis
|
|
: null;
|
|
|
|
const centerLat =
|
|
minLat != null && maxLat != null
|
|
? (minLat + maxLat) / 2
|
|
: null;
|
|
|
|
const centerLon =
|
|
minLon != null && maxLon != null
|
|
? (minLon + maxLon) / 2
|
|
: null;
|
|
|
|
const trackPreview = buildTrackPreviewFromTcxPoints(points, 300);
|
|
|
|
return {
|
|
format: 'TCX',
|
|
|
|
display_name: activity.Notes || null,
|
|
route_name: activity.Notes || null,
|
|
title: activity.Notes || null,
|
|
description: null,
|
|
notes: activity.Notes || null,
|
|
|
|
sport,
|
|
activity_type: null,
|
|
device_manufacturer: root.Author?.Name || null,
|
|
device_model: root.Author?.Build?.Version?.VersionMajor || null,
|
|
source_app: root.Author?.Name || null,
|
|
|
|
start_time_millis,
|
|
end_time_millis,
|
|
duration_millis,
|
|
|
|
distance_meters: totalDistance || null,
|
|
elevation_gain_meters: null,
|
|
elevation_loss_meters: null,
|
|
|
|
min_elevation_meters: minElev,
|
|
max_elevation_meters: maxElev,
|
|
|
|
file_distance_meters: totalDistance || null,
|
|
file_elevation_gain_meters: null,
|
|
|
|
computed_distance_meters: totalDistance || null,
|
|
computed_elevation_gain_meters: null,
|
|
|
|
average_speed_mps: null,
|
|
max_speed_mps: null,
|
|
average_pace_sec_per_km: null,
|
|
|
|
average_heart_rate: avgHr,
|
|
max_heart_rate: maxHr,
|
|
average_cadence: null,
|
|
max_cadence: null,
|
|
|
|
calories: totalCalories || null,
|
|
power_avg: null,
|
|
power_max: null,
|
|
|
|
point_count: points.length,
|
|
segment_count: tracks.length,
|
|
lap_count: laps.length,
|
|
|
|
min_latitude: minLat,
|
|
max_latitude: maxLat,
|
|
min_longitude: minLon,
|
|
max_longitude: maxLon,
|
|
center_latitude: centerLat,
|
|
center_longitude: centerLon,
|
|
|
|
// Preview ridotta del percorso per i thumb della gallery
|
|
track_preview: trackPreview,
|
|
|
|
tags: [],
|
|
|
|
diagnostics: {
|
|
source: 'TCX',
|
|
laps: laps.length,
|
|
tracks: tracks.length,
|
|
points: points.length,
|
|
track_preview_points: trackPreview.length
|
|
}
|
|
};
|
|
}
|
|
|
|
module.exports = parseTcx;
|