Skip to content

Commit 64c96d2

Browse files
committed
New features and refactoring
1 parent 705d04b commit 64c96d2

7 files changed

Lines changed: 448 additions & 20130 deletions

File tree

tcx2ics/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .tcx2ics import Tcx2Ics
1+
from .tcx2ics import Tcx2Ics, TcxData
22

3-
__all__ = ["Tcx2Ics"]
3+
__all__ = ["Tcx2Ics", "TcxData"]

tcx2ics/tcx2ics.py

Lines changed: 168 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,174 @@
1+
import os
2+
import uuid
3+
from dataclasses import dataclass
14
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)
421

522

623
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,
52117
)
53118

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")

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pytest
2+
import pathlib
3+
4+
FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
5+
6+
7+
@pytest.fixture
8+
def sample_tcx():
9+
"""Path to a minimal but valid TCX fixture file."""
10+
return str(FIXTURES_DIR / "sample.tcx")
11+
12+
13+
@pytest.fixture
14+
def sample_tcx_content():
15+
"""Raw text content of the sample TCX fixture."""
16+
return (FIXTURES_DIR / "sample.tcx").read_text()

0 commit comments

Comments
 (0)