diff --git a/cdn/static/web/launcher/intervals.html b/cdn/static/web/launcher/intervals.html
index 286aaea..9f1ee13 100644
--- a/cdn/static/web/launcher/intervals.html
+++ b/cdn/static/web/launcher/intervals.html
@@ -8,6 +8,7 @@
@@ -43,5 +44,19 @@
{% endif %}
{% endwith %}
+ {% if sync_status and aid and akey %}
+
+
+ -
+ {% if sync_status.metadata %}
+
Synced workout: {{ sync_status.metadata.name or sync_status.metadata.filename }}
+ {% else %}
+ No synced Intervals workout.
+ Use "Sync today's workout" before launching Zwift.
+ {% endif %}
+
+
+
+ {% endif %}
{% endblock %}
diff --git a/cdn/static/web/launcher/settings.html b/cdn/static/web/launcher/settings.html
index 91fa9ec..d7db488 100644
--- a/cdn/static/web/launcher/settings.html
+++ b/cdn/static/web/launcher/settings.html
@@ -14,6 +14,7 @@
Zwift
Strava
Intervals
+ TrainingPeaks
{% if files %}
diff --git a/cdn/static/web/launcher/trainingpeaks.html b/cdn/static/web/launcher/trainingpeaks.html
new file mode 100644
index 0000000..8068655
--- /dev/null
+++ b/cdn/static/web/launcher/trainingpeaks.html
@@ -0,0 +1,51 @@
+{% extends "./layout.html" %}
+{% block content %}
+ TrainingPeaks
+ {% if username != "zoffline" %}
+ Logged in as {{ username }}
+ {% endif %}
+
+
+
+
+ {% with messages = get_flashed_messages() %}
+ {% if messages %}
+
+ {% for message in messages %}
+ -
+
{{ message }}
+
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+
+
+
+
+
+
Manual bridge mode: export TrainingPeaks workouts as .zwo files into a folder, save that folder path here, then click "Import workouts".
+
+
+
+
+{% endblock %}
diff --git a/cdn/static/web/launcher/user_home.html b/cdn/static/web/launcher/user_home.html
index e0d60e3..8e41a48 100644
--- a/cdn/static/web/launcher/user_home.html
+++ b/cdn/static/web/launcher/user_home.html
@@ -83,5 +83,21 @@
{% endif %}
{% endwith %}
+ {% if workout_sync_status %}
+
+ {% endif %}
{% endblock %}
diff --git a/intervals_workouts.py b/intervals_workouts.py
new file mode 100644
index 0000000..59863c7
--- /dev/null
+++ b/intervals_workouts.py
@@ -0,0 +1,135 @@
+import datetime
+import re
+from io import BytesIO
+from urllib.parse import urlencode
+
+from requests.auth import HTTPBasicAuth
+
+
+DEFAULT_BASE_URL = "https://intervals.icu/api/v1"
+
+
+def normalize_events_payload(payload):
+ if isinstance(payload, list):
+ return payload
+ if isinstance(payload, dict):
+ for key in ("events", "data", "items"):
+ value = payload.get(key)
+ if isinstance(value, list):
+ return value
+ return []
+
+
+def parse_datetime(value):
+ if not value:
+ return None
+ if isinstance(value, datetime.datetime):
+ parsed = value
+ else:
+ if value.endswith("Z"):
+ value = value[:-1] + "+00:00"
+ try:
+ parsed = datetime.datetime.fromisoformat(value)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=datetime.timezone.utc)
+ return parsed
+
+
+def parse_event_datetime(event):
+ value = event.get("start_date_local") or event.get("start_date") or event.get("date")
+ return parse_datetime(value)
+
+
+def choose_workout_event(events, now=None):
+ if not events:
+ return None
+ now = now or datetime.datetime.now(datetime.timezone.utc)
+ ordered = sorted(events, key=lambda event: parse_event_datetime(event) or datetime.datetime.max.replace(tzinfo=datetime.timezone.utc))
+ future = [event for event in ordered if (parse_event_datetime(event) or now) >= now]
+ if future:
+ return future[0]
+ return ordered[-1]
+
+
+def slugify_filename(value):
+ value = (value or "").strip().lower()
+ value = re.sub(r"[^a-z0-9]+", "-", value)
+ return value.strip("-")
+
+
+def build_workout_filename(event):
+ event_id = event.get("id", "workout")
+ name = slugify_filename(event.get("name") or f"workout-{event_id}")
+ if not name:
+ name = f"workout-{event_id}"
+ return f"intervals-icu-{event_id}-{name}.zwo"
+
+
+def build_activity_upload_url(base_url, athlete_id, activity_name, paired_event_id=None):
+ params = {"name": activity_name}
+ if paired_event_id:
+ params["paired_event_id"] = paired_event_id
+ return f"{base_url}/athlete/{athlete_id}/activities?{urlencode(params)}"
+
+
+def activity_matches_workout(activity, workout_metadata):
+ if not workout_metadata:
+ return False
+ event_dt = parse_datetime(workout_metadata.get("start_date_local") or workout_metadata.get("start_date") or workout_metadata.get("synced_for_date"))
+ activity_dt = parse_datetime(getattr(activity, "start_date", None) or getattr(activity, "date", None))
+ if not event_dt or not activity_dt:
+ return False
+ return event_dt.date() == activity_dt.date()
+
+
+def upload_activity(athlete_id, api_key, activity, workout_metadata=None, session=None, base_url=DEFAULT_BASE_URL):
+ session = session or __import__("requests")
+ paired_event_id = None
+ if activity_matches_workout(activity, workout_metadata):
+ paired_event_id = workout_metadata.get("event_id") or workout_metadata.get("id")
+ upload_url = build_activity_upload_url(base_url, athlete_id, activity.name, paired_event_id=paired_event_id)
+ upload_response = session.post(upload_url, files={"file": BytesIO(activity.fit)}, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30)
+ upload_response.raise_for_status()
+ if paired_event_id:
+ mark_done_url = f"{base_url}/athlete/{athlete_id}/events/{paired_event_id}/mark-done"
+ mark_done_response = session.post(mark_done_url, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30)
+ mark_done_response.raise_for_status()
+ return {
+ "status": "uploaded",
+ "paired": bool(paired_event_id),
+ "paired_event_id": paired_event_id,
+ }
+
+
+def sync_workout(athlete_id, api_key, store_workout, today=None, now=None, session=None, base_url=DEFAULT_BASE_URL):
+ today = today or datetime.date.today()
+ now = now or datetime.datetime.now(datetime.timezone.utc)
+ session = session or __import__("requests")
+
+ query = urlencode({
+ "oldest": today.isoformat(),
+ "newest": today.isoformat(),
+ "category": "WORKOUT",
+ })
+ events_url = f"{base_url}/athlete/{athlete_id}/events?{query}"
+ events_response = session.get(events_url, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30)
+ events_response.raise_for_status()
+ events = normalize_events_payload(events_response.json())
+ event = choose_workout_event(events, now=now)
+ if not event:
+ return {"status": "no_workout", "message": f"No Intervals.icu workout found for {today.isoformat()}"}
+
+ workout_url = f"{base_url}/athlete/{athlete_id}/events/{event['id']}/download.zwo"
+ workout_response = session.get(workout_url, auth=HTTPBasicAuth("API_KEY", api_key), timeout=30)
+ workout_response.raise_for_status()
+
+ filename = build_workout_filename(event)
+ store_workout(filename, workout_response.content, event)
+ return {
+ "status": "synced",
+ "event": event,
+ "filename": filename,
+ "message": f"Synced Intervals.icu workout: {event.get('name', filename)}",
+ }
diff --git a/trainingpeaks_workouts.py b/trainingpeaks_workouts.py
new file mode 100644
index 0000000..d620e99
--- /dev/null
+++ b/trainingpeaks_workouts.py
@@ -0,0 +1,78 @@
+import os
+import re
+
+
+PARTNER_ACCESS_MESSAGE = (
+ 'TrainingPeaks automatic sync requires approved partner API access and app credentials. '
+ 'This provider is scaffolded but not yet live-configured.'
+)
+
+
+def slugify_filename(value):
+ value = (value or '').strip().lower()
+ value = re.sub(r'[^a-z0-9]+', '-', value)
+ return value.strip('-')
+
+
+def build_workout_filename(workout):
+ workout_id = workout.get('id', 'workout')
+ name = slugify_filename(workout.get('name') or workout.get('title') or f'workout-{workout_id}')
+ if not name:
+ name = f'workout-{workout_id}'
+ return f'trainingpeaks-{workout_id}-{name}.zwo'
+
+
+def build_exported_filename(path):
+ base = os.path.splitext(os.path.basename(path))[0]
+ slug = slugify_filename(base)
+ if not slug:
+ slug = 'workout'
+ return f'trainingpeaks-{slug}.zwo'
+
+
+def sync_exported_workouts(folder, store_workout):
+ if not folder or not os.path.isdir(folder):
+ return {
+ 'status': 'missing_folder',
+ 'message': 'TrainingPeaks bridge folder is missing or unreadable.',
+ }
+ entries = []
+ for name in sorted(os.listdir(folder), key=lambda item: item.lower()):
+ path = os.path.join(folder, name)
+ if not os.path.isfile(path) or not name.lower().endswith('.zwo'):
+ continue
+ with open(path, 'rb') as fd:
+ content = fd.read()
+ filename = build_exported_filename(path)
+ workout = {'title': os.path.splitext(name)[0], 'source_path': path}
+ store_workout(filename, content, workout)
+ entries.append(filename)
+ if not entries:
+ return {
+ 'status': 'no_workout',
+ 'message': 'No .zwo workouts were found in the TrainingPeaks bridge folder.',
+ 'count': 0,
+ }
+ return {
+ 'status': 'synced',
+ 'message': f'Imported {len(entries)} TrainingPeaks workout(s) from bridge folder.',
+ 'count': len(entries),
+ 'filenames': entries,
+ }
+
+
+def sync_workout(credentials=None, **kwargs):
+ return {
+ 'status': 'unsupported',
+ 'message': PARTNER_ACCESS_MESSAGE,
+ 'credentials_present': bool(credentials),
+ }
+
+
+def upload_activity(credentials=None, activity=None, **kwargs):
+ return {
+ 'status': 'unsupported',
+ 'message': PARTNER_ACCESS_MESSAGE,
+ 'credentials_present': bool(credentials),
+ 'activity_present': activity is not None,
+ }
diff --git a/workout_state.py b/workout_state.py
new file mode 100644
index 0000000..29b33cb
--- /dev/null
+++ b/workout_state.py
@@ -0,0 +1,80 @@
+import datetime
+import json
+import os
+import re
+
+
+def normalize_provider_name(provider):
+ return re.sub(r'[^a-z0-9]+', '_', provider.lower()).strip('_') or 'provider'
+
+
+def metadata_file(storage_dir, player_id, provider):
+ return os.path.join(storage_dir, str(player_id), f'{normalize_provider_name(provider)}_workout.json')
+
+
+def build_metadata(provider, workout, filename):
+ return {
+ 'provider': provider,
+ 'event_id': workout.get('event_id', workout.get('id')),
+ 'name': workout.get('name', workout.get('title')),
+ 'filename': filename,
+ 'start_date_local': workout.get('start_date_local'),
+ 'start_date': workout.get('start_date'),
+ 'synced_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ }
+
+
+def save_metadata(storage_dir, player_id, provider, workout, filename):
+ payload = build_metadata(provider, workout, filename)
+ file = metadata_file(storage_dir, player_id, provider)
+ os.makedirs(os.path.dirname(file), exist_ok=True)
+ with open(file, 'w') as fd:
+ json.dump(payload, fd)
+ return payload
+
+
+def load_metadata(storage_dir, player_id, provider):
+ file = metadata_file(storage_dir, player_id, provider)
+ if not os.path.exists(file):
+ return None
+ try:
+ with open(file) as fd:
+ return json.load(fd)
+ except Exception:
+ return None
+
+
+def clear_metadata(storage_dir, player_id, provider):
+ file = metadata_file(storage_dir, player_id, provider)
+ if os.path.exists(file):
+ os.remove(file)
+
+
+def active_provider_file(storage_dir, player_id):
+ return os.path.join(storage_dir, str(player_id), 'active_workout_provider.txt')
+
+
+def save_active_provider(storage_dir, player_id, provider):
+ file = active_provider_file(storage_dir, player_id)
+ os.makedirs(os.path.dirname(file), exist_ok=True)
+ with open(file, 'w') as fd:
+ fd.write((provider or '').strip())
+
+
+def load_active_provider(storage_dir, player_id):
+ file = active_provider_file(storage_dir, player_id)
+ if not os.path.exists(file):
+ return None
+ with open(file) as fd:
+ provider = fd.read().strip()
+ return provider or None
+
+
+def resolve_active_provider(saved_provider, available_providers):
+ available = set(available_providers or set())
+ if saved_provider and saved_provider in available:
+ return saved_provider
+ for candidate in ('intervals-icu', 'trainingpeaks'):
+ if candidate in available:
+ return candidate
+ return None
diff --git a/workouts_manifest.py b/workouts_manifest.py
new file mode 100644
index 0000000..473447f
--- /dev/null
+++ b/workouts_manifest.py
@@ -0,0 +1,95 @@
+import hashlib
+import os
+import xml.etree.ElementTree as ET
+
+
+MANIFEST_FILENAME = 'workouts.files'
+
+
+def manifest_file(workouts_dir):
+ return os.path.join(workouts_dir, MANIFEST_FILENAME)
+
+
+def parse_int(value):
+ try:
+ return int((value or '0').strip() or '0')
+ except ValueError:
+ return 0
+
+
+def file_checksum(content):
+ return (-sum(content)) % 256
+
+
+def file_guid(filename):
+ digest = hashlib.sha1(filename.encode('utf-8')).digest()
+ return (int.from_bytes(digest[:4], 'big') & 0x7fffffff) or 1
+
+
+def load_manifest(workouts_dir):
+ path = manifest_file(workouts_dir)
+ if not os.path.exists(path):
+ return []
+ try:
+ root = ET.parse(path).getroot()
+ except ET.ParseError:
+ return []
+ entries = []
+ for node in root.findall('custom_file'):
+ name = (node.findtext('name') or '').strip()
+ if not name:
+ continue
+ entries.append({
+ 'name': name,
+ 'time': parse_int(node.findtext('time')),
+ 'guid': parse_int(node.findtext('guid')),
+ 'checksum': parse_int(node.findtext('checksum')),
+ 'deleted': (node.findtext('deleted') or 'false').strip().lower() == 'true',
+ })
+ return entries
+
+
+def save_manifest(workouts_dir, entries):
+ os.makedirs(workouts_dir, exist_ok=True)
+ root = ET.Element('custom_file_directory')
+ for entry in sorted(entries, key=lambda item: item['name'].lower()):
+ node = ET.SubElement(root, 'custom_file')
+ ET.SubElement(node, 'name').text = entry['name']
+ ET.SubElement(node, 'time').text = str(int(entry['time']))
+ ET.SubElement(node, 'guid').text = str(int(entry['guid']))
+ ET.SubElement(node, 'checksum').text = str(int(entry['checksum']))
+ ET.SubElement(node, 'deleted').text = 'true' if entry.get('deleted') else 'false'
+ ET.SubElement(root, 'deleted_files')
+ tree = ET.ElementTree(root)
+ ET.indent(tree, space=' ')
+ path = manifest_file(workouts_dir)
+ tree.write(path, encoding='utf-8', xml_declaration=False)
+ with open(path, 'a', encoding='utf-8') as fd:
+ fd.write('\n')
+ return path
+
+
+def upsert_manifest_entry(workouts_dir, filename, content, timestamp=None):
+ entries = [entry for entry in load_manifest(workouts_dir) if entry['name'] != filename]
+ timestamp = int(timestamp if timestamp is not None else os.path.getmtime(os.path.join(workouts_dir, filename)))
+ entry = {
+ 'name': filename,
+ 'time': timestamp,
+ 'guid': file_guid(filename),
+ 'checksum': file_checksum(content),
+ 'deleted': False,
+ }
+ entries.append(entry)
+ save_manifest(workouts_dir, entries)
+ return entry
+
+
+def remove_prefixed_workouts(workouts_dir, prefixes):
+ entries = load_manifest(workouts_dir)
+ removed = 0
+ for entry in entries:
+ if entry['name'].startswith(tuple(prefixes)):
+ entry['deleted'] = True
+ removed += 1
+ save_manifest(workouts_dir, entries)
+ return removed
diff --git a/zwift_offline.py b/zwift_offline.py
index 28801a3..c7d055b 100644
--- a/zwift_offline.py
+++ b/zwift_offline.py
@@ -62,6 +62,10 @@ import fitness_pb2
import structured_events_pb2
import online_sync
+import intervals_workouts
+import trainingpeaks_workouts
+import workout_state
+import workouts_manifest
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
logger = logging.getLogger('zoffline')
@@ -209,6 +213,7 @@ class AnonUser(User, AnonymousUserMixin, db.Model):
first_name = "z"
last_name = "offline"
enable_ghosts = os.path.isfile(ENABLEGHOSTS_FILE)
+ is_admin = False
def is_authenticated(self):
return True
@@ -860,6 +865,235 @@ def backup_file(file):
if os.path.isfile(file):
copyfile(file, "%s-%s.bak" % (file, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H-%M-%S")))
+
+def save_player_zfile(player_id, folder, filename, content):
+ zfiles_dir = os.path.join(STORAGE_DIR, str(player_id), folder)
+ if not make_dir(zfiles_dir):
+ raise IOError("failed to create zfiles directory")
+ with open(os.path.join(zfiles_dir, filename), 'wb') as fd:
+ fd.write(content)
+ timestamp = int(time.time())
+ row = Zfile.query.filter_by(folder=folder, filename=filename, player_id=player_id).first()
+ if not row:
+ row = Zfile(folder=folder, filename=filename, timestamp=timestamp, player_id=player_id)
+ db.session.add(row)
+ else:
+ row.timestamp = timestamp
+ db.session.commit()
+ return row
+
+
+def update_workouts_manifest(player_id, folder, filename, content):
+ workouts_manifest.upsert_manifest_entry(os.path.join(STORAGE_DIR, str(player_id), folder), filename, content)
+ timestamp = int(time.time())
+ row = Zfile.query.filter_by(folder=folder, filename=workouts_manifest.MANIFEST_FILENAME, player_id=player_id).first()
+ if not row:
+ row = Zfile(folder=folder, filename=workouts_manifest.MANIFEST_FILENAME, timestamp=timestamp, player_id=player_id)
+ db.session.add(row)
+ else:
+ row.timestamp = timestamp
+ db.session.commit()
+ return row
+
+
+def remove_player_zfiles_by_prefix(player_id, folder, prefix):
+ rows = Zfile.query.filter_by(folder=folder, player_id=player_id)
+ removed = False
+ for row in rows:
+ if not row.filename.startswith(prefix):
+ continue
+ removed = True
+ try:
+ os.remove(os.path.join(STORAGE_DIR, str(row.player_id), row.folder, row.filename))
+ except FileNotFoundError:
+ pass
+ except Exception as exc:
+ logger.warning('remove_player_zfiles_by_prefix: %s' % repr(exc))
+ db.session.delete(row)
+ if removed:
+ db.session.commit()
+
+
+def load_workout_metadata(player_id, provider):
+ payload = workout_state.load_metadata(STORAGE_DIR, player_id, provider)
+ if payload is None:
+ return None
+ return payload
+
+
+def save_workout_metadata(player_id, provider, event, filename):
+ return workout_state.save_metadata(STORAGE_DIR, player_id, provider, event, filename)
+
+
+def clear_workout_metadata(player_id, provider):
+ workout_state.clear_metadata(STORAGE_DIR, player_id, provider)
+
+
+def load_intervals_workout_metadata(player_id):
+ return load_workout_metadata(player_id, 'intervals-icu')
+
+
+def save_intervals_workout_metadata(player_id, event, filename):
+ return save_workout_metadata(player_id, 'intervals-icu', event, filename)
+
+
+def clear_intervals_workout_metadata(player_id):
+ clear_workout_metadata(player_id, 'intervals-icu')
+
+
+def load_active_workout_provider(player_id):
+ return workout_state.load_active_provider(STORAGE_DIR, player_id)
+
+
+def save_active_workout_provider(player_id, provider):
+ workout_state.save_active_provider(STORAGE_DIR, player_id, provider)
+
+
+def available_workout_providers_for_player(player_id):
+ providers = set()
+ intervals_credentials = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, player_id)
+ if os.path.exists(intervals_credentials):
+ providers.add('intervals-icu')
+ if load_trainingpeaks_bridge_folder(player_id):
+ providers.add('trainingpeaks')
+ return providers
+
+
+def resolve_workout_provider_for_player(player_id):
+ return workout_state.resolve_active_provider(load_active_workout_provider(player_id), available_workout_providers_for_player(player_id))
+
+
+def managed_workout_prefixes(provider):
+ if provider == 'intervals-icu':
+ return {'intervals-icu-'}
+ if provider == 'trainingpeaks':
+ return {'trainingpeaks-'}
+ return set()
+
+
+def clear_managed_workouts_for_provider(player_id, provider, clear_metadata=True):
+ prefixes = managed_workout_prefixes(provider)
+ for prefix in prefixes:
+ remove_player_zfiles_by_prefix(player_id, 'customworkouts', prefix)
+ if prefixes:
+ workouts_manifest.remove_prefixed_workouts(os.path.join(STORAGE_DIR, str(player_id), 'customworkouts'), prefixes)
+ if provider == 'intervals-icu' and clear_metadata:
+ clear_intervals_workout_metadata(player_id)
+
+
+def current_workout_sync_status(player_id, provider=None):
+ provider = provider or resolve_workout_provider_for_player(player_id)
+ if not provider:
+ return None
+ metadata = load_workout_metadata(player_id, provider)
+ if not metadata:
+ return {
+ 'provider': provider,
+ 'metadata': None,
+ 'server_file_exists': False,
+ }
+ filename = metadata.get('filename')
+ server_file = os.path.join(STORAGE_DIR, str(player_id), 'customworkouts', filename) if filename else ''
+ return {
+ 'provider': provider,
+ 'metadata': metadata,
+ 'server_file_exists': bool(filename and os.path.exists(server_file)),
+ 'server_file': server_file,
+ }
+
+
+def activate_workout_provider(player_id, provider):
+ save_active_workout_provider(player_id, provider)
+ if provider == 'intervals-icu':
+ clear_managed_workouts_for_provider(player_id, 'trainingpeaks', clear_metadata=False)
+ elif provider == 'trainingpeaks':
+ clear_managed_workouts_for_provider(player_id, 'intervals-icu')
+
+
+def sync_intervals_workout_for_player(player_id):
+ intervals_credentials = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, player_id)
+ if not os.path.exists(intervals_credentials):
+ return {"status": "missing_credentials", "message": "Intervals.icu credentials are not configured."}
+ athlete_id, api_key = decrypt_credentials(intervals_credentials)
+ if not athlete_id or not api_key:
+ return {"status": "missing_credentials", "message": "Intervals.icu credentials are incomplete."}
+
+ activate_workout_provider(player_id, 'intervals-icu')
+ stored = {}
+
+ def store_workout(filename, content, event):
+ clear_managed_workouts_for_provider(player_id, 'intervals-icu', clear_metadata=False)
+ stored['zfile'] = save_player_zfile(player_id, 'customworkouts', filename, content)
+ update_workouts_manifest(player_id, 'customworkouts', filename, content)
+ stored['metadata'] = save_intervals_workout_metadata(player_id, event, filename)
+
+ try:
+ result = intervals_workouts.sync_workout(athlete_id, api_key, store_workout)
+ if result['status'] == 'no_workout':
+ clear_managed_workouts_for_provider(player_id, 'intervals-icu')
+ return {
+ **result,
+ 'sync_status': current_workout_sync_status(player_id, 'intervals-icu'),
+ 'message': 'No Intervals.icu workout found for today.',
+ }
+ if result['status'] == 'synced':
+ sync_status = current_workout_sync_status(player_id, 'intervals-icu')
+ return {
+ **result,
+ 'sync_status': sync_status,
+ }
+ return result
+ except Exception as exc:
+ logger.warning('sync_intervals_workout_for_player: %s' % repr(exc))
+ return {"status": "error", "message": "Intervals.icu workout sync failed."}
+
+
+def trainingpeaks_bridge_folder_file(player_id):
+ return os.path.join(STORAGE_DIR, str(player_id), 'trainingpeaks_bridge_folder.txt')
+
+
+def load_trainingpeaks_bridge_folder(player_id):
+ file = trainingpeaks_bridge_folder_file(player_id)
+ if not os.path.exists(file):
+ return ''
+ with open(file) as fd:
+ return fd.read().strip()
+
+
+def save_trainingpeaks_bridge_folder(player_id, folder):
+ file = trainingpeaks_bridge_folder_file(player_id)
+ with open(file, 'w') as fd:
+ fd.write(folder.strip())
+
+
+def sync_trainingpeaks_workout_for_player(player_id):
+ folder = load_trainingpeaks_bridge_folder(player_id)
+ if not folder:
+ return {
+ 'status': 'missing_folder',
+ 'message': 'Set a TrainingPeaks bridge folder first. Export .zwo workouts there, then sync again.',
+ }
+
+ activate_workout_provider(player_id, 'trainingpeaks')
+ clear_managed_workouts_for_provider(player_id, 'trainingpeaks', clear_metadata=False)
+
+ def store_workout(filename, content, workout):
+ save_player_zfile(player_id, 'customworkouts', filename, content)
+ update_workouts_manifest(player_id, 'customworkouts', filename, content)
+
+ try:
+ result = trainingpeaks_workouts.sync_exported_workouts(folder, store_workout)
+ if result['status'] == 'no_workout':
+ clear_managed_workouts_for_provider(player_id, 'trainingpeaks', clear_metadata=False)
+ return {
+ **result,
+ 'message': 'No .zwo workouts were found in the TrainingPeaks bridge folder.',
+ }
+ return result
+ except Exception as exc:
+ logger.warning('sync_trainingpeaks_workout_for_player: %s' % repr(exc))
+ return {'status': 'error', 'message': 'TrainingPeaks bridge sync failed.'}
+
@app.route("/profile//", methods=["GET", "POST"])
@login_required
def profile(username):
@@ -957,14 +1191,49 @@ def intervals(username):
encrypt_credentials(file, (request.form['athlete_id'], request.form['api_key']))
return redirect(url_for('settings', username=current_user.username))
cred = decrypt_credentials(file)
- return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1])
+ return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1],
+ sync_status=current_workout_sync_status(current_user.player_id, 'intervals-icu'))
+
+
+@app.route("/intervals//sync", methods=["GET"])
+@login_required
+def intervals_sync(username):
+ result = sync_intervals_workout_for_player(current_user.player_id)
+ flash(result['message'])
+ return redirect(url_for('intervals', username=current_user.username))
+
+
+@app.route("/trainingpeaks//", methods=["GET", "POST"])
+@login_required
+def trainingpeaks(username):
+ if request.method == 'POST':
+ save_trainingpeaks_bridge_folder(current_user.player_id, request.form['bridge_folder'])
+ flash('TrainingPeaks bridge folder saved.')
+ return redirect(url_for('trainingpeaks', username=current_user.username))
+ bridge_folder = load_trainingpeaks_bridge_folder(current_user.player_id)
+ return render_template("trainingpeaks.html", username=current_user.username,
+ message=trainingpeaks_workouts.PARTNER_ACCESS_MESSAGE, bridge_folder=bridge_folder)
+
+
+@app.route("/trainingpeaks//sync", methods=["GET"])
+@login_required
+def trainingpeaks_sync(username):
+ result = sync_trainingpeaks_workout_for_player(current_user.player_id)
+ flash(result['message'])
+ return redirect(url_for('trainingpeaks', username=current_user.username))
@app.route("/user//")
@login_required
def user_home(username):
+ provider = resolve_workout_provider_for_player(current_user.player_id)
+ if provider == 'intervals-icu':
+ sync_intervals_workout_for_player(current_user.player_id)
+ elif provider == 'trainingpeaks':
+ sync_trainingpeaks_workout_for_player(current_user.player_id)
return render_template("user_home.html", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), climbs=CLIMBS,
- online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes)
+ online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes,
+ active_workout_provider=provider, workout_sync_status=current_workout_sync_status(current_user.player_id))
def enqueue_player_update(player_id, wa_bytes):
if not player_id in player_update_queue:
@@ -2377,10 +2646,11 @@ def intervals_upload(player_id, activity):
logger.info("intervals_credentials.bin missing, skip Intervals.icu activity update")
return
athlete_id, api_key = decrypt_credentials(intervals_credentials)
+ workout_metadata = load_intervals_workout_metadata(player_id)
try:
- from requests.auth import HTTPBasicAuth
- url = 'https://intervals.icu/api/v1/athlete/%s/activities?name=%s' % (athlete_id, activity.name)
- requests.post(url, files = {"file": BytesIO(activity.fit)}, auth = HTTPBasicAuth('API_KEY', api_key))
+ result = intervals_workouts.upload_activity(athlete_id, api_key, activity, workout_metadata=workout_metadata)
+ if result.get('paired'):
+ clear_intervals_workout_metadata(player_id)
except Exception as exc:
logger.warning("Intervals.icu upload failed. No internet? %s" % repr(exc))
@@ -4345,8 +4615,7 @@ def launch_zwift():
if MULTIPLAYER:
return redirect(url_for('login'))
else:
- return render_template("user_home.html", username=current_user.username, enable_ghosts=os.path.exists(ENABLEGHOSTS_FILE), online=get_online(),
- climbs=CLIMBS, is_admin=False, restarting=restarting, restarting_in_minutes=restarting_in_minutes)
+ return redirect(url_for('user_home', username=current_user.username))
else:
if MULTIPLAYER:
return redirect("http://zwift/?code=zwift_refresh_token%s" % fake_refresh_token_with_session_cookie(request.cookies.get('remember_token')), 302)