VEU.WathBody/3-app/2-cli/Server.WatchBody.Import/TcxGpxParser.cs

111 lines
4.7 KiB
C#

using System.Xml.Linq;
namespace Server.WatchBody.Import;
/// <summary>
/// Упрощённый разбор .tcx/.gpx (производные форматы — документ
/// форматов): тип/старт/длительность + пульс-записи; подходов в этих
/// форматах нет. fit_file_id — имя файла (дедупликация).
/// </summary>
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<string, object?>
{
["schema"] = "workout-facts/1",
["session"] = new Dictionary<string, object?>
{
["type"] = type,
["start"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"),
["duration_s"] = durationS,
},
["laps"] = Array.Empty<object>(),
["records"] = records.Select(r => new Dictionary<string, object?>
{
["t_offset_s"] = (int)(r.time - start).TotalSeconds,
["hr"] = r.hr,
}).ToList(),
["sets"] = Array.Empty<object>(),
["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;
}