using System.Xml.Linq; namespace Server.WatchBody.Import; /// /// Упрощённый разбор .tcx/.gpx (производные форматы — документ /// форматов): тип/старт/длительность + пульс-записи; подходов в этих /// форматах нет. fit_file_id — имя файла (дедупликация). /// public class TcxGpxParser { public ActivityFacts Parse(string path) { var name = Path.GetFileName(path); XDocument doc; try { doc = XDocument.Load(path); } catch (Exception e) { throw new FitParseException($"XML не читается: {name} — {e.Message}"); } return doc.Root!.Name.LocalName == "TrainingCenterDatabase" ? ParseTcx(doc, name) : ParseGpx(doc, name); } private static ActivityFacts ParseTcx(XDocument doc, string name) { var ns = doc.Root!.Name.Namespace; var activity = doc.Descendants(ns + "Activity").FirstOrDefault() ?? throw new FitParseException($"в TCX нет Activity: {name}"); var id = (string?)activity.Element(ns + "Id"); var start = ParseStart(id) ?? throw new FitParseException($"в TCX нет Id-старта: {name}"); var records = activity.Descendants(ns + "Trackpoint") .Select(tp => new { time = DateTime.TryParse((string?)tp.Element(ns + "Time"), out var t) ? t.ToUniversalTime() : (DateTime?)null, hr = (int?)tp.Element(ns + "HeartRateBpm")?.Element(ns + "Value"), }) .Where(r => r.time != null && r.hr != null && r.hr > 0) .Select(r => (time: r.time!.Value, hr: r.hr!.Value)) .ToList(); var durationS = (int)((records.Count > 0 ? records.Max(r => r.time) : start) - start).TotalSeconds; var type = (string?)activity.Attribute("Sport") ?? "unknown"; return Build(name, start, durationS, type.ToLowerInvariant(), records, device: string.Empty); } private static ActivityFacts ParseGpx(XDocument doc, string name) { var start = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "time") is { } t ? DateTime.Parse(t.Value).ToUniversalTime() : throw new FitParseException($"в GPX нет времени: {name}"); var records = doc.Descendants() .Where(e => e.Name.LocalName is "trkpt" or "wpt") .Select(pt => new { time = pt.Descendants().FirstOrDefault(x => x.Name.LocalName == "time") is { } ptTime && DateTime.TryParse(ptTime.Value, out var ptParsed) ? ptParsed.ToUniversalTime() : (DateTime?)null, hr = pt.Descendants().FirstOrDefault(x => x.Name.LocalName is "hr" or "gpxtpx:hr" || x.Name.LocalName.EndsWith("hr")) is { } hrEl && int.TryParse(hrEl.Value, out var hr) ? hr : (int?)null, }) .Where(r => r.time != null && r.hr != null && r.hr > 0) .Select(r => (time: r.time!.Value, hr: r.hr!.Value)) .ToList(); var durationS = (int)((records.Count > 0 ? records.Max(r => r.time) : start) - start).TotalSeconds; return Build(name, start, durationS, "unknown", records, device: string.Empty); } internal static ActivityFacts Build(string fileId, DateTime start, int durationS, string type, List<(DateTime time, int hr)> records, string device) { var facts = new Dictionary { ["schema"] = "workout-facts/1", ["session"] = new Dictionary { ["type"] = type, ["start"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), ["duration_s"] = durationS, }, ["laps"] = Array.Empty(), ["records"] = records.Select(r => new Dictionary { ["t_offset_s"] = (int)(r.time - start).TotalSeconds, ["hr"] = r.hr, }).ToList(), ["sets"] = Array.Empty(), ["fit_file_id"] = fileId, ["device"] = device, }; int? avgHr = records.Count == 0 ? null : (int)Math.Round(records.Average(r => r.hr), MidpointRounding.AwayFromZero); return new ActivityFacts( System.Text.Json.JsonSerializer.Serialize(facts), start, avgHr, null, fileId, device); } private static DateTime? ParseStart(string? text) => text != null && DateTime.TryParse(text, out var value) ? value.ToUniversalTime() : null; }