diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9c27b2..8b5b47c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -217,6 +217,18 @@ fn trainer_device(state: &State) -> Result { .ok_or_else(|| "станок не найден".to_string()) } +/// Список доступных Ride-воркаутов (сводки). +#[tauri::command] +fn list_workouts() -> Result, String> { + workout::list_workouts(&workout::workouts_dir()).map_err(|e| e.to_string()) +} + +/// Полный воркаут по имени файла (из каталога воркаутов). +#[tauri::command] +fn get_workout(path: String) -> Result { + workout::read_workout(&path).map_err(|e| e.to_string()) +} + /// Why the watchdog loop stopped consuming a stream. enum StreamOutcome { /// The stream ended (peripheral disconnected / notifications closed). @@ -384,7 +396,9 @@ pub fn run() { set_target_power, set_target_cadence, trainer_start, - trainer_stop + trainer_stop, + list_workouts, + get_workout ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/workout.rs b/src-tauri/src/workout.rs index 22085ae..2055f62 100644 --- a/src-tauri/src/workout.rs +++ b/src-tauri/src/workout.rs @@ -1,8 +1,7 @@ //! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание //! вложенных повторов в линейный список сегментов для исполнения на станке. -// TODO(phase 2): убрать, когда модуль будет подключён к tauri-командам. -#![allow(dead_code)] +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -52,6 +51,16 @@ pub struct Workout { pub steps: Vec, } +/// Сводка воркаута для списка. +#[derive(Debug, Clone, Serialize)] +pub struct WorkoutSummary { + pub name: String, + pub date: String, + pub duration_min: u64, + pub load: Option, + pub path: String, +} + // ===== Сырые serde-структуры (повторяют формат /events intervals.icu) ===== #[derive(Deserialize)] @@ -110,6 +119,10 @@ struct RawCadence { pub enum WorkoutError { #[error("invalid workout JSON: {0}")] Json(#[from] serde_json::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("unsafe workout path: {0}")] + InvalidPath(String), } // ===== Парсинг ===== @@ -185,6 +198,64 @@ pub fn total_duration_secs(segments: &[Segment]) -> u64 { segments.iter().map(|s| s.duration_secs as u64).sum() } +/// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd. +pub fn workouts_dir() -> PathBuf { + std::env::var("RIDEIN_WORKOUTS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("workouts")) +} + +/// Сканирует каталог и возвращает сводки Ride-воркаутов (с непустыми шагами), +/// отсортированные по дате. `events.json` и неразбираемые файлы пропускаются. +pub fn list_workouts(dir: &Path) -> Result, WorkoutError> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if path.file_name().and_then(|f| f.to_str()) == Some("events.json") { + continue; + } + let workout = match read_workout_file(&path) { + Ok(w) => w, + Err(e) => { + eprintln!("skip unparseable workout {}: {e}", path.display()); + continue; + } + }; + if workout.steps.is_empty() { + continue; // не Ride (нет workout_doc.steps) + } + let duration_min = total_duration_secs(&flatten(&workout.steps)) / 60; + out.push(WorkoutSummary { + name: workout.name.clone(), + date: workout.start_date_local.clone().unwrap_or_default(), + duration_min, + load: workout.icu_training_load, + path: path.file_name().unwrap().to_string_lossy().into_owned(), + }); + } + out.sort_by(|a, b| a.date.cmp(&b.date)); + Ok(out) +} + +/// Читает воркаут по имени файла (без пути) из каталога воркаутов. +pub fn read_workout(filename: &str) -> Result { + let fname = Path::new(filename); + if filename.contains("..") || fname.components().count() != 1 { + return Err(WorkoutError::InvalidPath(filename.to_string())); + } + let path = workouts_dir().join(fname); + read_workout_file(&path) +} + +fn read_workout_file(path: &Path) -> Result { + let json = std::fs::read_to_string(path)?; + parse_workout(&json) +} + #[cfg(test)] mod tests { use super::*; @@ -279,5 +350,45 @@ mod tests { "workout {file}: flat total mismatch" ); } + + // list_workouts на реальном каталоге: 3 Ride-воркаута, по дате. + let dir = std::path::Path::new("/home/dude/code/ridein/workouts"); + let list = list_workouts(dir).unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0].name, "Threshold Over-Under 2x20"); + assert_eq!(list[1].name, "VO2max 5x4min"); + assert_eq!(list[2].name, "Long Z2 (Endurance)"); + } + + #[test] + fn lists_ride_workouts_skipping_non_ride() { + let dir = std::env::temp_dir().join("ridein-workout-list-test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Ride-воркаут (с шагами) + std::fs::write(dir.join("2026-10-28_test-a.json"), EVENT).unwrap(); + // events.json — пропускается по имени + std::fs::write(dir.join("events.json"), "[{}]").unwrap(); + // не-Ride (пустой workout_doc) — пропускается + std::fs::write( + dir.join("2026-10-30_yoga.json"), + r#"{"name":"Yoga","workout_doc":{"steps":[]}}"#, + ) + .unwrap(); + + let list = list_workouts(&dir).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Test"); + assert_eq!(list[0].duration_min, 36); // 2160s / 60 + assert_eq!(list[0].load, Some(91.0)); + assert_eq!(list[0].path, "2026-10-28_test-a.json"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn read_workout_rejects_traversal() { + assert!(read_workout("../etc/passwd").is_err()); + assert!(read_workout("a/b.json").is_err()); } }