|
| 1 | +import os |
| 2 | +import uuid |
| 3 | +from dataclasses import dataclass |
1 | 4 | from datetime import datetime, timedelta, timezone |
2 | | -from pathlib import Path |
3 | | -from tcxreader.tcxreader import TCXReader |
| 5 | +from xml.etree import ElementTree as ET |
| 6 | + |
| 7 | +TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" |
| 8 | + |
| 9 | +@dataclass |
| 10 | +class TcxData: |
| 11 | + """Structured result from parsing a TCX file.""" |
| 12 | + |
| 13 | + start_time: datetime |
| 14 | + duration_seconds: float |
| 15 | + distance_km: float |
| 16 | + sport: str |
| 17 | + |
| 18 | + @property |
| 19 | + def end_time(self) -> datetime: |
| 20 | + return self.start_time + timedelta(seconds=self.duration_seconds) |
4 | 21 |
|
5 | 22 |
|
6 | 23 | class Tcx2Ics: |
7 | | - def __init__(self): |
8 | | - self.reader = TCXReader() |
9 | | - |
10 | | - def convert(self, tcx_file: str, ics_file: str): |
11 | | - ex = self.reader.read(tcx_file) |
12 | | - |
13 | | - sport = getattr(ex, "activity_type", None) or getattr(ex, "sport", None) or "Workout" |
14 | | - start = getattr(ex, "start_time", None) |
15 | | - duration = float(getattr(ex, "duration", 0) or 0) # seconds |
16 | | - distance = float(getattr(ex, "distance", 0) or 0) # meters |
17 | | - |
18 | | - if not isinstance(start, datetime): |
19 | | - raise ValueError("No start_time found in TCX") |
20 | | - |
21 | | - if start.tzinfo is None: |
22 | | - start = start.replace(tzinfo=timezone.utc) |
23 | | - else: |
24 | | - start = start.astimezone(timezone.utc) |
25 | | - |
26 | | - end = start + timedelta(seconds=duration) |
27 | | - |
28 | | - dist_km = distance / 1000.0 |
29 | | - dur_h = int(duration) // 3600 |
30 | | - dur_m = (int(duration) % 3600) // 60 |
31 | | - dur_s = int(duration) % 60 |
32 | | - |
33 | | - summary = f"{sport} - {dist_km:.2f} km" |
34 | | - desc = f"Sport: {sport}\\nTotal duration: {dur_h}:{dur_m:02d}:{dur_s:02d}\\nDistance: {dist_km:.2f} km" |
35 | | - |
36 | | - uid = f"{Path(tcx_file).stem}-{start.strftime('%Y%m%dT%H%M%SZ')}@tcx2ics" |
37 | | - now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") |
38 | | - |
39 | | - ics = ( |
40 | | - "BEGIN:VCALENDAR\r\n" |
41 | | - "VERSION:2.0\r\n" |
42 | | - "PRODID:-//tcx2ics//EN\r\n" |
43 | | - "BEGIN:VEVENT\r\n" |
44 | | - f"UID:{uid}\r\n" |
45 | | - f"DTSTAMP:{now}\r\n" |
46 | | - f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\r\n" |
47 | | - f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\r\n" |
48 | | - f"SUMMARY:{summary}\r\n" |
49 | | - f"DESCRIPTION:{desc}\r\n" |
50 | | - "END:VEVENT\r\n" |
51 | | - "END:VCALENDAR\r\n" |
| 24 | + """Convert a TCX workout file to an ICS calendar event.""" |
| 25 | + |
| 26 | + # ------------------------------------------------------------------ |
| 27 | + # Public API |
| 28 | + # ------------------------------------------------------------------ |
| 29 | + |
| 30 | + def convert(self, tcx_path: str, ics_path: str) -> None: |
| 31 | + """ |
| 32 | + Parse *tcx_path* and write a calendar event to *ics_path*. |
| 33 | +
|
| 34 | + Raises |
| 35 | + ------ |
| 36 | + FileNotFoundError |
| 37 | + If *tcx_path* does not exist on disk. |
| 38 | + ValueError |
| 39 | + If *tcx_path* does not have a .tcx extension. |
| 40 | + """ |
| 41 | + self._validate_input(tcx_path) |
| 42 | + data = self._parse(tcx_path) |
| 43 | + self._write_ics(data, ics_path) |
| 44 | + |
| 45 | + def parse(self, tcx_path: str) -> TcxData: |
| 46 | + """ |
| 47 | + Parse *tcx_path* and return a :class:`TcxData` instance. |
| 48 | +
|
| 49 | + Raises |
| 50 | + ------ |
| 51 | + FileNotFoundError |
| 52 | + If *tcx_path* does not exist on disk. |
| 53 | + ValueError |
| 54 | + If *tcx_path* does not have a .tcx extension. |
| 55 | + """ |
| 56 | + self._validate_input(tcx_path) |
| 57 | + return self._parse(tcx_path) |
| 58 | + |
| 59 | + # ------------------------------------------------------------------ |
| 60 | + # Validation |
| 61 | + # ------------------------------------------------------------------ |
| 62 | + |
| 63 | + @staticmethod |
| 64 | + def _validate_input(tcx_path: str) -> None: |
| 65 | + """Raise early with clear messages on bad input.""" |
| 66 | + if not tcx_path.lower().endswith(".tcx"): |
| 67 | + raise ValueError( |
| 68 | + f"Expected a .tcx file, got: '{tcx_path}'. " |
| 69 | + "Please supply a Garmin TCX file." |
| 70 | + ) |
| 71 | + if not os.path.isfile(tcx_path): |
| 72 | + raise FileNotFoundError( |
| 73 | + f"TCX file not found: '{tcx_path}'. " |
| 74 | + "Check that the path is correct." |
| 75 | + ) |
| 76 | + |
| 77 | + # ------------------------------------------------------------------ |
| 78 | + # Parsing |
| 79 | + # ------------------------------------------------------------------ |
| 80 | + |
| 81 | + @staticmethod |
| 82 | + def _ns(tag: str) -> str: |
| 83 | + return f"{{{TCX_NS}}}{tag}" |
| 84 | + |
| 85 | + def _parse(self, tcx_path: str) -> TcxData: |
| 86 | + tree = ET.parse(tcx_path) |
| 87 | + root = tree.getroot() |
| 88 | + |
| 89 | + activity = root.find(f".//{self._ns('Activity')}") |
| 90 | + if activity is None: |
| 91 | + raise ValueError("No <Activity> element found in TCX file.") |
| 92 | + |
| 93 | + sport = activity.get("Sport", "Workout") |
| 94 | + |
| 95 | + # Start time from the first Lap's StartTime attribute |
| 96 | + lap = activity.find(self._ns("Lap")) |
| 97 | + if lap is None: |
| 98 | + raise ValueError("No <Lap> element found in TCX file.") |
| 99 | + |
| 100 | + start_time_str = lap.get("StartTime") or "" |
| 101 | + start_time = self._parse_datetime(start_time_str) |
| 102 | + |
| 103 | + total_seconds = float( |
| 104 | + self._find_text(lap, "TotalTimeSeconds") or "0" |
| 105 | + ) |
| 106 | + |
| 107 | + distance_m = float( |
| 108 | + self._find_text(lap, "DistanceMeters") or "0" |
| 109 | + ) |
| 110 | + distance_km = distance_m / 1000.0 |
| 111 | + |
| 112 | + return TcxData( |
| 113 | + start_time=start_time, |
| 114 | + duration_seconds=total_seconds, |
| 115 | + distance_km=distance_km, |
| 116 | + sport=sport, |
52 | 117 | ) |
53 | 118 |
|
54 | | - Path(ics_file).write_text(ics, encoding="utf-8") |
| 119 | + def _find_text(self, parent: ET.Element, tag: str) -> str | None: |
| 120 | + el = parent.find(self._ns(tag)) |
| 121 | + return el.text if el is not None else None |
| 122 | + |
| 123 | + @staticmethod |
| 124 | + def _parse_datetime(value: str) -> datetime: |
| 125 | + """Parse ISO-8601 string to timezone-aware datetime.""" |
| 126 | + value = value.rstrip("Z") |
| 127 | + dt = datetime.fromisoformat(value) |
| 128 | + if dt.tzinfo is None: |
| 129 | + dt = dt.replace(tzinfo=timezone.utc) |
| 130 | + return dt |
| 131 | + |
| 132 | + # ------------------------------------------------------------------ |
| 133 | + # ICS output |
| 134 | + # ------------------------------------------------------------------ |
| 135 | + |
| 136 | + @staticmethod |
| 137 | + def _fmt_dt(dt: datetime) -> str: |
| 138 | + """Format datetime as iCalendar UTC timestamp (YYYYMMDDTHHMMSSZ).""" |
| 139 | + utc = dt.astimezone(timezone.utc) |
| 140 | + return utc.strftime("%Y%m%dT%H%M%SZ") |
| 141 | + |
| 142 | + def _write_ics(self, data: TcxData, ics_path: str) -> None: |
| 143 | + summary = ( |
| 144 | + f"{data.sport} — " |
| 145 | + f"{data.distance_km:.1f} km, " |
| 146 | + f"{int(data.duration_seconds // 60)} min" |
| 147 | + ) |
| 148 | + description = ( |
| 149 | + f"Sport: {data.sport}\\n" |
| 150 | + f"Distance: {data.distance_km:.2f} km\\n" |
| 151 | + f"Duration: {int(data.duration_seconds // 60)} min" |
| 152 | + ) |
| 153 | + uid = str(uuid.uuid4()) |
| 154 | + now = self._fmt_dt(datetime.now(tz=timezone.utc)) |
| 155 | + |
| 156 | + lines = [ |
| 157 | + "BEGIN:VCALENDAR", |
| 158 | + "VERSION:2.0", |
| 159 | + "PRODID:-//tcx2ics//tcx2ics//EN", |
| 160 | + "CALSCALE:GREGORIAN", |
| 161 | + "METHOD:PUBLISH", |
| 162 | + "BEGIN:VEVENT", |
| 163 | + f"UID:{uid}", |
| 164 | + f"DTSTAMP:{now}", |
| 165 | + f"DTSTART:{self._fmt_dt(data.start_time)}", |
| 166 | + f"DTEND:{self._fmt_dt(data.end_time)}", |
| 167 | + f"SUMMARY:{summary}", |
| 168 | + f"DESCRIPTION:{description}", |
| 169 | + "END:VEVENT", |
| 170 | + "END:VCALENDAR", |
| 171 | + ] |
| 172 | + |
| 173 | + with open(ics_path, "w", encoding="utf-8") as fh: |
| 174 | + fh.write("\r\n".join(lines) + "\r\n") |
0 commit comments