intervalssync 0.9.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. intervalssync/__init__.py +3 -0
  2. intervalssync/_dropbox_app_key.py +7 -0
  3. intervalssync/_version.py +1 -0
  4. intervalssync/bryton/__init__.py +1 -0
  5. intervalssync/bryton/api.py +49 -0
  6. intervalssync/bryton/core.py +249 -0
  7. intervalssync/bryton/ddp.py +231 -0
  8. intervalssync/bryton/exceptions.py +13 -0
  9. intervalssync/bryton/fit_encode.py +363 -0
  10. intervalssync/bryton/workout.py +342 -0
  11. intervalssync/cli/__init__.py +5 -0
  12. intervalssync/cli/config.py +53 -0
  13. intervalssync/cli/env.py +189 -0
  14. intervalssync/cli/main.py +637 -0
  15. intervalssync/dropbox_client.py +143 -0
  16. intervalssync/gui/__init__.py +1 -0
  17. intervalssync/gui/app.py +352 -0
  18. intervalssync/gui/config.py +102 -0
  19. intervalssync/gui/profile_sync_ui.py +225 -0
  20. intervalssync/gui/secrets.py +135 -0
  21. intervalssync/gui/settings_view.py +716 -0
  22. intervalssync/gui/support_gamification.py +658 -0
  23. intervalssync/gui/sync_view.py +486 -0
  24. intervalssync/gui/system.py +21 -0
  25. intervalssync/gui/theme.py +216 -0
  26. intervalssync/gui/update_check.py +55 -0
  27. intervalssync/igpsport/__init__.py +1 -0
  28. intervalssync/igpsport/core.py +520 -0
  29. intervalssync/igpsport/interval_info.py +269 -0
  30. intervalssync/igpsport/profile_sync.py +391 -0
  31. intervalssync/igpsport/region.py +80 -0
  32. intervalssync/igpsport/workout.py +503 -0
  33. intervalssync/igpsport/zone_map.py +143 -0
  34. intervalssync/intervals_icu.py +201 -0
  35. intervalssync-0.9.3.dist-info/METADATA +179 -0
  36. intervalssync-0.9.3.dist-info/RECORD +39 -0
  37. intervalssync-0.9.3.dist-info/WHEEL +4 -0
  38. intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
  39. intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,520 @@
