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,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}")
|