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,486 @@
1
+ """Sync view: one-click sync with a progress bar, live log and activity list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import flet as ft
8
+
9
+ from . import config as config_module
10
+ from . import secrets as secrets_module
11
+ from ..bryton.core import SyncConfig as BrytonSyncConfig, sync as bryton_sync
12
+ from ..bryton.exceptions import BrytonSyncError
13
+ from ..bryton.workout import (
14
+ BrytonWorkoutUploadConfig,
15
+ apply_uploaded_bryton_workout_map,
16
+ upload_workouts as bryton_upload_workouts,
17
+ )
18
+ from ..igpsport.core import SyncConfig as IgpSyncConfig, SyncError, sync as igpsport_sync
19
+ from ..dropbox_client import get_dropbox_app_key
20
+ from ..igpsport.workout import WorkoutUploadConfig, apply_uploaded_workout_map, upload_workouts
21
+ from . import support_gamification
22
+ from . import theme
23
+
24
+
25
+ def build_sync_view(
26
+ page: ft.Page,
27
+ config: config_module.AppConfig,
28
+ store: secrets_module.SecretStore,
29
+ ) -> ft.Control:
30
+ colors = theme.palette(page)
31
+ mobile = theme.is_mobile(page)
32
+ progress = ft.ProgressBar(
33
+ visible=False,
34
+ color=colors["accent"],
35
+ bgcolor=colors["surface_alt"],
36
+ bar_height=3,
37
+ border_radius=2,
38
+ )
39
+ log = ft.ListView(spacing=4, auto_scroll=True, height=280, expand=False)
40
+ stats_refs = support_gamification.StatsCardRefs(
41
+ number=ft.Ref[ft.Text](),
42
+ rank=ft.Ref[ft.Text](),
43
+ breakdown=ft.Ref[ft.Text](),
44
+ target=ft.Ref[ft.Text](),
45
+ remaining=ft.Ref[ft.Text](),
46
+ progress_bar=ft.Ref[ft.ProgressBar](),
47
+ )
48
+ stats_card = support_gamification.build_stats_card(page, config, stats_refs)
49
+
50
+ def _action_button(label: str, icon: str, *, outlined: bool = False) -> ft.FilledButton | ft.OutlinedButton:
51
+ label_size = 13 if mobile else 14
52
+ content = ft.Row(
53
+ spacing=theme.SPACE_XS,
54
+ alignment=ft.MainAxisAlignment.CENTER,
55
+ controls=[
56
+ ft.Icon(icon, size=16 if mobile else 18),
57
+ ft.Text(
58
+ label,
59
+ size=label_size,
60
+ font_family=f"{theme.FONT_BODY}Medium",
61
+ overflow=ft.TextOverflow.ELLIPSIS,
62
+ max_lines=1,
63
+ expand=True,
64
+ text_align=ft.TextAlign.CENTER,
65
+ ),
66
+ ],
67
+ )
68
+ pad = theme.SPACE_SM if mobile else theme.SPACE_MD
69
+ style = ft.ButtonStyle(
70
+ shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_SM),
71
+ padding=ft.Padding(pad, theme.SPACE_SM, pad, theme.SPACE_SM),
72
+ )
73
+ if outlined:
74
+ style = ft.ButtonStyle(
75
+ shape=style.shape,
76
+ padding=style.padding,
77
+ side=ft.BorderSide(1, colors["border"]),
78
+ color=colors["text"],
79
+ )
80
+ button: ft.FilledButton | ft.OutlinedButton = ft.OutlinedButton(content=content, style=style)
81
+ else:
82
+ button = ft.FilledButton(content=content, style=style)
83
+ button.expand = True
84
+ return button
85
+
86
+ def _source_card(title: str, sync_btn: ft.Control, upload_btn: ft.Control) -> ft.Container:
87
+ return ft.Container(
88
+ content=ft.Column(
89
+ spacing=theme.SPACE_MD,
90
+ controls=[
91
+ ft.Text(
92
+ title,
93
+ size=15,
94
+ weight=ft.FontWeight.W_600,
95
+ font_family=f"{theme.FONT_BODY}Medium",
96
+ color=colors["text"],
97
+ ),
98
+ ft.Column(
99
+ spacing=theme.SPACE_SM,
100
+ controls=[
101
+ ft.Row(spacing=0, controls=[sync_btn]),
102
+ ft.Row(spacing=0, controls=[upload_btn]),
103
+ ],
104
+ ),
105
+ ],
106
+ ),
107
+ padding=theme.SPACE_LG,
108
+ bgcolor=colors["surface"],
109
+ border=ft.Border.all(1, colors["border"]),
110
+ border_radius=theme.RADIUS_MD,
111
+ expand=True,
112
+ )
113
+
114
+ sync_igp_button = _action_button("Sync activities", ft.Icons.SYNC)
115
+ sync_bryton_button = _action_button("Sync activities", ft.Icons.SYNC)
116
+ upload_igp_workouts_button = _action_button(
117
+ "Upload workouts", ft.Icons.FITNESS_CENTER_OUTLINED, outlined=True
118
+ )
119
+ upload_bryton_workouts_button = _action_button(
120
+ "Upload workouts", ft.Icons.FITNESS_CENTER_OUTLINED, outlined=True
121
+ )
122
+
123
+ action_cards: list[ft.Container] = []
124
+ if config.enable_igpsport:
125
+ action_cards.append(_source_card("iGPSPORT", sync_igp_button, upload_igp_workouts_button))
126
+ if config.enable_bryton:
127
+ action_cards.append(_source_card("Bryton", sync_bryton_button, upload_bryton_workouts_button))
128
+
129
+ if len(action_cards) == 1:
130
+ action_area = ft.Row(
131
+ alignment=ft.MainAxisAlignment.CENTER,
132
+ controls=[ft.Container(content=action_cards[0], width=340)],
133
+ )
134
+ elif action_cards:
135
+ if mobile:
136
+ action_area = ft.Column(spacing=theme.SPACE_MD, controls=action_cards)
137
+ else:
138
+ action_area = ft.Row(spacing=theme.SPACE_MD, controls=action_cards)
139
+ else:
140
+ action_area = theme.muted_text("Enable a source in Settings to sync.", page)
141
+
142
+ action_buttons: list[ft.FilledButton | ft.OutlinedButton] = []
143
+ if config.enable_igpsport:
144
+ action_buttons.extend([sync_igp_button, upload_igp_workouts_button])
145
+ if config.enable_bryton:
146
+ action_buttons.extend([sync_bryton_button, upload_bryton_workouts_button])
147
+
148
+ def set_buttons_enabled(enabled: bool) -> None:
149
+ for button in action_buttons:
150
+ button.disabled = not enabled
151
+
152
+ def append_log(message: str) -> None:
153
+ line_color = colors["text"]
154
+ if message.startswith("✗"):
155
+ line_color = ft.Colors.RED_400
156
+ elif message.startswith("\nDone"):
157
+ line_color = colors["accent"]
158
+ log.controls.append(
159
+ ft.Text(
160
+ message,
161
+ size=12,
162
+ selectable=True,
163
+ font_family="Courier New",
164
+ color=line_color,
165
+ )
166
+ )
167
+ page.update()
168
+
169
+ def _on_sync_complete(*, activities: int = 0, workouts: int = 0) -> None:
170
+ milestone = support_gamification.record_uploads(
171
+ config,
172
+ activities=activities,
173
+ workouts=workouts,
174
+ )
175
+ support_gamification.update_stats_display(page, config, stats_refs)
176
+ if milestone is not None:
177
+
178
+ async def _celebrate() -> None:
179
+ await support_gamification.show_milestone_dialog(page, milestone)
180
+
181
+ page.run_task(_celebrate)
182
+
183
+ def run_igpsport_sync(
184
+ igp_password: str,
185
+ api_key: str | None,
186
+ dropbox_refresh_token: str | None,
187
+ dropbox_app_key: str | None,
188
+ ) -> None:
189
+ sync_config = IgpSyncConfig(
190
+ igp_user=config.igp_user,
191
+ igp_password=igp_password,
192
+ igp_region=config.igp_region,
193
+ intervals_api_key=api_key,
194
+ dropbox_refresh_token=dropbox_refresh_token,
195
+ dropbox_app_key=dropbox_app_key,
196
+ max_activities=config.max_activities,
197
+ download_dir=config.download_dir,
198
+ delete_after_upload=config.delete_after_upload,
199
+ force_resync=config.force_resync,
200
+ activity_type=config.activity_type,
201
+ list_activities=config.step_list_activities,
202
+ get_download_url=config.step_get_download_url,
203
+ download_fit=config.step_download_fit,
204
+ upload_intervals=config.step_upload_intervals,
205
+ upload_dropbox=config.upload_dropbox,
206
+ dropbox_folder=config.dropbox_folder,
207
+ dropbox_date_filenames=config.dropbox_date_filenames,
208
+ )
209
+
210
+ uploaded_count = 0
211
+ try:
212
+ result = igpsport_sync(sync_config, progress=append_log)
213
+ uploaded_count = result.uploaded
214
+ append_log(
215
+ f"\nDone — intervals uploaded {result.uploaded}, "
216
+ f"Dropbox uploaded {result.uploaded_dropbox}, "
217
+ f"downloaded {result.downloaded}, "
218
+ f"skipped {result.skipped}, Dropbox skipped {result.skipped_dropbox}, "
219
+ f"failed {result.failed}, Dropbox failed {result.failed_dropbox}."
220
+ )
221
+ except SyncError as exc:
222
+ append_log(f"✗ {exc}")
223
+ except Exception as exc: # noqa: BLE001 — surface any failure to the user
224
+ append_log(f"✗ Unexpected error: {exc}")
225
+ finally:
226
+ if uploaded_count > 0:
227
+ _on_sync_complete(activities=uploaded_count)
228
+ progress.visible = False
229
+ set_buttons_enabled(True)
230
+ page.update()
231
+
232
+ def run_bryton_sync(
233
+ bryton_password: str,
234
+ api_key: str | None,
235
+ dropbox_refresh_token: str | None,
236
+ dropbox_app_key: str | None,
237
+ ) -> None:
238
+ sync_config = BrytonSyncConfig(
239
+ bryton_email=config.bryton_user,
240
+ bryton_password=bryton_password,
241
+ intervals_api_key=api_key,
242
+ dropbox_refresh_token=dropbox_refresh_token,
243
+ dropbox_app_key=dropbox_app_key,
244
+ max_activities=config.max_activities,
245
+ download_dir=Path(config.download_dir),
246
+ delete_after_upload=config.delete_after_upload,
247
+ force_resync=config.force_resync,
248
+ activity_type=config.activity_type,
249
+ list_activities=config.step_list_activities,
250
+ download_fit=config.step_download_fit,
251
+ upload_intervals=config.step_upload_intervals,
252
+ upload_dropbox=config.upload_dropbox,
253
+ dropbox_folder=config.dropbox_folder,
254
+ dropbox_date_filenames=config.dropbox_date_filenames,
255
+ )
256
+
257
+ uploaded_count = 0
258
+ try:
259
+ result = bryton_sync(sync_config, progress=append_log)
260
+ uploaded_count = result.uploaded
261
+ append_log(
262
+ f"\nDone — intervals uploaded {result.uploaded}, "
263
+ f"Dropbox uploaded {result.uploaded_dropbox}, "
264
+ f"downloaded {result.downloaded}, "
265
+ f"skipped {result.skipped}, Dropbox skipped {result.skipped_dropbox}, "
266
+ f"failed {result.failed}, Dropbox failed {result.failed_dropbox}."
267
+ )
268
+ except BrytonSyncError as exc:
269
+ append_log(f"✗ {exc}")
270
+ except Exception as exc: # noqa: BLE001 — surface any failure to the user
271
+ append_log(f"✗ Unexpected error: {exc}")
272
+ finally:
273
+ if uploaded_count > 0:
274
+ _on_sync_complete(activities=uploaded_count)
275
+ progress.visible = False
276
+ set_buttons_enabled(True)
277
+ page.update()
278
+
279
+ def run_upload_igp_workouts(igp_password: str, api_key: str) -> None:
280
+ upload_config = WorkoutUploadConfig(
281
+ igp_user=config.igp_user,
282
+ igp_password=igp_password,
283
+ igp_region=config.igp_region,
284
+ intervals_api_key=api_key,
285
+ workout_days_ahead=config.workout_days_ahead,
286
+ uploaded_workouts=dict(config.uploaded_workouts),
287
+ force_resync=config.force_resync,
288
+ )
289
+
290
+ uploaded_count = 0
291
+ try:
292
+ result = upload_workouts(upload_config, progress=append_log)
293
+ if result.uploaded_map or result.pruned_keys:
294
+ apply_uploaded_workout_map(config.uploaded_workouts, result)
295
+ config_module.save(config)
296
+ uploaded_count = result.uploaded
297
+ append_log(
298
+ f"\nDone — uploaded {result.uploaded}, "
299
+ f"skipped {result.skipped}, no steps {result.no_steps}, "
300
+ f"failed {result.failed}."
301
+ )
302
+ except SyncError as exc:
303
+ append_log(f"✗ {exc}")
304
+ except Exception as exc: # noqa: BLE001 — surface any failure to the user
305
+ append_log(f"✗ Unexpected error: {exc}")
306
+ finally:
307
+ if uploaded_count > 0:
308
+ _on_sync_complete(workouts=uploaded_count)
309
+ progress.visible = False
310
+ set_buttons_enabled(True)
311
+ page.update()
312
+
313
+ def run_upload_bryton_workouts(bryton_password: str, api_key: str) -> None:
314
+ upload_config = BrytonWorkoutUploadConfig(
315
+ bryton_email=config.bryton_user,
316
+ bryton_password=bryton_password,
317
+ intervals_api_key=api_key,
318
+ workout_days_ahead=config.workout_days_ahead,
319
+ uploaded_workouts=dict(config.uploaded_bryton_workouts),
320
+ force_resync=config.force_resync,
321
+ )
322
+
323
+ uploaded_count = 0
324
+ try:
325
+ result = bryton_upload_workouts(upload_config, progress=append_log)
326
+ if result.uploaded_map or result.pruned_keys:
327
+ apply_uploaded_bryton_workout_map(config.uploaded_bryton_workouts, result)
328
+ config_module.save(config)
329
+ uploaded_count = result.uploaded
330
+ append_log(
331
+ f"\nDone — uploaded {result.uploaded}, "
332
+ f"skipped {result.skipped}, no steps {result.no_steps}, "
333
+ f"failed {result.failed}."
334
+ )
335
+ except BrytonSyncError as exc:
336
+ append_log(f"✗ {exc}")
337
+ except Exception as exc: # noqa: BLE001 — surface any failure to the user
338
+ append_log(f"✗ Unexpected error: {exc}")
339
+ finally:
340
+ if uploaded_count > 0:
341
+ _on_sync_complete(workouts=uploaded_count)
342
+ progress.visible = False
343
+ set_buttons_enabled(True)
344
+ page.update()
345
+
346
+ async def on_sync_igp_click(_: ft.ControlEvent) -> None:
347
+ igp_password = await store.get(secrets_module.IGP_PASSWORD)
348
+ if not config.igp_user or not igp_password:
349
+ page.show_dialog(ft.SnackBar(ft.Text("Add your iGPSPORT credentials in Settings first.")))
350
+ return
351
+ api_key = await store.get(secrets_module.INTERVALS_API_KEY)
352
+ dropbox_refresh_token = await store.get(secrets_module.DROPBOX_REFRESH_TOKEN)
353
+ dropbox_app_key = get_dropbox_app_key()
354
+
355
+ log.controls.clear()
356
+ progress.visible = True
357
+ set_buttons_enabled(False)
358
+ page.update()
359
+ page.run_thread(
360
+ run_igpsport_sync,
361
+ igp_password,
362
+ api_key,
363
+ dropbox_refresh_token,
364
+ dropbox_app_key,
365
+ )
366
+
367
+ async def on_sync_bryton_click(_: ft.ControlEvent) -> None:
368
+ bryton_password = await store.get(secrets_module.BRYTON_PASSWORD)
369
+ if not config.bryton_user or not bryton_password:
370
+ page.show_dialog(ft.SnackBar(ft.Text("Add your Bryton credentials in Settings first.")))
371
+ return
372
+ api_key = await store.get(secrets_module.INTERVALS_API_KEY)
373
+ dropbox_refresh_token = await store.get(secrets_module.DROPBOX_REFRESH_TOKEN)
374
+ dropbox_app_key = get_dropbox_app_key()
375
+
376
+ log.controls.clear()
377
+ progress.visible = True
378
+ set_buttons_enabled(False)
379
+ page.update()
380
+ page.run_thread(
381
+ run_bryton_sync,
382
+ bryton_password,
383
+ api_key,
384
+ dropbox_refresh_token,
385
+ dropbox_app_key,
386
+ )
387
+
388
+ async def on_upload_igp_workouts_click(_: ft.ControlEvent) -> None:
389
+ igp_password = await store.get(secrets_module.IGP_PASSWORD)
390
+ if not config.igp_user or not igp_password:
391
+ page.show_dialog(ft.SnackBar(ft.Text("Add your iGPSPORT credentials in Settings first.")))
392
+ return
393
+ api_key = await store.get(secrets_module.INTERVALS_API_KEY)
394
+ if not api_key:
395
+ page.show_dialog(
396
+ ft.SnackBar(ft.Text("Add your intervals.icu API key in Settings first."))
397
+ )
398
+ return
399
+
400
+ log.controls.clear()
401
+ progress.visible = True
402
+ set_buttons_enabled(False)
403
+ page.update()
404
+ page.run_thread(run_upload_igp_workouts, igp_password, api_key)
405
+
406
+ async def on_upload_bryton_workouts_click(_: ft.ControlEvent) -> None:
407
+ bryton_password = await store.get(secrets_module.BRYTON_PASSWORD)
408
+ if not config.bryton_user or not bryton_password:
409
+ page.show_dialog(ft.SnackBar(ft.Text("Add your Bryton credentials in Settings first.")))
410
+ return
411
+ api_key = await store.get(secrets_module.INTERVALS_API_KEY)
412
+ if not api_key:
413
+ page.show_dialog(
414
+ ft.SnackBar(ft.Text("Add your intervals.icu API key in Settings first."))
415
+ )
416
+ return
417
+
418
+ log.controls.clear()
419
+ progress.visible = True
420
+ set_buttons_enabled(False)
421
+ page.update()
422
+ page.run_thread(run_upload_bryton_workouts, bryton_password, api_key)
423
+
424
+ sync_igp_button.on_click = on_sync_igp_click
425
+ sync_bryton_button.on_click = on_sync_bryton_click
426
+ upload_igp_workouts_button.on_click = on_upload_igp_workouts_click
427
+ upload_bryton_workouts_button.on_click = on_upload_bryton_workouts_click
428
+
429
+ log_panel = ft.Container(
430
+ content=ft.Column(
431
+ spacing=theme.SPACE_SM,
432
+ controls=[
433
+ ft.Row(
434
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
435
+ controls=[
436
+ theme.section_label("Activity log", page),
437
+ ft.Text(
438
+ "live",
439
+ size=10,
440
+ color=colors["accent"],
441
+ font_family=f"{theme.FONT_BODY}Medium",
442
+ ),
443
+ ],
444
+ ),
445
+ ft.Container(
446
+ content=log,
447
+ expand=True,
448
+ padding=theme.SPACE_MD,
449
+ bgcolor=colors["surface_alt"],
450
+ border_radius=theme.RADIUS_SM,
451
+ border=ft.Border(left=ft.BorderSide(3, colors["accent"])),
452
+ ),
453
+ ],
454
+ ),
455
+ padding=theme.SPACE_LG,
456
+ bgcolor=colors["surface"],
457
+ border=ft.Border.all(1, colors["border"]),
458
+ border_radius=theme.RADIUS_MD,
459
+ )
460
+
461
+ return ft.Column(
462
+ spacing=theme.SPACE_LG,
463
+ controls=[
464
+ ft.Column(
465
+ spacing=theme.SPACE_SM,
466
+ controls=[
467
+ theme.display_text("Your rides, synced", size=26, color=colors["text"]),
468
+ theme.muted_text(
469
+ "Pull recent activities from iGPSPORT or Bryton into intervals.icu, "
470
+ "or push planned workouts the other way.",
471
+ page,
472
+ ),
473
+ ],
474
+ ),
475
+ action_area,
476
+ progress,
477
+ log_panel,
478
+ stats_card,
479
+ *(
480
+ [support_gamification.build_dev_milestone_panel(page, config, stats_refs)]
481
+ if support_gamification.dev_mode_enabled()
482
+ else []
483
+ ),
484
+ ft.Container(height=theme.SPACE_MD),
485
+ ],
486
+ )
@@ -0,0 +1,21 @@
1
+ """Small OS integration helpers for the desktop GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def open_folder(path: str | Path) -> None:
12
+ """Open `path` in the OS file manager, creating it first if needed."""
13
+ folder = Path(path)
14
+ folder.mkdir(parents=True, exist_ok=True)
15
+
16
+ if sys.platform == "win32":
17
+ os.startfile(folder) # type: ignore[attr-defined] # Windows-only
18
+ elif sys.platform == "darwin":
19
+ subprocess.run(["open", str(folder)], check=False)
20
+ else:
21
+ subprocess.run(["xdg-open", str(folder)], check=False)