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,225 @@
1
+ """GUI helpers for iGPSPORT profile / zone sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Callable
7
+
8
+ import flet as ft
9
+
10
+ from ..igpsport.core import SyncError
11
+ from ..igpsport.profile_sync import (
12
+ ProfileSyncConfig,
13
+ ProfileSyncResult,
14
+ ProfileThresholdStatus,
15
+ fetch_profile_threshold_status,
16
+ sync_profile_zones,
17
+ )
18
+ from . import config as config_module
19
+ from . import secrets as secrets_module
20
+ from . import theme
21
+
22
+
23
+ async def credentials_ready(
24
+ config: config_module.AppConfig,
25
+ store: secrets_module.SecretStore,
26
+ ) -> tuple[str, str] | None:
27
+ if not config.enable_igpsport or not config.igp_user:
28
+ return None
29
+ igp_password = await store.get(secrets_module.IGP_PASSWORD)
30
+ api_key = await store.get(secrets_module.INTERVALS_API_KEY)
31
+ if not igp_password or not api_key:
32
+ return None
33
+ return igp_password, api_key
34
+
35
+
36
+ def run_sync_profile_zones(
37
+ config: config_module.AppConfig,
38
+ igp_password: str,
39
+ api_key: str,
40
+ *,
41
+ progress: Callable[[str], None] | None = None,
42
+ ) -> ProfileSyncResult:
43
+ sync_config = ProfileSyncConfig(
44
+ igp_user=config.igp_user,
45
+ igp_password=igp_password,
46
+ intervals_api_key=api_key,
47
+ igp_region=config.igp_region,
48
+ )
49
+ return sync_profile_zones(sync_config, progress=progress)
50
+
51
+
52
+ async def check_profile_thresholds(
53
+ config: config_module.AppConfig,
54
+ store: secrets_module.SecretStore,
55
+ ) -> ProfileThresholdStatus | None:
56
+ creds = await credentials_ready(config, store)
57
+ if creds is None:
58
+ return None
59
+ igp_password, api_key = creds
60
+ try:
61
+ return await asyncio.to_thread(
62
+ fetch_profile_threshold_status,
63
+ ProfileSyncConfig(
64
+ igp_user=config.igp_user,
65
+ igp_password=igp_password,
66
+ intervals_api_key=api_key,
67
+ igp_region=config.igp_region,
68
+ ),
69
+ )
70
+ except (SyncError, Exception): # noqa: BLE001 — fail silent for background checks
71
+ return None
72
+
73
+
74
+ def _should_prompt(
75
+ config: config_module.AppConfig,
76
+ status: ProfileThresholdStatus,
77
+ ) -> bool:
78
+ if not status.needs_sync:
79
+ return False
80
+ if (
81
+ config.profile_sync_declined_fingerprint
82
+ and status.intervals_fingerprint == config.profile_sync_declined_fingerprint
83
+ ):
84
+ return False
85
+ return True
86
+
87
+
88
+ def _clear_declined_fingerprint(config: config_module.AppConfig) -> None:
89
+ if config.profile_sync_declined_fingerprint:
90
+ config.profile_sync_declined_fingerprint = ""
91
+ config_module.save(config)
92
+
93
+
94
+ def _save_declined_fingerprint(
95
+ config: config_module.AppConfig,
96
+ status: ProfileThresholdStatus,
97
+ ) -> None:
98
+ config.profile_sync_declined_fingerprint = status.intervals_fingerprint
99
+ config_module.save(config)
100
+
101
+
102
+ def _sync_success_message(result: ProfileSyncResult) -> str:
103
+ if result.after is None:
104
+ return "iGPSPORT profile updated."
105
+ member = result.after.get("member")
106
+ if not isinstance(member, dict):
107
+ return "iGPSPORT profile updated."
108
+ parts: list[str] = []
109
+ for key, label in (("ftp", "FTP"), ("lthr", "LTHR"), ("mhr", "max HR")):
110
+ if key in member:
111
+ parts.append(f"{label} {member[key]}")
112
+ if result.weight_after is not None:
113
+ parts.append(f"weight {result.weight_after}")
114
+ if parts:
115
+ return "iGPSPORT profile updated — " + ", ".join(parts) + "."
116
+ return "iGPSPORT profile updated."
117
+
118
+
119
+ async def sync_with_feedback(
120
+ page: ft.Page,
121
+ config: config_module.AppConfig,
122
+ store: secrets_module.SecretStore,
123
+ *,
124
+ clear_declined: bool = True,
125
+ ) -> bool:
126
+ creds = await credentials_ready(config, store)
127
+ if creds is None:
128
+ page.show_dialog(
129
+ ft.SnackBar(
130
+ ft.Text("Add iGPSPORT credentials and intervals.icu API key in Settings first.")
131
+ )
132
+ )
133
+ page.update()
134
+ return False
135
+
136
+ igp_password, api_key = creds
137
+ page.show_dialog(ft.SnackBar(ft.Text("Updating iGPSPORT profile…")))
138
+ page.update()
139
+
140
+ def _run_sync() -> tuple[bool, str]:
141
+ try:
142
+ result = run_sync_profile_zones(config, igp_password, api_key)
143
+ return True, _sync_success_message(result)
144
+ except SyncError as exc:
145
+ return False, str(exc)
146
+ except Exception as exc: # noqa: BLE001 — surface any failure to the user
147
+ return False, f"Unexpected error: {exc}"
148
+
149
+ ok, message = await asyncio.to_thread(_run_sync)
150
+ if ok and clear_declined:
151
+ _clear_declined_fingerprint(config)
152
+ page.show_dialog(ft.SnackBar(ft.Text(message)))
153
+ page.update()
154
+ return ok
155
+
156
+
157
+ async def prompt_if_needed(
158
+ page: ft.Page,
159
+ config: config_module.AppConfig,
160
+ store: secrets_module.SecretStore,
161
+ ) -> None:
162
+ if not config.enable_igpsport:
163
+ return
164
+
165
+ status = await check_profile_thresholds(config, store)
166
+ if status is None or not _should_prompt(config, status):
167
+ return
168
+
169
+ colors = theme.palette(page)
170
+
171
+ async def dismiss(_: ft.ControlEvent) -> None:
172
+ page.pop_dialog()
173
+ _save_declined_fingerprint(config, status)
174
+ page.update()
175
+
176
+ async def update_now(_: ft.ControlEvent) -> None:
177
+ page.pop_dialog()
178
+ page.update()
179
+ await sync_with_feedback(page, config, store)
180
+
181
+ difference_lines = [
182
+ ft.Text(f"• {diff}", size=13, color=colors["text"])
183
+ for diff in status.differences
184
+ ]
185
+ page.show_dialog(
186
+ ft.AlertDialog(
187
+ modal=True,
188
+ shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD),
189
+ title=theme.display_text("Update iGPSPORT profile?", size=20),
190
+ content=ft.Column(
191
+ tight=True,
192
+ spacing=theme.SPACE_SM,
193
+ controls=[
194
+ ft.Text(
195
+ "Your iGPSPORT profile differs from intervals.icu:",
196
+ size=13,
197
+ color=colors["text_muted"],
198
+ ),
199
+ *difference_lines,
200
+ ft.Text(
201
+ "Power/HR zones and weight will be updated too.",
202
+ size=12,
203
+ color=colors["text_muted"],
204
+ ),
205
+ ],
206
+ ),
207
+ actions=[
208
+ ft.TextButton("Not now", on_click=dismiss),
209
+ ft.TextButton("Update now", on_click=update_now),
210
+ ],
211
+ )
212
+ )
213
+ page.update()
214
+
215
+
216
+ def format_threshold_status(status: ProfileThresholdStatus | None) -> str:
217
+ if status is None:
218
+ return "Could not check profile status."
219
+ if not status.needs_sync:
220
+ return "In sync with intervals.icu."
221
+ if len(status.differences) == 1:
222
+ return f"Out of sync: {status.differences[0]}"
223
+ return "Out of sync: " + ", ".join(
224
+ diff.split(":")[0] for diff in status.differences
225
+ )
@@ -0,0 +1,135 @@
1
+ """Secure secret storage.
2
+
3
+ Secrets are kept in the operating system's native vault (Windows Credential
4
+ Manager, macOS Keychain, Android Keystore, Linux libsecret) — never in a
5
+ plaintext file.
6
+
7
+ `SecretStore` is the async seam between the GUI and storage. Most platforms use
8
+ `flet-secure-storage`, while macOS uses the native `security` CLI to avoid
9
+ Flet's prebuilt desktop runner hitting Keychain entitlement errors.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import subprocess
16
+ from abc import ABC, abstractmethod
17
+
18
+ from flet_secure_storage import SecureStorage
19
+
20
+ # Logical secret keys.
21
+ IGP_PASSWORD = "igp_password"
22
+ BRYTON_PASSWORD = "bryton_password"
23
+ INTERVALS_API_KEY = "intervals_api_key"
24
+ DROPBOX_REFRESH_TOKEN = "dropbox_refresh_token"
25
+ MACOS_KEYCHAIN_SERVICE = "io.github.jorgehuxley.intervalssync"
26
+
27
+
28
+ class SecretStore(ABC):
29
+ @abstractmethod
30
+ async def get(self, key: str) -> str | None: ...
31
+
32
+ @abstractmethod
33
+ async def set(self, key: str, value: str) -> None: ...
34
+
35
+ @abstractmethod
36
+ async def delete(self, key: str) -> None: ...
37
+
38
+
39
+ class FletSecureStorage(SecretStore):
40
+ """Stores secrets in the OS-native vault via a flet-secure-storage service.
41
+
42
+ The `SecureStorage` service must already be registered on the page
43
+ (``page.services.append(storage)``) before use.
44
+ """
45
+
46
+ def __init__(self, storage: SecureStorage) -> None:
47
+ self._storage = storage
48
+
49
+ async def get(self, key: str) -> str | None:
50
+ return await self._storage.get(key)
51
+
52
+ async def set(self, key: str, value: str) -> None:
53
+ await self._storage.set(key, value)
54
+
55
+ async def delete(self, key: str) -> None:
56
+ await self._storage.remove(key)
57
+
58
+
59
+ class MacOSKeychainStore(SecretStore):
60
+ """Stores secrets in the user's macOS login Keychain."""
61
+
62
+ def __init__(self, service: str = MACOS_KEYCHAIN_SERVICE) -> None:
63
+ self._service = service
64
+
65
+ async def get(self, key: str) -> str | None:
66
+ return await asyncio.to_thread(self._get_sync, key)
67
+
68
+ async def set(self, key: str, value: str) -> None:
69
+ await asyncio.to_thread(self._set_sync, key, value)
70
+
71
+ async def delete(self, key: str) -> None:
72
+ await asyncio.to_thread(self._delete_sync, key)
73
+
74
+ def _get_sync(self, key: str) -> str | None:
75
+ result = self._run_security(
76
+ "find-generic-password",
77
+ "-s",
78
+ self._service,
79
+ "-a",
80
+ key,
81
+ "-w",
82
+ )
83
+ if result.returncode == 0:
84
+ return result.stdout.removesuffix("\n")
85
+ if _is_missing_keychain_item(result):
86
+ return None
87
+ raise _keychain_error("read", key, result)
88
+
89
+ def _set_sync(self, key: str, value: str) -> None:
90
+ result = self._run_security(
91
+ "add-generic-password",
92
+ "-U",
93
+ "-s",
94
+ self._service,
95
+ "-a",
96
+ key,
97
+ "-w",
98
+ value,
99
+ )
100
+ if result.returncode != 0:
101
+ raise _keychain_error("save", key, result)
102
+
103
+ def _delete_sync(self, key: str) -> None:
104
+ result = self._run_security(
105
+ "delete-generic-password",
106
+ "-s",
107
+ self._service,
108
+ "-a",
109
+ key,
110
+ )
111
+ if result.returncode == 0 or _is_missing_keychain_item(result):
112
+ return
113
+ raise _keychain_error("delete", key, result)
114
+
115
+ def _run_security(self, *args: str) -> subprocess.CompletedProcess[str]:
116
+ return subprocess.run(
117
+ ["security", *args],
118
+ capture_output=True,
119
+ check=False,
120
+ text=True,
121
+ )
122
+
123
+
124
+ def _is_missing_keychain_item(result: subprocess.CompletedProcess[str]) -> bool:
125
+ output = f"{result.stdout}\n{result.stderr}".lower()
126
+ return result.returncode == 44 or "could not be found" in output
127
+
128
+
129
+ def _keychain_error(
130
+ action: str,
131
+ key: str,
132
+ result: subprocess.CompletedProcess[str],
133
+ ) -> RuntimeError:
134
+ detail = (result.stderr or result.stdout or f"exit code {result.returncode}").strip()
135
+ return RuntimeError(f"Could not {action} Keychain item {key!r}: {detail}")