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,637 @@
|
|
|
1
|
+
"""Headless CLI for syncing activities to intervals.icu.
|
|
2
|
+
|
|
3
|
+
Supports multiple activity sources (iGPSPORT, Bryton). Credentials are read
|
|
4
|
+
from a .env file; progress goes to stderr; structured results go to stdout with
|
|
5
|
+
--json.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .. import __version__
|
|
16
|
+
from ..bryton.core import SyncConfig as BrytonSyncConfig
|
|
17
|
+
from ..bryton.core import SyncResult as BrytonSyncResult
|
|
18
|
+
from ..bryton.core import sync as bryton_sync
|
|
19
|
+
from ..bryton.exceptions import BrytonSyncError
|
|
20
|
+
from ..igpsport.core import SyncConfig as IgpSyncConfig
|
|
21
|
+
from ..igpsport.core import SyncError as IgpSyncError
|
|
22
|
+
from ..igpsport.core import SyncResult as IgpSyncResult
|
|
23
|
+
from ..igpsport.core import sync as igpsport_sync
|
|
24
|
+
from ..igpsport.profile_sync import (
|
|
25
|
+
ProfileSyncConfig,
|
|
26
|
+
ProfileSyncResult,
|
|
27
|
+
result_payload as profile_sync_result_payload,
|
|
28
|
+
sync_profile_zones,
|
|
29
|
+
)
|
|
30
|
+
from ..bryton.workout import (
|
|
31
|
+
BrytonWorkoutUploadConfig,
|
|
32
|
+
BrytonWorkoutUploadResult,
|
|
33
|
+
apply_uploaded_bryton_workout_map,
|
|
34
|
+
upload_workouts as bryton_upload_workouts,
|
|
35
|
+
)
|
|
36
|
+
from ..igpsport.workout import (
|
|
37
|
+
WorkoutUploadConfig,
|
|
38
|
+
WorkoutUploadResult,
|
|
39
|
+
apply_uploaded_workout_map,
|
|
40
|
+
upload_workouts,
|
|
41
|
+
)
|
|
42
|
+
from . import config as cli_config_module
|
|
43
|
+
from .env import ActivitySource, CliConfigError, load_credentials, resolve_env_path
|
|
44
|
+
|
|
45
|
+
EXIT_OK = 0
|
|
46
|
+
EXIT_SYNC_ERROR = 1
|
|
47
|
+
EXIT_CONFIG_ERROR = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_igpsport_sync_config(
|
|
51
|
+
credentials,
|
|
52
|
+
config: cli_config_module.CliConfig,
|
|
53
|
+
*,
|
|
54
|
+
max_activities: int | None,
|
|
55
|
+
force_resync: bool | None,
|
|
56
|
+
activity_type: str | None,
|
|
57
|
+
download_dir: str | None,
|
|
58
|
+
delete_after_upload: bool | None,
|
|
59
|
+
) -> IgpSyncConfig:
|
|
60
|
+
return IgpSyncConfig(
|
|
61
|
+
igp_user=credentials.igp_user,
|
|
62
|
+
igp_password=credentials.igp_password,
|
|
63
|
+
intervals_api_key=credentials.intervals_api_key,
|
|
64
|
+
igp_region=credentials.igp_region,
|
|
65
|
+
max_activities=max_activities if max_activities is not None else config.max_activities,
|
|
66
|
+
download_dir=Path(download_dir or config.download_dir),
|
|
67
|
+
delete_after_upload=(
|
|
68
|
+
delete_after_upload if delete_after_upload is not None else config.delete_after_upload
|
|
69
|
+
),
|
|
70
|
+
force_resync=force_resync if force_resync is not None else config.force_resync,
|
|
71
|
+
activity_type=activity_type if activity_type is not None else config.activity_type,
|
|
72
|
+
list_activities=True,
|
|
73
|
+
get_download_url=True,
|
|
74
|
+
download_fit=True,
|
|
75
|
+
upload_intervals=True,
|
|
76
|
+
upload_dropbox=False,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_bryton_sync_config(
|
|
81
|
+
credentials,
|
|
82
|
+
config: cli_config_module.CliConfig,
|
|
83
|
+
*,
|
|
84
|
+
max_activities: int | None,
|
|
85
|
+
force_resync: bool | None,
|
|
86
|
+
activity_type: str | None,
|
|
87
|
+
download_dir: str | None,
|
|
88
|
+
delete_after_upload: bool | None,
|
|
89
|
+
) -> BrytonSyncConfig:
|
|
90
|
+
return BrytonSyncConfig(
|
|
91
|
+
bryton_email=credentials.bryton_email,
|
|
92
|
+
bryton_password=credentials.bryton_password,
|
|
93
|
+
intervals_api_key=credentials.intervals_api_key,
|
|
94
|
+
max_activities=max_activities if max_activities is not None else config.max_activities,
|
|
95
|
+
download_dir=Path(download_dir or config.download_dir),
|
|
96
|
+
delete_after_upload=(
|
|
97
|
+
delete_after_upload if delete_after_upload is not None else config.delete_after_upload
|
|
98
|
+
),
|
|
99
|
+
force_resync=force_resync if force_resync is not None else config.force_resync,
|
|
100
|
+
activity_type=activity_type if activity_type is not None else config.activity_type,
|
|
101
|
+
list_activities=True,
|
|
102
|
+
download_fit=True,
|
|
103
|
+
upload_intervals=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _igpsport_result_payload(result: IgpSyncResult, *, ok: bool, error: str | None = None) -> dict:
|
|
108
|
+
payload = {
|
|
109
|
+
"ok": ok,
|
|
110
|
+
"source": "igpsport",
|
|
111
|
+
"listed": result.listed,
|
|
112
|
+
"uploaded": result.uploaded,
|
|
113
|
+
"skipped": result.skipped,
|
|
114
|
+
"failed": result.failed,
|
|
115
|
+
"downloaded": result.downloaded,
|
|
116
|
+
"activities": [
|
|
117
|
+
{
|
|
118
|
+
"ride_id": act.ride_id,
|
|
119
|
+
"title": act.title,
|
|
120
|
+
"start_time": act.start_time,
|
|
121
|
+
}
|
|
122
|
+
for act in result.activities
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
if error is not None:
|
|
126
|
+
payload["error"] = error
|
|
127
|
+
return payload
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _bryton_result_payload(result: BrytonSyncResult, *, ok: bool, error: str | None = None) -> dict:
|
|
131
|
+
payload = {
|
|
132
|
+
"ok": ok,
|
|
133
|
+
"source": "bryton",
|
|
134
|
+
"listed": result.listed,
|
|
135
|
+
"uploaded": result.uploaded,
|
|
136
|
+
"skipped": result.skipped,
|
|
137
|
+
"failed": result.failed,
|
|
138
|
+
"downloaded": result.downloaded,
|
|
139
|
+
"activities": [
|
|
140
|
+
{
|
|
141
|
+
"activity_id": act.activity_id,
|
|
142
|
+
"title": act.title,
|
|
143
|
+
"start_time": act.start_time,
|
|
144
|
+
}
|
|
145
|
+
for act in result.activities
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
if error is not None:
|
|
149
|
+
payload["error"] = error
|
|
150
|
+
return payload
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _emit_json(payload: dict) -> None:
|
|
154
|
+
print(json.dumps(payload, indent=2))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _emit_igpsport_text_summary(result: IgpSyncResult) -> None:
|
|
158
|
+
print(
|
|
159
|
+
f"Done — uploaded {result.uploaded}, "
|
|
160
|
+
f"downloaded {result.downloaded}, "
|
|
161
|
+
f"skipped {result.skipped}, "
|
|
162
|
+
f"failed {result.failed}."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _emit_bryton_text_summary(result: BrytonSyncResult) -> None:
|
|
167
|
+
print(
|
|
168
|
+
f"Done — uploaded {result.uploaded}, "
|
|
169
|
+
f"downloaded {result.downloaded}, "
|
|
170
|
+
f"skipped {result.skipped}, "
|
|
171
|
+
f"failed {result.failed}."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_workout_upload_config(
|
|
176
|
+
credentials,
|
|
177
|
+
config: cli_config_module.CliConfig,
|
|
178
|
+
*,
|
|
179
|
+
workout_days_ahead: int | None,
|
|
180
|
+
force_resync: bool | None,
|
|
181
|
+
) -> WorkoutUploadConfig:
|
|
182
|
+
return WorkoutUploadConfig(
|
|
183
|
+
igp_user=credentials.igp_user,
|
|
184
|
+
igp_password=credentials.igp_password,
|
|
185
|
+
intervals_api_key=credentials.intervals_api_key,
|
|
186
|
+
igp_region=credentials.igp_region,
|
|
187
|
+
workout_days_ahead=(
|
|
188
|
+
workout_days_ahead if workout_days_ahead is not None else config.workout_days_ahead
|
|
189
|
+
),
|
|
190
|
+
uploaded_workouts=dict(config.uploaded_workouts),
|
|
191
|
+
force_resync=force_resync if force_resync is not None else config.force_resync,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _build_bryton_workout_upload_config(
|
|
196
|
+
credentials,
|
|
197
|
+
config: cli_config_module.CliConfig,
|
|
198
|
+
*,
|
|
199
|
+
workout_days_ahead: int | None,
|
|
200
|
+
force_resync: bool | None,
|
|
201
|
+
) -> BrytonWorkoutUploadConfig:
|
|
202
|
+
return BrytonWorkoutUploadConfig(
|
|
203
|
+
bryton_email=credentials.bryton_email,
|
|
204
|
+
bryton_password=credentials.bryton_password,
|
|
205
|
+
intervals_api_key=credentials.intervals_api_key,
|
|
206
|
+
workout_days_ahead=(
|
|
207
|
+
workout_days_ahead if workout_days_ahead is not None else config.workout_days_ahead
|
|
208
|
+
),
|
|
209
|
+
uploaded_workouts=dict(config.uploaded_bryton_workouts),
|
|
210
|
+
force_resync=force_resync if force_resync is not None else config.force_resync,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _workout_result_payload(
|
|
215
|
+
result: WorkoutUploadResult | BrytonWorkoutUploadResult,
|
|
216
|
+
*,
|
|
217
|
+
ok: bool,
|
|
218
|
+
source: str = "igpsport",
|
|
219
|
+
error: str | None = None,
|
|
220
|
+
) -> dict:
|
|
221
|
+
payload = {
|
|
222
|
+
"ok": ok,
|
|
223
|
+
"source": source,
|
|
224
|
+
"listed": result.listed,
|
|
225
|
+
"uploaded": result.uploaded,
|
|
226
|
+
"skipped": result.skipped,
|
|
227
|
+
"failed": result.failed,
|
|
228
|
+
"no_steps": result.no_steps,
|
|
229
|
+
}
|
|
230
|
+
if error is not None:
|
|
231
|
+
payload["error"] = error
|
|
232
|
+
return payload
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _emit_workout_text_summary(result: WorkoutUploadResult | BrytonWorkoutUploadResult) -> None:
|
|
236
|
+
print(
|
|
237
|
+
f"Done — uploaded {result.uploaded}, "
|
|
238
|
+
f"skipped {result.skipped}, "
|
|
239
|
+
f"no steps {result.no_steps}, "
|
|
240
|
+
f"failed {result.failed}."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _source_from_args(args: argparse.Namespace) -> ActivitySource:
|
|
245
|
+
return args.source
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
249
|
+
config = cli_config_module.load()
|
|
250
|
+
source = _source_from_args(args)
|
|
251
|
+
try:
|
|
252
|
+
env_path = resolve_env_path(
|
|
253
|
+
env_file=Path(args.env_file) if args.env_file else None,
|
|
254
|
+
config_env_file=config.env_file or None,
|
|
255
|
+
)
|
|
256
|
+
load_credentials(env_path, source=source)
|
|
257
|
+
except CliConfigError as exc:
|
|
258
|
+
if args.json:
|
|
259
|
+
_emit_json({"ok": False, "source": source, "error": str(exc)})
|
|
260
|
+
else:
|
|
261
|
+
print(exc, file=sys.stderr)
|
|
262
|
+
return EXIT_CONFIG_ERROR
|
|
263
|
+
|
|
264
|
+
if args.json:
|
|
265
|
+
_emit_json({"ok": True, "source": source, "env_file": str(env_path)})
|
|
266
|
+
else:
|
|
267
|
+
print(f"Credentials OK for {source} ({env_path})")
|
|
268
|
+
return EXIT_OK
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
272
|
+
config = cli_config_module.load()
|
|
273
|
+
use_json = args.json
|
|
274
|
+
source = _source_from_args(args)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
env_path = resolve_env_path(
|
|
278
|
+
env_file=Path(args.env_file) if args.env_file else None,
|
|
279
|
+
config_env_file=config.env_file or None,
|
|
280
|
+
)
|
|
281
|
+
credentials = load_credentials(env_path, source=source)
|
|
282
|
+
except CliConfigError as exc:
|
|
283
|
+
if use_json:
|
|
284
|
+
_emit_json({"ok": False, "source": source, "error": str(exc)})
|
|
285
|
+
else:
|
|
286
|
+
print(exc, file=sys.stderr)
|
|
287
|
+
return EXIT_CONFIG_ERROR
|
|
288
|
+
|
|
289
|
+
delete_after_upload = False if args.keep_files else None
|
|
290
|
+
|
|
291
|
+
def progress(message: str) -> None:
|
|
292
|
+
print(message, file=sys.stderr)
|
|
293
|
+
|
|
294
|
+
if source == "bryton":
|
|
295
|
+
sync_config = _build_bryton_sync_config(
|
|
296
|
+
credentials,
|
|
297
|
+
config,
|
|
298
|
+
max_activities=args.max_activities,
|
|
299
|
+
force_resync=True if args.force_resync else None,
|
|
300
|
+
activity_type=args.activity_type,
|
|
301
|
+
download_dir=args.download_dir,
|
|
302
|
+
delete_after_upload=delete_after_upload,
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
result = bryton_sync(sync_config, progress=progress)
|
|
306
|
+
except BrytonSyncError as exc:
|
|
307
|
+
if use_json:
|
|
308
|
+
_emit_json(_bryton_result_payload(BrytonSyncResult(), ok=False, error=str(exc)))
|
|
309
|
+
else:
|
|
310
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
311
|
+
return EXIT_SYNC_ERROR
|
|
312
|
+
except Exception as exc: # noqa: BLE001
|
|
313
|
+
if use_json:
|
|
314
|
+
_emit_json(_bryton_result_payload(BrytonSyncResult(), ok=False, error=str(exc)))
|
|
315
|
+
else:
|
|
316
|
+
print(f"✗ Unexpected error: {exc}", file=sys.stderr)
|
|
317
|
+
return EXIT_SYNC_ERROR
|
|
318
|
+
|
|
319
|
+
ok = result.failed == 0
|
|
320
|
+
if use_json:
|
|
321
|
+
_emit_json(_bryton_result_payload(result, ok=ok))
|
|
322
|
+
else:
|
|
323
|
+
_emit_bryton_text_summary(result)
|
|
324
|
+
return EXIT_OK if ok else EXIT_SYNC_ERROR
|
|
325
|
+
|
|
326
|
+
sync_config = _build_igpsport_sync_config(
|
|
327
|
+
credentials,
|
|
328
|
+
config,
|
|
329
|
+
max_activities=args.max_activities,
|
|
330
|
+
force_resync=True if args.force_resync else None,
|
|
331
|
+
activity_type=args.activity_type,
|
|
332
|
+
download_dir=args.download_dir,
|
|
333
|
+
delete_after_upload=delete_after_upload,
|
|
334
|
+
)
|
|
335
|
+
try:
|
|
336
|
+
result = igpsport_sync(sync_config, progress=progress)
|
|
337
|
+
except IgpSyncError as exc:
|
|
338
|
+
if use_json:
|
|
339
|
+
_emit_json(_igpsport_result_payload(IgpSyncResult(), ok=False, error=str(exc)))
|
|
340
|
+
else:
|
|
341
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
342
|
+
return EXIT_SYNC_ERROR
|
|
343
|
+
except Exception as exc: # noqa: BLE001
|
|
344
|
+
if use_json:
|
|
345
|
+
_emit_json(_igpsport_result_payload(IgpSyncResult(), ok=False, error=str(exc)))
|
|
346
|
+
else:
|
|
347
|
+
print(f"✗ Unexpected error: {exc}", file=sys.stderr)
|
|
348
|
+
return EXIT_SYNC_ERROR
|
|
349
|
+
|
|
350
|
+
ok = result.failed == 0
|
|
351
|
+
if use_json:
|
|
352
|
+
_emit_json(_igpsport_result_payload(result, ok=ok))
|
|
353
|
+
else:
|
|
354
|
+
_emit_igpsport_text_summary(result)
|
|
355
|
+
|
|
356
|
+
return EXIT_OK if ok else EXIT_SYNC_ERROR
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_upload_workouts(args: argparse.Namespace) -> int:
|
|
360
|
+
config = cli_config_module.load()
|
|
361
|
+
use_json = args.json
|
|
362
|
+
source = args.source
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
env_path = resolve_env_path(
|
|
366
|
+
env_file=Path(args.env_file) if args.env_file else None,
|
|
367
|
+
config_env_file=config.env_file or None,
|
|
368
|
+
)
|
|
369
|
+
credentials = load_credentials(env_path, source=source)
|
|
370
|
+
except CliConfigError as exc:
|
|
371
|
+
if use_json:
|
|
372
|
+
_emit_json({"ok": False, "source": source, "error": str(exc)})
|
|
373
|
+
else:
|
|
374
|
+
print(exc, file=sys.stderr)
|
|
375
|
+
return EXIT_CONFIG_ERROR
|
|
376
|
+
|
|
377
|
+
def progress(message: str) -> None:
|
|
378
|
+
print(message, file=sys.stderr)
|
|
379
|
+
|
|
380
|
+
if source == "bryton":
|
|
381
|
+
upload_config = _build_bryton_workout_upload_config(
|
|
382
|
+
credentials,
|
|
383
|
+
config,
|
|
384
|
+
workout_days_ahead=args.workout_days_ahead,
|
|
385
|
+
force_resync=True if args.force_resync else None,
|
|
386
|
+
)
|
|
387
|
+
try:
|
|
388
|
+
result = bryton_upload_workouts(upload_config, progress=progress)
|
|
389
|
+
except BrytonSyncError as exc:
|
|
390
|
+
if use_json:
|
|
391
|
+
_emit_json(
|
|
392
|
+
_workout_result_payload(
|
|
393
|
+
BrytonWorkoutUploadResult(), ok=False, source=source, error=str(exc)
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
else:
|
|
397
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
398
|
+
return EXIT_SYNC_ERROR
|
|
399
|
+
except Exception as exc: # noqa: BLE001
|
|
400
|
+
if use_json:
|
|
401
|
+
_emit_json(
|
|
402
|
+
_workout_result_payload(
|
|
403
|
+
BrytonWorkoutUploadResult(), ok=False, source=source, error=str(exc)
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
print(f"✗ Unexpected error: {exc}", file=sys.stderr)
|
|
408
|
+
return EXIT_SYNC_ERROR
|
|
409
|
+
|
|
410
|
+
if result.uploaded_map or result.pruned_keys:
|
|
411
|
+
apply_uploaded_bryton_workout_map(config.uploaded_bryton_workouts, result)
|
|
412
|
+
cli_config_module.save(config)
|
|
413
|
+
|
|
414
|
+
ok = result.failed == 0
|
|
415
|
+
if use_json:
|
|
416
|
+
_emit_json(_workout_result_payload(result, ok=ok, source=source))
|
|
417
|
+
else:
|
|
418
|
+
_emit_workout_text_summary(result)
|
|
419
|
+
return EXIT_OK if ok else EXIT_SYNC_ERROR
|
|
420
|
+
|
|
421
|
+
upload_config = _build_workout_upload_config(
|
|
422
|
+
credentials,
|
|
423
|
+
config,
|
|
424
|
+
workout_days_ahead=args.workout_days_ahead,
|
|
425
|
+
force_resync=True if args.force_resync else None,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
result = upload_workouts(upload_config, progress=progress)
|
|
430
|
+
except IgpSyncError as exc:
|
|
431
|
+
if use_json:
|
|
432
|
+
_emit_json(
|
|
433
|
+
_workout_result_payload(
|
|
434
|
+
WorkoutUploadResult(), ok=False, source=source, error=str(exc)
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
439
|
+
return EXIT_SYNC_ERROR
|
|
440
|
+
except Exception as exc: # noqa: BLE001
|
|
441
|
+
if use_json:
|
|
442
|
+
_emit_json(
|
|
443
|
+
_workout_result_payload(
|
|
444
|
+
WorkoutUploadResult(), ok=False, source=source, error=str(exc)
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
else:
|
|
448
|
+
print(f"✗ Unexpected error: {exc}", file=sys.stderr)
|
|
449
|
+
return EXIT_SYNC_ERROR
|
|
450
|
+
|
|
451
|
+
if result.uploaded_map or result.pruned_keys:
|
|
452
|
+
apply_uploaded_workout_map(config.uploaded_workouts, result)
|
|
453
|
+
cli_config_module.save(config)
|
|
454
|
+
|
|
455
|
+
ok = result.failed == 0
|
|
456
|
+
if use_json:
|
|
457
|
+
_emit_json(_workout_result_payload(result, ok=ok, source=source))
|
|
458
|
+
else:
|
|
459
|
+
_emit_workout_text_summary(result)
|
|
460
|
+
|
|
461
|
+
return EXIT_OK if ok else EXIT_SYNC_ERROR
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def cmd_sync_zones(args: argparse.Namespace) -> int:
|
|
465
|
+
config = cli_config_module.load()
|
|
466
|
+
use_json = args.json
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
env_path = resolve_env_path(
|
|
470
|
+
env_file=Path(args.env_file) if args.env_file else None,
|
|
471
|
+
config_env_file=config.env_file or None,
|
|
472
|
+
)
|
|
473
|
+
credentials = load_credentials(env_path, source="igpsport")
|
|
474
|
+
except CliConfigError as exc:
|
|
475
|
+
if use_json:
|
|
476
|
+
_emit_json({"ok": False, "source": "igpsport", "error": str(exc)})
|
|
477
|
+
else:
|
|
478
|
+
print(exc, file=sys.stderr)
|
|
479
|
+
return EXIT_CONFIG_ERROR
|
|
480
|
+
|
|
481
|
+
sync_config = ProfileSyncConfig(
|
|
482
|
+
igp_user=credentials.igp_user,
|
|
483
|
+
igp_password=credentials.igp_password,
|
|
484
|
+
intervals_api_key=credentials.intervals_api_key,
|
|
485
|
+
igp_region=credentials.igp_region,
|
|
486
|
+
sport=args.sport,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def progress(message: str) -> None:
|
|
490
|
+
print(message, file=sys.stderr)
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
result = sync_profile_zones(sync_config, progress=progress)
|
|
494
|
+
except IgpSyncError as exc:
|
|
495
|
+
if use_json:
|
|
496
|
+
_emit_json(profile_sync_result_payload(ProfileSyncResult(None, None), ok=False, error=str(exc)))
|
|
497
|
+
else:
|
|
498
|
+
print(f"✗ {exc}", file=sys.stderr)
|
|
499
|
+
return EXIT_SYNC_ERROR
|
|
500
|
+
except Exception as exc: # noqa: BLE001
|
|
501
|
+
if use_json:
|
|
502
|
+
_emit_json(profile_sync_result_payload(ProfileSyncResult(None, None), ok=False, error=str(exc)))
|
|
503
|
+
else:
|
|
504
|
+
print(f"✗ Unexpected error: {exc}", file=sys.stderr)
|
|
505
|
+
return EXIT_SYNC_ERROR
|
|
506
|
+
|
|
507
|
+
if use_json:
|
|
508
|
+
_emit_json(profile_sync_result_payload(result, ok=True))
|
|
509
|
+
else:
|
|
510
|
+
summary = profile_sync_result_payload(result, ok=True)
|
|
511
|
+
print(
|
|
512
|
+
f"Done — FTP {summary.get('ftp')}, LTHR {summary.get('lthr')}, "
|
|
513
|
+
f"MHR {summary.get('mhr')} synced to iGPSPORT."
|
|
514
|
+
)
|
|
515
|
+
return EXIT_OK
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _add_source_arg(parser: argparse.ArgumentParser) -> None:
|
|
519
|
+
parser.add_argument(
|
|
520
|
+
"--source",
|
|
521
|
+
choices=("igpsport", "bryton"),
|
|
522
|
+
default="igpsport",
|
|
523
|
+
help="Activity source (default: igpsport).",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
528
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
529
|
+
common.add_argument(
|
|
530
|
+
"--env-file",
|
|
531
|
+
metavar="PATH",
|
|
532
|
+
help="Override path to the secrets .env file.",
|
|
533
|
+
)
|
|
534
|
+
common.add_argument(
|
|
535
|
+
"--json",
|
|
536
|
+
action="store_true",
|
|
537
|
+
help="Emit machine-readable JSON on stdout (progress stays on stderr).",
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
parser = argparse.ArgumentParser(
|
|
541
|
+
prog="intervalssync",
|
|
542
|
+
description="Sync cycling activities to intervals.icu from iGPSPORT or Bryton Active.",
|
|
543
|
+
)
|
|
544
|
+
parser.add_argument(
|
|
545
|
+
"--version",
|
|
546
|
+
action="version",
|
|
547
|
+
version=f"%(prog)s {__version__}",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
551
|
+
subparsers.required = True
|
|
552
|
+
|
|
553
|
+
check_parser = subparsers.add_parser(
|
|
554
|
+
"check",
|
|
555
|
+
parents=[common],
|
|
556
|
+
help="Validate credentials in the .env file (no network).",
|
|
557
|
+
)
|
|
558
|
+
_add_source_arg(check_parser)
|
|
559
|
+
check_parser.set_defaults(func=cmd_check)
|
|
560
|
+
|
|
561
|
+
sync_parser = subparsers.add_parser(
|
|
562
|
+
"sync",
|
|
563
|
+
parents=[common],
|
|
564
|
+
help="Download recent rides and upload to intervals.icu.",
|
|
565
|
+
)
|
|
566
|
+
_add_source_arg(sync_parser)
|
|
567
|
+
sync_parser.add_argument(
|
|
568
|
+
"--max-activities",
|
|
569
|
+
type=int,
|
|
570
|
+
metavar="N",
|
|
571
|
+
help="Number of recent activities to process (default: from cli config).",
|
|
572
|
+
)
|
|
573
|
+
sync_parser.add_argument(
|
|
574
|
+
"--force-resync",
|
|
575
|
+
action="store_true",
|
|
576
|
+
help="Re-upload activities even if already on intervals.icu.",
|
|
577
|
+
)
|
|
578
|
+
sync_parser.add_argument(
|
|
579
|
+
"--activity-type",
|
|
580
|
+
metavar="TYPE",
|
|
581
|
+
help='intervals.icu sport to set after upload (e.g. "Mountain Bike Ride").',
|
|
582
|
+
)
|
|
583
|
+
sync_parser.add_argument(
|
|
584
|
+
"--download-dir",
|
|
585
|
+
metavar="PATH",
|
|
586
|
+
help="Directory for temporary .fit files.",
|
|
587
|
+
)
|
|
588
|
+
sync_parser.add_argument(
|
|
589
|
+
"--keep-files",
|
|
590
|
+
action="store_true",
|
|
591
|
+
help="Keep .fit files after upload instead of deleting them.",
|
|
592
|
+
)
|
|
593
|
+
sync_parser.set_defaults(func=cmd_sync)
|
|
594
|
+
|
|
595
|
+
upload_workouts_parser = subparsers.add_parser(
|
|
596
|
+
"upload-workouts",
|
|
597
|
+
parents=[common],
|
|
598
|
+
help="Upload planned workouts from intervals.icu to iGPSPORT or Bryton.",
|
|
599
|
+
)
|
|
600
|
+
_add_source_arg(upload_workouts_parser)
|
|
601
|
+
upload_workouts_parser.add_argument(
|
|
602
|
+
"--workout-days-ahead",
|
|
603
|
+
type=int,
|
|
604
|
+
metavar="N",
|
|
605
|
+
help="Number of calendar days to upload (default: from cli config).",
|
|
606
|
+
)
|
|
607
|
+
upload_workouts_parser.add_argument(
|
|
608
|
+
"--force-resync",
|
|
609
|
+
action="store_true",
|
|
610
|
+
help="Re-upload workouts even if already on the target device platform.",
|
|
611
|
+
)
|
|
612
|
+
upload_workouts_parser.set_defaults(func=cmd_upload_workouts)
|
|
613
|
+
|
|
614
|
+
sync_zones_parser = subparsers.add_parser(
|
|
615
|
+
"sync-zones",
|
|
616
|
+
parents=[common],
|
|
617
|
+
help="Push FTP, LTHR, max HR, weight, and zones from intervals.icu to iGPSPORT.",
|
|
618
|
+
)
|
|
619
|
+
sync_zones_parser.add_argument(
|
|
620
|
+
"--sport",
|
|
621
|
+
default="Ride",
|
|
622
|
+
metavar="TYPE",
|
|
623
|
+
help='intervals.icu sport-settings key (default: "Ride").',
|
|
624
|
+
)
|
|
625
|
+
sync_zones_parser.set_defaults(func=cmd_sync_zones)
|
|
626
|
+
|
|
627
|
+
return parser
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def main(argv: list[str] | None = None) -> None:
|
|
631
|
+
parser = _build_parser()
|
|
632
|
+
args = parser.parse_args(argv)
|
|
633
|
+
raise SystemExit(args.func(args))
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
if __name__ == "__main__":
|
|
637
|
+
main()
|