Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 78 additions & 43 deletions src/intervalssync/igpsport/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,28 @@

LOGIN_URL = INTERNATIONAL.login_url
GATEWAY = INTERNATIONAL.gateway_base
# iGPSPORT reports activity start times as "YYYY-MM-DD HH:MM:SS".
# iGPSPORT usually reports start times as "YYYY-MM-DD HH:MM:SS", but some
# accounts/regions return date-only dotted forms like "2026.07.19".
IGP_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
IGP_TIME_FORMATS = (
IGP_TIME_FORMAT,
"%Y-%m-%d",
"%Y.%m.%d %H:%M:%S",
"%Y.%m.%d",
)


def parse_igp_start_time(value: str) -> datetime | None:
"""Parse an iGPSPORT activity start time; return None if unrecognised."""
if not value or not isinstance(value, str):
return None
text = value.strip()
for fmt in IGP_TIME_FORMATS:
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None

# iGPSPORT exports every ride as the generic "Ride". intervals.icu doesn't take
# a sport on upload, so we PUT the desired type afterwards. An empty activity
Expand Down Expand Up @@ -72,9 +92,8 @@ def dropbox_filename_for(activity: "Activity", use_date: bool = True) -> str:
fallback = f"{external_id_for(activity.ride_id)}.fit"
if not use_date:
return fallback
try:
start = datetime.strptime(activity.start_time, IGP_TIME_FORMAT)
except (TypeError, ValueError):
start = parse_igp_start_time(activity.start_time)
if start is None:
return fallback
return f"ride-0-{start:%Y-%m-%d-%H-%M-%S}.fit"

Expand Down Expand Up @@ -187,42 +206,59 @@ def _list_activities_query(
max_activities: int,
region: IgpRegionConfig,
) -> list[Activity]:
"""queryMyActivity — camelCase rideId in data.rows (China and new intl accounts)."""
resp = session.get(
region.activity_query_url,
params={
"pageNo": "1",
"pageSize": str(max_activities),
"sort": "1",
"reqType": "0",
},
)
resp.raise_for_status()