1
+ """Pure sync logic for moving activities from iGPSPORT to intervals.icu.
2
+
3
+ This module contains no UI and no module-level side effects — it is driven by
4
+ the CLI and the GUI alike. Progress is surfaced through a callback so each
5
+ front-end can render it however it likes.
6
+
7
+ Flow (see CLAUDE.md for the full API notes):
8
+ 1. Log in to iGPSPORT via the mobile auth API; reuse access_token as Bearer.
9
+ 2. List activities via queryMyActivity (camelCase rideId / title / startTime).
10
+ 3. Resolve the FIT download URL: try queryActivityDetail, fall back to
11
+ getDownloadUrl.
12
+ 4. Download the .fit file and upload it to intervals.icu (basic auth with the
13
+ literal username "API_KEY").
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from datetime import date, datetime, timedelta
20
+ from pathlib import Path
21
+ from typing import Callable
22
+
23
+ import requests
24
+
25
+ from ..dropbox_client import (
26
+ DEFAULT_DROPBOX_FOLDER,
27
+ list_dropbox_fit_names,
28
+ upload_to_dropbox,
29
+ )
30
+ from ..intervals_icu import (
31
+ fetch_uploaded_external_ids as icu_fetch_uploaded_external_ids,
32
+ set_activity_type,
33
+ upload_fit_file,
34
+ )
35
+
36
+ from .region import INTERNATIONAL, IgpRegionConfig, resolve_region
37
+
38
+ LOGIN_URL = INTERNATIONAL.login_url
39
+ GATEWAY = INTERNATIONAL.gateway_base
40
+ # iGPSPORT usually reports start times as "YYYY-MM-DD HH:MM:SS", but some
41
+ # accounts/regions return date-only dotted forms like "2026.07.19".
42
+ IGP_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
43
+ IGP_TIME_FORMATS = (
44
+ IGP_TIME_FORMAT,
45
+ "%Y-%m-%d",
46
+ "%Y.%m.%d %H:%M:%S",
47
+ "%Y.%m.%d",
48
+ )
49
+
50
+
51
+ def parse_igp_start_time(value: str) -> datetime | None:
52
+ """Parse an iGPSPORT activity start time; return None if unrecognised."""
53
+ if not value or not isinstance(value, str):
54
+ return None
55
+ text = value.strip()
56
+ for fmt in IGP_TIME_FORMATS:
57
+ try:
58
+ return datetime.strptime(text, fmt)
59
+ except ValueError:
60
+ continue
61
+ return None
62
+
63
+ # iGPSPORT exports every ride as the generic "Ride". intervals.icu doesn't take
64
+ # a sport on upload, so we PUT the desired type afterwards. An empty activity
65
+ # type means "leave the uploaded sport untouched" (do nothing). Any non-empty
66
+ # value — including "Ride" — is applied via PUT.
67
+ ACTIVITY_TYPE_KEEP = ""
68
+
69
+ # Cycling subset of the intervals.icu SportInfo enum (api_value, label). The API
70
+ # does NOT validate the string, so only ever send values from this list.
71
+ CYCLING_ACTIVITY_TYPES: list[tuple[str, str]] = [
72
+ ("Ride", "Ride"),
73
+ ("MountainBikeRide", "Mountain Bike Ride"),
74
+ ("GravelRide", "Gravel Ride"),
75
+ ("VirtualRide", "Virtual Ride"),
76
+ ("EBikeRide", "E-Bike Ride"),
77
+ ("EMountainBikeRide", "E-Mountain Bike Ride"),
78
+ ("TrackRide", "Track Ride"),
79
+ ("Cyclocross", "Cyclocross"),
80
+ ("Handcycle", "Handcycle"),
81
+ ("Velomobile", "Velomobile"),
82
+ ]
83
+
84
+
85
+ def external_id_for(ride_id: int) -> str:
86
+ """The intervals.icu external_id we assign to an iGPSPORT ride."""
87
+ return f"igpsport_{ride_id}"
88
+
89
+
90
+ def dropbox_filename_for(activity: "Activity", use_date: bool = True) -> str:
91
+ """Return the Dropbox filename for an activity."""
92
+ fallback = f"{external_id_for(activity.ride_id)}.fit"
93
+ if not use_date:
94
+ return fallback
95
+ start = parse_igp_start_time(activity.start_time)
96
+ if start is None:
97
+ return fallback
98
+ return f"ride-0-{start:%Y-%m-%d-%H-%M-%S}.fit"
99
+
100
+
101
+ class SyncError(Exception):
102
+ """Base class for errors the front-ends can show as friendly messages."""
103
+
104
+
105
+ class AuthError(SyncError):
106
+ """Raised when iGPSPORT login fails or returns no usable token."""
107
+
108
+
109
+ @dataclass
110
+ class Activity:
111
+ ride_id: int
112
+ title: str
113
+ start_time: str
114
+
115
+
116
+ @dataclass
117
+ class SyncResult:
118
+ listed: int = 0
119
+ downloaded: int = 0
120
+ uploaded: int = 0
121
+ uploaded_dropbox: int = 0
122
+ skipped: int = 0
123
+ skipped_dropbox: int = 0
124
+ failed: int = 0
125
+ failed_dropbox: int = 0
126
+ activities: list[Activity] = field(default_factory=list)
127
+
128
+
129
+ # A progress callback receives short, human-readable status events. It is
130
+ # optional everywhere; the default is a no-op so core stays silent by itself.
131
+ Progress = Callable[[str], None]
132
+
133
+
134
+ def _noop(_message: str) -> None:
135
+ pass
136
+
137
+
138
+ def login(
139
+ session: requests.Session,
140
+ user: str,
141
+ password: str,
142
+ region: IgpRegionConfig | str | None = None,
143
+ ) -> dict[str, str]:
144
+ """Authenticate and return auth headers carrying the Bearer token."""
145
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
146
+ try:
147
+ return _login_bearer_token(session, cfg.login_url, user, password)
148
+ except AuthError as exc:
149
+ if cfg.name == "international":
150
+ raise AuthError(f"{exc}{_INTL_LOGIN_RETRY_HINT}") from exc
151
+ raise
152
+
153
+
154
+ _CHINA_REGION_HINT = (
155
+ " If your account is registered on app.igpsport.cn, set region to China in Settings."
156
+ )
157
+
158
+ _INTL_LOGIN_RETRY_HINT = (
159
+ " Check your email and password (international accounts use your email)."
160
+ + _CHINA_REGION_HINT
161
+ )
162
+
163
+
164
+ def _login_bearer_token(
165
+ session: requests.Session,
166
+ login_url: str,
167
+ user: str,
168
+ password: str,
169
+ ) -> dict[str, str]:
170
+ """Mobile API login; returns Bearer headers from access_token JSON field."""
171
+ resp = session.post(
172
+ login_url,
173
+ json={"appId": "igpsport-web", "username": user, "password": password},
174
+ )
175
+ try:
176
+ body = resp.json()
177
+ except ValueError as exc:
178
+ raise AuthError(f"iGPSPORT login failed: HTTP {resp.status_code}, non-JSON body") from exc
179
+
180
+ if not isinstance(body, dict):
181
+ raise AuthError("iGPSPORT login returned an unexpected response")
182
+
183
+ data = body.get("data") or {}
184
+ token = data.get("access_token") if isinstance(data, dict) else None
185
+ if not token:
186
+ message = body.get("message") or f"HTTP {resp.status_code}"
187
+ raise AuthError(f"iGPSPORT login failed: {message}")
188
+
189
+ auth_headers = {"Authorization": f"Bearer {token}"}
190
+ session.headers.update(auth_headers)
191
+ return auth_headers
192
+
193
+
194
+ def list_activities(
195
+ session: requests.Session,
196
+ max_activities: int,
197
+ region: IgpRegionConfig | str | None = None,
198
+ ) -> list[Activity]:
199
+ """Return the most recent activities, newest first, capped at max_activities."""
200
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
201
+ return _list_activities_query(session, max_activities, cfg)
202
+
203
+
204
+ def _list_activities_query(
205
+ session: requests.Session,
206
+ max_activities: int,
207
+ region: IgpRegionConfig,
208
+ ) -> list[Activity]:
209
+ """queryMyActivity across pages until max is reached or no more rows exist."""
210
+ # API responses are effectively paged in chunks of up to 20 items.
211
+ page_size = min(max_activities, 20)
212
+ activities: list[Activity] = []
213
+ page_no = 1
214
+
215
+ while len(activities) < max_activities:
216
+ resp = session.get(
217
+ region.activity_query_url,
218
+ params={
219
+ "pageNo": str(page_no),
220
+ "pageSize": str(page_size),
221
+ "sort": "1",
222
+ "reqType": "0",
223
+ },
224
+ )
225
+ resp.raise_for_status()
226
+
227
+ body = resp.json()
228
+ data = body.get("data") if isinstance(body, dict) else {}
229
+ rows = (data.get("rows") if isinstance(data, dict) else None) or []
230
+ if not rows:
231
+ break
232
+
233
+ for item in rows:
234
+ if len(activities) >= max_activities:
235
+ break
236
+ if not isinstance(item, dict):
237
+ continue
238
+ ride_id = item.get("rideId")
239
+ if ride_id is None:
240
+ ride_id = item.get("RideId")
241
+ if ride_id is None:
242
+ continue
243
+ activities.append(
244
+ Activity(
245
+ ride_id=int(ride_id),
246
+ title=item.get("title") or item.get("Title") or f"iGPSPORT {ride_id}",
247
+ start_time=(
248
+ item.get("startTime")
249
+ or item.get("StartTime")
250
+ or item.get("startTimeString")
251
+ or item.get("StartTimeString")
252
+ or "unknown date"
253
+ ),
254
+ )
255
+ )
256
+
257
+ if len(rows) < page_size:
258
+ break
259
+
260
+ page_no += 1
261
+
262
+ return activities
263
+
264
+
265
+ def resolve_fit_url(
266
+ session: requests.Session,
267
+ auth_headers: dict[str, str],
268
+ ride_id: int,
269
+ region: IgpRegionConfig | str | None = None,
270
+ ) -> str | None:
271
+ """Resolve a FIT download URL: try queryActivityDetail, then getDownloadUrl."""
272
+ cfg = resolve_region(region.name if isinstance(region, IgpRegionConfig) else region)
273
+ gateway = cfg.gateway_base
274
+
275
+ detail = session.get(
276
+ f"{gateway}/queryActivityDetail/{ride_id}", headers=auth_headers
277
+ )
278
+ if detail.ok:
279
+ fit_url = detail.json().get("data", {}).get("fitUrl")
280
+ if fit_url:
281
+ return fit_url
282
+
283
+ fallback = session.get(
284
+ f"{gateway}/getDownloadUrl/{ride_id}", headers=auth_headers
285
+ )
286
+ if fallback.ok:
287
+ return fallback.json().get("data")
288
+
289
+ return None
290
+
291
+
292
+ def download_fit(fit_url: str, dest_path: Path) -> Path:
293
+ """Download a .fit file to dest_path and return the path."""
294
+ resp = requests.get(fit_url)
295
+ resp.raise_for_status()
296
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
297
+ dest_path.write_bytes(resp.content)
298
+ return dest_path
299
+
300
+
301
+ def upload_to_intervals(
302
+ fit_path: Path, title: str, ride_id: int, api_key: str
303
+ ) -> str | None:
304
+ """Upload a .fit file to intervals.icu; return the new activity id or None."""
305
+ return upload_fit_file(
306
+ fit_path, title, external_id_for(ride_id), api_key
307
+ )
308
+
309
+
310
+ def fetch_uploaded_external_ids(
311
+ api_key: str, oldest: date, newest: date
312
+ ) -> set[str]:
313
+ """Return external_ids for rides already on intervals.icu in a date range."""
314
+ return icu_fetch_uploaded_external_ids(api_key, oldest, newest)
315
+
316
+
317
+ def _activity_date_range(activities: list[Activity]) -> tuple[date, date]:
318
+ """Date window covering the activities, padded a day each side.
319
+
320
+ Falls back to a wide window if start times can't be parsed.
321
+ """
322
+ dates: list[date] = []
323
+ for act in activities:
324
+ start = parse_igp_start_time(act.start_time)
325
+ if start is not None:
326
+ dates.append(start.date())
327
+
328
+ if not dates:
329
+ today = date.today()
330
+ return today - timedelta(days=365), today + timedelta(days=1)
331
+
332
+ return min(dates) - timedelta(days=1), max(dates) + timedelta(days=1)
333
+
334
+
335
+ @dataclass
336
+ class SyncConfig:
337
+ igp_user: str
338
+ igp_password: str
339
+ intervals_api_key: str | None
340
+ igp_region: str = "international"
341
+ dropbox_refresh_token: str | None = None
342
+ dropbox_app_key: str | None = None
343
+ max_activities: int = 5
344
+ download_dir: Path = Path("downloads")
345
+ delete_after_upload: bool = True
346
+ # When False (default), skip activities already uploaded to intervals.icu.
347
+ # When True, re-download and re-upload them regardless.
348
+ force_resync: bool = False
349
+ # Sport to set on uploaded activities (intervals.icu doesn't accept it on
350
+ # upload). Empty string = leave the uploaded sport untouched.
351
+ activity_type: str = ACTIVITY_TYPE_KEEP
352
+ list_activities: bool = True
353
+ get_download_url: bool = False
354
+ download_fit: bool = False
355
+ upload_intervals: bool = False
356
+ upload_dropbox: bool = False
357
+ dropbox_folder: str = DEFAULT_DROPBOX_FOLDER
358
+ dropbox_date_filenames: bool = True
359
+
360
+
361
+ def sync(config: SyncConfig, progress: Progress | None = None) -> SyncResult:
362
+ """Run the configured steps end-to-end, reporting progress via the callback."""
363
+ report = progress or _noop
364
+ result = SyncResult()
365
+
366
+ session = requests.Session()
367
+ region = resolve_region(config.igp_region)
368
+ report("Logging in to iGPSPORT…")
369
+ auth_headers = login(session, config.igp_user, config.igp_password, region)
370
+ report("Logged in.")
371
+
372
+ activities = list_activities(session, config.max_activities, region)
373
+ result.activities = activities
374
+ result.listed = len(activities)
375
+
376
+ if config.list_activities:
377
+ report(f"Found {len(activities)} activities.")
378
+ for act in activities:
379
+ report(f" • {act.ride_id} | {act.start_time} | {act.title}")
380
+
381
+ needs_url = (
382
+ config.get_download_url
383
+ or config.download_fit
384
+ or config.upload_intervals
385
+ or config.upload_dropbox
386
+ )
387
+ if not needs_url:
388
+ return result
389
+
390
+ # Figure out which activities are already on intervals.icu so we can skip
391
+ # re-downloading them. Only relevant when uploading and not forcing a resync.
392
+ already_uploaded: set[str] = set()
393
+ if config.upload_intervals and not config.force_resync and config.intervals_api_key:
394
+ oldest, newest = _activity_date_range(activities)
395
+ try:
396
+ already_uploaded = fetch_uploaded_external_ids(
397
+ config.intervals_api_key, oldest, newest
398
+ )
399
+ report(
400
+ f"{len(already_uploaded)} activities already on intervals.icu "
401
+ f"in {oldest.isoformat()}…{newest.isoformat()}."
402
+ )
403
+ except requests.RequestException as exc:
404
+ report(f"⚠ Could not check intervals.icu (will process all): {exc}")
405
+
406
+ # Validate Dropbox prerequisites once, before processing any activity, so a
407
+ # misconfiguration fails fast instead of part-way through the loop.
408
+ dropbox_uploaded_names: set[str] = set()
409
+ if config.upload_dropbox:
410
+ if not config.dropbox_app_key:
411
+ raise SyncError("Dropbox app key is required for Dropbox upload.")
412
+ if not config.dropbox_refresh_token:
413
+ raise SyncError("Connect Dropbox in Settings before syncing.")
414
+
415
+ # Skip-tracking for Dropbox is independent of intervals.icu: list the
416
+ # FIT files already present in the folder so we don't re-upload them.
417
+ if not config.force_resync:
418
+ try:
419
+ dropbox_uploaded_names = list_dropbox_fit_names(
420
+ config.dropbox_folder,
421
+ config.dropbox_refresh_token,
422
+ config.dropbox_app_key,
423
+ )
424
+ report(f"{len(dropbox_uploaded_names)} activities already in Dropbox.")
425
+ except Exception as exc: # noqa: BLE001 — non-fatal; process all
426
+ report(f"⚠ Could not check Dropbox (will process all): {exc}")
427
+
428
+ download_dir = Path(config.download_dir)
429
+
430
+ any_upload_enabled = config.upload_intervals or config.upload_dropbox
431
+
432
+ for act in activities:
433
+ ext = external_id_for(act.ride_id)
434
+ fit_path = download_dir / f"{ext}.fit"
435
+ dropbox_filename = dropbox_filename_for(act, config.dropbox_date_filenames)
436
+
437
+ # Each target tracks its own "already there" state, so an activity can
438
+ # be uploaded to one target while being skipped on the other.
439
+ intervals_needs_it = config.upload_intervals and ext not in already_uploaded
440
+ dropbox_needs_it = (
441
+ config.upload_dropbox and dropbox_filename not in dropbox_uploaded_names
442
+ )
443
+
444
+ if config.upload_intervals and not intervals_needs_it:
445
+ report(f"↷ Skipping {act.ride_id} — already on intervals.icu.")
446
+ result.skipped += 1
447
+ if config.upload_dropbox and not dropbox_needs_it:
448
+ report(f"↷ Skipping {act.ride_id} — already in Dropbox.")
449
+ result.skipped_dropbox += 1
450
+
451
+ # Nothing left to do once every enabled upload target already has it.
452
+ if any_upload_enabled and not intervals_needs_it and not dropbox_needs_it:
453
+ continue
454
+
455
+ fit_url = resolve_fit_url(session, auth_headers, act.ride_id, region)
456
+ if not fit_url:
457
+ report(f"⚠ Could not resolve FIT URL for {act.ride_id}; skipping.")
458
+ result.failed += 1
459
+ continue
460
+
461
+ if config.get_download_url and not (
462
+ config.download_fit or config.upload_intervals or config.upload_dropbox
463
+ ):
464
+ report(f"FIT URL for {act.ride_id}: {fit_url}")
465
+ continue
466
+
467
+ report(f"Downloading {act.ride_id}…")
468
+ download_fit(fit_url, fit_path)
469
+ result.downloaded += 1
470
+
471
+ intervals_ok = True
472
+ if intervals_needs_it:
473
+ if not config.intervals_api_key:
474
+ raise SyncError("intervals.icu API key is required for upload.")
475
+
476
+ activity_id = upload_to_intervals(
477
+ fit_path, act.title, act.ride_id, config.intervals_api_key
478
+ )
479
+ if activity_id:
480
+ report(f"✓ Uploaded {act.ride_id}: {act.title}")
481
+ result.uploaded += 1
482
+
483
+ if config.activity_type:
484
+ if set_activity_type(
485
+ activity_id, config.activity_type, config.intervals_api_key
486
+ ):
487
+ report(f"↻ Set {act.ride_id} → {config.activity_type}")
488
+ else:
489
+ report(f"⚠ Could not set activity type for {act.ride_id}.")
490
+ else:
491
+ report(f"✗ Failed to upload {act.ride_id}.")
492
+ result.failed += 1
493
+ intervals_ok = False
494
+
495
+ dropbox_ok = True
496
+ if dropbox_needs_it:
497
+ report(f"Uploading {act.ride_id} to Dropbox…")
498
+ try:
499
+ upload_to_dropbox(
500
+ fit_path,
501
+ dropbox_filename,
502
+ config.dropbox_refresh_token,
503
+ config.dropbox_app_key,
504
+ config.dropbox_folder,
505
+ )
506
+ except Exception as exc: # noqa: BLE001 — surface provider failures
507
+ dropbox_ok = False
508
+ result.failed_dropbox += 1
509
+ report(f"⚠ Dropbox upload failed for {act.ride_id}: {exc}")
510
+ else:
511
+ result.uploaded_dropbox += 1
512
+ report(f"✓ Uploaded {act.ride_id} to Dropbox")
513
+
514
+ # Remove the local file only once every enabled target is satisfied, so
515
+ # a failure on either target keeps the file for a later retry.
516
+ if config.delete_after_upload and any_upload_enabled and intervals_ok and dropbox_ok:
517
+ fit_path.unlink(missing_ok=True)
518
+ report(f" Removed local file {fit_path.name}")
519
+
520
+ return result