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.
- intervalssync/__init__.py +3 -0
- intervalssync/_dropbox_app_key.py +7 -0
- intervalssync/_version.py +1 -0
- intervalssync/bryton/__init__.py +1 -0
- intervalssync/bryton/api.py +49 -0
- intervalssync/bryton/core.py +249 -0
- intervalssync/bryton/ddp.py +231 -0
- intervalssync/bryton/exceptions.py +13 -0
- intervalssync/bryton/fit_encode.py +363 -0
- intervalssync/bryton/workout.py +342 -0
- intervalssync/cli/__init__.py +5 -0
- intervalssync/cli/config.py +53 -0
- intervalssync/cli/env.py +189 -0
- intervalssync/cli/main.py +637 -0
- intervalssync/dropbox_client.py +143 -0
- intervalssync/gui/__init__.py +1 -0
- intervalssync/gui/app.py +352 -0
- intervalssync/gui/config.py +102 -0
- intervalssync/gui/profile_sync_ui.py +225 -0
- intervalssync/gui/secrets.py +135 -0
- intervalssync/gui/settings_view.py +716 -0
- intervalssync/gui/support_gamification.py +658 -0
- intervalssync/gui/sync_view.py +486 -0
- intervalssync/gui/system.py +21 -0
- intervalssync/gui/theme.py +216 -0
- intervalssync/gui/update_check.py +55 -0
- intervalssync/igpsport/__init__.py +1 -0
- intervalssync/igpsport/core.py +520 -0
- intervalssync/igpsport/interval_info.py +269 -0
- intervalssync/igpsport/profile_sync.py +391 -0
- intervalssync/igpsport/region.py +80 -0
- intervalssync/igpsport/workout.py +503 -0
- intervalssync/igpsport/zone_map.py +143 -0
- intervalssync/intervals_icu.py +201 -0
- intervalssync-0.9.3.dist-info/METADATA +179 -0
- intervalssync-0.9.3.dist-info/RECORD +39 -0
- intervalssync-0.9.3.dist-info/WHEEL +4 -0
- intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
- 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
|