data = resp.json().get("data") or {}
rows = (data.get("rows") if isinstance(data, dict) else None) or []
"""queryMyActivity across pages until max is reached or no more rows exist."""
# API responses are effectively paged in chunks of up to 20 items.
page_size = min(max_activities, 20)
activities: list[Activity] = []
for item in rows[:max_activities]:
if not isinstance(item, dict):
continue
ride_id = item.get("rideId")
if ride_id is None:
ride_id = item.get("RideId")
if ride_id is None:
continue
activities.append(
Activity(
ride_id=int(ride_id),
title=item.get("title") or item.get("Title") or f"iGPSPORT {ride_id}",
start_time=(
item.get("startTime")
or item.get("StartTime")
or item.get("startTimeString")
or item.get("StartTimeString")
or "unknown date"
),
)
page_no = 1

while len(activities) < max_activities:
resp = session.get(
region.activity_query_url,
params={
"pageNo": str(page_no),
"pageSize": str(page_size),
"sort": "1",
"reqType": "0",
},
)
resp.raise_for_status()

body = resp.json()
data = body.get("data") if isinstance(body, dict) else {}
rows = (data.get("rows") if isinstance(data, dict) else None) or []
if not rows:
break

for item in rows:
if len(activities) >= max_activities:
break
if not isinstance(item, dict):
continue
ride_id = item.get("rideId")
if ride_id is None:
ride_id = item.get("RideId")
if ride_id is None:
continue
activities.append(
Activity(
ride_id=int(ride_id),
title=item.get("title") or item.get("Title") or f"iGPSPORT {ride_id}",
start_time=(
item.get("startTime")
or item.get("StartTime")
or item.get("startTimeString")
or item.get("StartTimeString")
or "unknown date"
),
)
)

if len(rows) < page_size:
break

page_no += 1

return activities


Expand Down Expand Up @@ -285,10 +321,9 @@ def _activity_date_range(activities: list[Activity]) -> tuple[date, date]:
"""
dates: list[date] = []
for act in activities:
try:
dates.append(datetime.strptime(act.start_time, IGP_TIME_FORMAT).date())
except (ValueError, TypeError):
continue
start = parse_igp_start_time(act.start_time)
if start is not None:
dates.append(start.date())

if not dates:
today = date.today()
Expand Down Expand Up @@ -363,7 +398,7 @@ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
)
report(
f"{len(already_uploaded)} activities already on intervals.icu "
"in this date range."
f"in {oldest.isoformat()}…{newest.isoformat()}."
)
except requests.RequestException as exc:
report(f"⚠ Could not check intervals.icu (will process all): {exc}")
Expand Down
6 changes: 5 additions & 1 deletion src/intervalssync/intervals_icu.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ def fetch_uploaded_external_ids(
auth=("API_KEY", api_key),
)
resp.raise_for_status()
return {a["external_id"] for a in resp.json() if a.get("external_id")}
return {
a["external_id"]
for a in resp.json()
if isinstance(a, dict) and a.get("external_id")
}


def fetch_calendar_workouts(
Expand Down
77 changes: 67 additions & 10 deletions tests/test_igpsport_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ def test_activity_date_range_from_start_times():
assert newest == date(2026, 5, 29) # max + 1 day


def test_activity_date_range_parses_dotted_dates():
"""Some iGPSPORT accounts return date-only 'YYYY.MM.DD' start times."""
acts = [
core.Activity(1, "a", "2026.07.19"),
core.Activity(2, "b", "2026.05.03"),
]
oldest, newest = core._activity_date_range(acts)
assert oldest == date(2026, 5, 2)
assert newest == date(2026, 7, 20)


def test_parse_igp_start_time_formats():
assert core.parse_igp_start_time("2026-05-28 19:20:42").date() == date(2026, 5, 28)
assert core.parse_igp_start_time("2026.07.19").date() == date(2026, 7, 19)
assert core.parse_igp_start_time("unknown date") is None


def test_dropbox_filename_for_dotted_date():
act = core.Activity(1, "Ride", "2026.07.19")
assert core.dropbox_filename_for(act) == "ride-0-2026-07-19-00-00-00.fit"


def test_activity_date_range_fallback_when_unparseable():
acts = [core.Activity(1, "a", "unknown date")]
oldest, newest = core._activity_date_range(acts)
Expand Down Expand Up @@ -225,34 +247,69 @@ def get(self, url, params=None):


def test_list_activities_requests_full_page_and_caps():
"""pageSize is sent so we get more than the server's default 10, and the
result is still capped at max_activities as a safety belt."""
captured = {}
"""Paginates when the API caps each page at 20 rows."""
captured_pages: list[dict] = []

class FakeSession:
def get(self, url, params=None):
captured["url"] = url
captured["params"] = params
captured_pages.append(dict(params or {}))
page_no = int(params["pageNo"])
page_size = int(params["pageSize"])
start = (page_no - 1) * page_size
end = min(start + page_size, 50)
rows = [
{
"rideId": i,
"title": f"Ride {i}",
"startTime": "2026-05-28 19:20:42",
}
for i in range(50)
for i in range(start, end)
]
return FakeResponse(json_data={"data": {"rows": rows}})

acts = core.list_activities(FakeSession(), 50)
assert "queryMyActivity" in captured["url"]
assert captured["params"] == {
assert len(captured_pages) == 3
assert captured_pages[0]["pageNo"] == "1"
assert captured_pages[0]["pageSize"] == "20"
assert captured_pages[1]["pageNo"] == "2"
assert captured_pages[2]["pageNo"] == "3"
assert len(acts) == 50
assert acts[0].ride_id == 0
assert acts[-1].ride_id == 49


def test_list_activities_paginates_when_api_caps_page_size():
"""Real API returns at most 20 rows per page even when pageSize is larger."""
captured_pages: list[dict] = []

class FakeSession:
def get(self, url, params=None):
captured_pages.append(dict(params or {}))
page_no = int(params["pageNo"])
# Simulate iGPSPORT: always cap at 20 rows per page.
start = (page_no - 1) * 20
if start >= 35:
rows = []
else:
end = min(start + 20, 35)
rows = [
{"rideId": i, "title": f"Ride {i}", "startTime": "2026-05-28 19:20:42"}
for i in range(start, end)
]
return FakeResponse(json_data={"data": {"rows": rows}})

acts = core.list_activities(FakeSession(), 30)
assert len(captured_pages) == 2
assert captured_pages[0] == {
"pageNo": "1",
"pageSize": "50",
"pageSize": "20",
"sort": "1",
"reqType": "0",
}
assert len(acts) == 50
assert captured_pages[1]["pageNo"] == "2"
assert len(acts) == 30
assert acts[0].ride_id == 0
assert acts[-1].ride_id == 29


def test_list_activities_caps_when_server_returns_extra():
Expand Down
Loading