mirror of
https://github.com/zoffline/zwift-offline.git
synced 2026-06-12 11:01:32 -07:00
Merge pull request #521 from fatsbrown/workout_sync
Add workout provider sync integrations
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<a href="{{ url_for('settings', username=username) }}" class="btn btn-sm btn-secondary">Back</a>
|
<a href="{{ url_for('settings', username=username) }}" class="btn btn-sm btn-secondary">Back</a>
|
||||||
{% if aid or akey %}
|
{% if aid or akey %}
|
||||||
|
<a href="{{ url_for('intervals_sync', username=username) }}" class="btn btn-sm btn-secondary">Sync today's workout</a>
|
||||||
<a href="/delete/intervals_credentials.bin" class="btn btn-sm btn-danger">Remove credentials</a>
|
<a href="/delete/intervals_credentials.bin" class="btn btn-sm btn-danger">Remove credentials</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -43,5 +44,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if sync_status and aid and akey %}
|
||||||
|
<div class="col-sm-8 col-md-6">
|
||||||
|
<ul class="list-group top-buffer">
|
||||||
|
<li class="list-group-item py-2">
|
||||||
|
{% if sync_status.metadata %}
|
||||||
|
<div class="text-shadow">Synced workout: {{ sync_status.metadata.name or sync_status.metadata.filename }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-shadow">No synced Intervals workout.</div>
|
||||||
|
<div class="text-shadow"><small>Use "Sync today's workout" before launching Zwift.</small></div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<a href="{{ url_for('profile', username=username) }}" class="btn btn-sm btn-secondary">Zwift</a>
|
<a href="{{ url_for('profile', username=username) }}" class="btn btn-sm btn-secondary">Zwift</a>
|
||||||
<a href="{{ url_for('strava', username=username) }}" class="btn btn-sm btn-secondary">Strava</a>
|
<a href="{{ url_for('strava', username=username) }}" class="btn btn-sm btn-secondary">Strava</a>
|
||||||
<a href="{{ url_for('intervals', username=username) }}" class="btn btn-sm btn-secondary">Intervals</a>
|
<a href="{{ url_for('intervals', username=username) }}" class="btn btn-sm btn-secondary">Intervals</a>
|
||||||
|
<a href="{{ url_for('trainingpeaks', username=username) }}" class="btn btn-sm btn-secondary">TrainingPeaks</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if files %}
|
{% if files %}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "./layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1><div class="text-shadow">TrainingPeaks</div></h1>
|
||||||
|
{% if username != "zoffline" %}
|
||||||
|
<h4 class="text-shadow">Logged in as {{ username }}</h4>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<a href="{{ url_for('settings', username=username) }}" class="btn btn-sm btn-secondary">Back</a>
|
||||||
|
<a href="{{ url_for('trainingpeaks_sync', username=username) }}" class="btn btn-sm btn-secondary">Import workouts</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-8 col-md-6">
|
||||||
|
<form id="trainingpeaks" action="{{ url_for('trainingpeaks', username=username) }}" method="post" class="top-buffer">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="col-form-label col-form-label-sm text-shadow">Bridge folder</label>
|
||||||
|
<input type="text" id="bridge_folder" name="bridge_folder" value="{{ bridge_folder }}" class="form-control form-control-sm" placeholder="/path/to/TrainingPeaks/exports">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 top-buffer">
|
||||||
|
<input type="submit" value="Save bridge folder" class="btn btn-sm btn-light">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="list-group top-buffer">
|
||||||
|
{% for message in messages %}
|
||||||
|
<li class="list-group-item py-2">
|
||||||
|
<div class="text-shadow">{{ message }}</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8 col-md-6">
|
||||||
|
<div class="list-group top-buffer">
|
||||||
|
<div class="list-group-item py-2">
|
||||||
|
<div class="text-shadow">{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item py-2">
|
||||||
|
<div class="text-shadow">Manual bridge mode: export TrainingPeaks workouts as .zwo files into a folder, save that folder path here, then click "Import workouts".</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -83,5 +83,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if workout_sync_status %}
|
||||||
|
<div class="col-sm-8 col-md-6">
|
||||||
|
<ul class="list-group top-buffer">
|
||||||
|
<li class="list-group-item py-2">
|
||||||
|
<div class="text-shadow">Workout provider: {{ active_workout_provider or 'not selected' }}</div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item py-2">
|
||||||
|
{% if workout_sync_status.metadata %}
|
||||||
|
<div class="text-shadow"><small>{{ workout_sync_status.metadata.name or workout_sync_status.metadata.filename }}</small></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-shadow"><small>No synced workout.</small></div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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)}",
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+276
-7
@@ -62,6 +62,10 @@ import fitness_pb2
|
|||||||
import structured_events_pb2
|
import structured_events_pb2
|
||||||
|
|
||||||
import online_sync
|
import online_sync
|
||||||
|
import intervals_workouts
|
||||||
|
import trainingpeaks_workouts
|
||||||
|
import workout_state
|
||||||
|
import workouts_manifest
|
||||||
|
|
||||||
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
||||||
logger = logging.getLogger('zoffline')
|
logger = logging.getLogger('zoffline')
|
||||||
@@ -209,6 +213,7 @@ class AnonUser(User, AnonymousUserMixin, db.Model):
|
|||||||
first_name = "z"
|
first_name = "z"
|
||||||
last_name = "offline"
|
last_name = "offline"
|
||||||
enable_ghosts = os.path.isfile(ENABLEGHOSTS_FILE)
|
enable_ghosts = os.path.isfile(ENABLEGHOSTS_FILE)
|
||||||
|
is_admin = False
|
||||||
|
|
||||||
def is_authenticated(self):
|
def is_authenticated(self):
|
||||||
return True
|
return True
|
||||||
@@ -860,6 +865,235 @@ def backup_file(file):
|
|||||||
if os.path.isfile(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")))
|
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/<username>/", methods=["GET", "POST"])
|
@app.route("/profile/<username>/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def profile(username):
|
def profile(username):
|
||||||
@@ -957,14 +1191,49 @@ def intervals(username):
|
|||||||
encrypt_credentials(file, (request.form['athlete_id'], request.form['api_key']))
|
encrypt_credentials(file, (request.form['athlete_id'], request.form['api_key']))
|
||||||
return redirect(url_for('settings', username=current_user.username))
|
return redirect(url_for('settings', username=current_user.username))
|
||||||
cred = decrypt_credentials(file)
|
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/<username>/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/<username>/", 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/<username>/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/<username>/")
|
@app.route("/user/<username>/")
|
||||||
@login_required
|
@login_required
|
||||||
def user_home(username):
|
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,
|
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):
|
def enqueue_player_update(player_id, wa_bytes):
|
||||||
if not player_id in player_update_queue:
|
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")
|
logger.info("intervals_credentials.bin missing, skip Intervals.icu activity update")
|
||||||
return
|
return
|
||||||
athlete_id, api_key = decrypt_credentials(intervals_credentials)
|
athlete_id, api_key = decrypt_credentials(intervals_credentials)
|
||||||
|
workout_metadata = load_intervals_workout_metadata(player_id)
|
||||||
try:
|
try:
|
||||||
from requests.auth import HTTPBasicAuth
|
result = intervals_workouts.upload_activity(athlete_id, api_key, activity, workout_metadata=workout_metadata)
|
||||||
url = 'https://intervals.icu/api/v1/athlete/%s/activities?name=%s' % (athlete_id, activity.name)
|
if result.get('paired'):
|
||||||
requests.post(url, files = {"file": BytesIO(activity.fit)}, auth = HTTPBasicAuth('API_KEY', api_key))
|
clear_intervals_workout_metadata(player_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Intervals.icu upload failed. No internet? %s" % repr(exc))
|
logger.warning("Intervals.icu upload failed. No internet? %s" % repr(exc))
|
||||||
|
|
||||||
@@ -4345,8 +4615,7 @@ def launch_zwift():
|
|||||||
if MULTIPLAYER:
|
if MULTIPLAYER:
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
else:
|
else:
|
||||||
return render_template("user_home.html", username=current_user.username, enable_ghosts=os.path.exists(ENABLEGHOSTS_FILE), online=get_online(),
|
return redirect(url_for('user_home', username=current_user.username))
|
||||||
climbs=CLIMBS, is_admin=False, restarting=restarting, restarting_in_minutes=restarting_in_minutes)
|
|
||||||
else:
|
else:
|
||||||
if MULTIPLAYER:
|
if MULTIPLAYER:
|
||||||
return redirect("http://zwift/?code=zwift_refresh_token%s" % fake_refresh_token_with_session_cookie(request.cookies.get('remember_token')), 302)
|
return redirect("http://zwift/?code=zwift_refresh_token%s" % fake_refresh_token_with_session_cookie(request.cookies.get('remember_token')), 302)
|
||||||
|
|||||||
Reference in New Issue
Block a user