// 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;