Sync your own workout data from your "Strong" app
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

transformer now takes Logs as argument

+252 -164
+100 -41
src/data_transformer.rs
··· 1 - use crate::user_response::CellSetGroup; 1 + use crate::user_response::Log; 2 2 3 + #[allow(dead_code, unused)] 3 4 #[derive(Debug)] 4 5 pub struct Set { 5 6 id: String, 6 - // We assume each exercise set may have a weight, number of reps, and optional RPE. 7 + // Each exercise set may have a weight, number of reps, and optional RPE. 7 8 weight: Option<f32>, 8 9 reps: u32, 9 10 rpe: Option<f32>, 10 11 } 11 12 13 + #[allow(dead_code, unused)] 14 + #[derive(Debug)] 15 + pub struct Exercise { 16 + pub(crate) id: String, 17 + pub(crate) sets: Vec<Set>, 18 + } 19 + 20 + #[allow(dead_code, unused)] 12 21 #[derive(Debug)] 13 22 pub struct Workout { 14 23 id: String, 15 - sets: Vec<Set>, 24 + pub(crate) exercises: Vec<Exercise>, 25 + pub(crate) name: String, 26 + timezone: Option<String>, 27 + start_date: Option<String>, 28 + end_date: Option<String>, 16 29 } 17 30 18 - 31 + /// A trait for transforming raw API log data into domain-specific Workouts. 19 32 pub trait DataTransformer { 20 - fn transform(&self, json_data: &CellSetGroup) -> Result<Workout, serde_json::Error>; 33 + fn transform(&self, logs: &Option<Vec<Log>>) -> Result<Vec<Workout>, serde_json::Error>; 21 34 } 22 35 23 36 pub(crate) struct DataTransformerImpl; 24 37 25 38 impl DataTransformer for DataTransformerImpl { 26 - fn transform(&self, raw_workout: &CellSetGroup) -> Result<Workout, serde_json::Error> { 27 - let mut exercises = Vec::new(); 39 + fn transform(&self, logs_option: &Option<Vec<Log>>) -> Result<Vec<Workout>, serde_json::Error> { 40 + let mut logs = match logs_option { 41 + Some(logs) => logs, 42 + None => return Ok(Vec::new()), 43 + }; 44 + 45 + let mut workouts = Vec::new(); 46 + 47 + // Process every log. 48 + for log in logs { 49 + let workout_id = log.id.clone(); 50 + // Assuming `name` can be converted to a String. 51 + let workout_name = log.name.clone().unwrap_or_default().to_string(); 52 + let timezone = log.timezone_id.clone(); 53 + let start_date = log.start_date.clone(); 54 + let end_date = log.end_date.clone(); 55 + 56 + let mut exercises = Vec::new(); 57 + 58 + // Iterate over each cellSetGroup in the log. 59 + for cell_set_group in &log.embedded.cell_set_group { 60 + let mut sets = Vec::new(); 61 + 62 + // Process each cell set in the group. 63 + for cell_set in &cell_set_group.cell_sets { 64 + // Skip any cell set that represents a rest timer or a note. 65 + if !cell_set 66 + .cells 67 + .iter() 68 + .any(|cell| cell.cell_type == "REST_TIMER" || cell.cell_type == "NOTE") 69 + { 70 + let weight = cell_set 71 + .cells 72 + .iter() 73 + .find(|cell| { 74 + cell.cell_type == "OTHER_WEIGHT" 75 + || cell.cell_type == "DUMBBELL_WEIGHT" 76 + || cell.cell_type == "BARBELL_WEIGHT" 77 + || cell.cell_type == "WEIGHTED_BODYWEIGHT" 78 + }) 79 + .and_then(|cell| cell.value.as_ref()) 80 + .and_then(|s| s.parse::<f32>().ok()); 28 81 29 - // Process each cell set. 30 - for cell_set in &raw_workout.cell_sets { 31 - if !(cell_set.cells.iter().any(|cell| cell.cell_type == "REST_TIMER" || cell.cell_type == "NOTE")) { 32 - let weight = cell_set 33 - .cells 34 - .iter() 35 - .find(|cell| 36 - cell.cell_type == "OTHER_WEIGHT" || 37 - cell.cell_type == "DUMBBELL_WEIGHT" || 38 - cell.cell_type == "BARBELL_WEIGHT" || 39 - cell.cell_type == "WEIGHTED_BODYWEIGHT" 40 - ) 41 - .and_then(|cell| cell.value.as_ref()) 42 - .and_then(|s| s.parse::<f32>().ok()); 82 + let reps = cell_set 83 + .cells 84 + .iter() 85 + .find(|cell| cell.cell_type == "REPS") 86 + .and_then(|cell| cell.value.as_ref()) 87 + .and_then(|s| s.parse::<u32>().ok()) 88 + .unwrap_or(0); 43 89 44 - let reps = cell_set 45 - .cells 46 - .iter() 47 - .find(|cell| cell.cell_type == "REPS") 48 - .and_then(|cell| cell.value.as_ref()) 49 - .and_then(|s| s.parse::<u32>().ok()) 50 - .unwrap_or(0); 90 + let rpe = cell_set 91 + .cells 92 + .iter() 93 + .find(|cell| cell.cell_type == "RPE") 94 + .and_then(|cell| cell.value.as_ref()) 95 + .and_then(|s| s.parse::<f32>().ok()); 51 96 52 - let rpe = cell_set 53 - .cells 54 - .iter() 55 - .find(|cell| cell.cell_type == "RPE") 56 - .and_then(|cell| cell.value.as_ref()) 57 - .and_then(|s| s.parse::<f32>().ok()); 97 + let id = cell_set.id.clone(); 58 98 59 - let id = cell_set.id.clone(); 99 + sets.push(Set { 100 + id, 101 + weight, 102 + reps, 103 + rpe, 104 + }); 105 + } 106 + } 60 107 61 - exercises.push(Set { id, weight, reps, rpe }); 108 + // Create an Exercise only if there is at least one valid set. 109 + if !sets.is_empty() { 110 + exercises.push(Exercise { 111 + id: cell_set_group.id.clone(), 112 + sets, 113 + }); 114 + } 62 115 } 116 + 117 + workouts.push(Workout { 118 + id: workout_id, 119 + exercises, 120 + name: workout_name, 121 + timezone, 122 + start_date, 123 + end_date, 124 + }); 63 125 } 64 126 65 - Ok(Workout { 66 - id: raw_workout.clone().id, 67 - sets: exercises, 68 - }) 127 + Ok(workouts) 69 128 } 70 - } 129 + }
+23 -50
src/main.rs
··· 1 + use crate::data_transformer::{DataTransformer, DataTransformerImpl}; 2 + use crate::strong_api::{Includes, StrongApi}; 3 + use dotenv::dotenv; 1 4 use reqwest::Url; 2 5 use std::env; 3 - use dotenv::dotenv; 4 - use serde::Deserialize; 5 - use serde_json::Value::String; 6 - use crate::strong_api::{Includes, StrongApi}; 7 - use crate::data_transformer::{DataTransformer, DataTransformerImpl}; 8 6 7 + mod data_transformer; 9 8 mod strong_api; 10 9 mod user_response; 11 - mod data_transformer; 12 10 13 11 #[tokio::main] 14 12 async fn main() -> Result<(), Box<dyn std::error::Error>> { ··· 17 15 let password = env::var("STRONG_PASS").expect("STRONG_PASS must be set"); 18 16 let strong_backend = env::var("STRONG_BACKEND").expect("STRONG_BACKEND must be set"); 19 17 20 - let url = Url::parse(&*strong_backend).ok().unwrap(); 18 + let url = Url::parse(&strong_backend).ok().expect("STRONG_BACKEND is not a valid URL"); 21 19 22 20 let mut strong_api = StrongApi::new(url); 23 21 24 - strong_api.login(username.as_str(), password.as_str()).await?; 25 - let user = strong_api.get_user( 26 - "", 27 - 500, 28 - vec![ 29 - Includes::Log, 30 - Includes::Folder, 31 - Includes::Measurement, 32 - Includes::MeasuredValue, 33 - Includes::Tag, 34 - Includes::Template, 35 - Includes::Widget 36 - ] 37 - ).await?; 22 + strong_api 23 + .login(username.as_str(), password.as_str()) 24 + .await?; 25 + let user = strong_api.get_user("", 500, vec![Includes::Log]).await?; 38 26 27 + let workouts = DataTransformerImpl 28 + .transform(&user.embedded.log) 29 + .expect("Couldn't read workouts"); 39 30 31 + println!("Workout count: {}", workouts.len()); 40 32 41 - for log in &user.embedded.log { 42 - if let Some(start_date) = &log.start_date { 43 - print!("{}: ", start_date); 44 - } 45 - 46 - let display_name = log.name.as_ref() 47 - .and_then(|name| name.custom.as_deref().or(name.en.as_deref())) 48 - .unwrap_or("Unknown"); 49 - 50 - println!("{}", display_name); 51 - for cell_set_group in &log.embedded.cell_set_group { 52 - /*for cell_set in &cell_set_group.cell_sets { 53 - for cell in &cell_set.cells { 54 - print!("{} - {:?} | ", cell.cell_type, cell.value); 55 - } 56 - }*/ 57 - println!(); 58 - dbg!(DataTransformerImpl.transform(cell_set_group).expect("TODO: panic message")); 59 - } 60 - println!(); 61 - } 62 - 63 - 64 - /*strong_api.get_logs().await?; 65 - 66 - dbg!(strong_api.access_token); 67 - dbg!(strong_api.refresh_token); 68 - dbg!(strong_api.user_id);*/ 33 + workouts.iter().for_each(|workout| { 34 + println!("Workout: {}", workout.name); 35 + workout.exercises.iter().for_each(|exercise| { 36 + println!("Exercise: {}", exercise.id); 37 + exercise.sets.iter().for_each(|set| { 38 + println!("Set: {:?}", set); 39 + }); 40 + }); 41 + }); 69 42 70 43 Ok(()) 71 - } 44 + }
+106 -63
src/strong_api.rs
··· 1 - use std::fmt; 2 - use reqwest::{Client, header::{HeaderMap, HeaderName, HeaderValue}, Url}; 1 + use crate::user_response::UserResponse; 2 + use reqwest::{ 3 + Client, Url, 4 + header::{HeaderMap, HeaderName, HeaderValue}, 5 + }; 3 6 use serde::Deserialize; 4 7 use serde_json::json; 5 - use crate::user_response::UserResponse; 8 + use std::fmt; 9 + 10 + #[derive(Debug, Deserialize)] 11 + struct ApiErrorResponse { 12 + code: String, 13 + description: String, 14 + } 15 + 16 + impl fmt::Display for ApiErrorResponse { 17 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 18 + write!(f, "{}: {}", self.code, self.description) 19 + } 20 + } 21 + 22 + impl std::error::Error for ApiErrorResponse {} 6 23 7 24 #[derive(Debug)] 8 25 pub struct StrongApi { ··· 24 41 user_id: Option<String>, 25 42 } 26 43 44 + #[allow(dead_code)] 45 + #[derive(Debug)] 27 46 pub enum Includes { 28 47 Log, 29 48 Measurement, ··· 31 50 Widget, 32 51 Template, 33 52 Folder, 34 - MeasuredValue 53 + MeasuredValue, 35 54 } 36 55 37 56 impl fmt::Display for Includes { ··· 50 69 } 51 70 52 71 impl StrongApi { 53 - 54 72 /// Creates a new StrongApi instance with the provided backend URL. 55 73 pub fn new(url: Url) -> Self { 56 74 let mut headers = HeaderMap::new(); 57 - headers.insert(HeaderName::from_static("user-agent"), HeaderValue::from_static("Strong Android")); 58 - headers.insert(HeaderName::from_static("content-type"), HeaderValue::from_static("application/json")); 59 - headers.insert(HeaderName::from_static("accept"), HeaderValue::from_static("application/json")); 60 - headers.insert(HeaderName::from_static("x-client-build"), HeaderValue::from_static("600013")); 61 - headers.insert(HeaderName::from_static("x-client-platform"), HeaderValue::from_static("android")); 75 + headers.insert( 76 + HeaderName::from_static("user-agent"), 77 + HeaderValue::from_static("Strong Android"), 78 + ); 79 + headers.insert( 80 + HeaderName::from_static("content-type"), 81 + HeaderValue::from_static("application/json"), 82 + ); 83 + headers.insert( 84 + HeaderName::from_static("accept"), 85 + HeaderValue::from_static("application/json"), 86 + ); 87 + headers.insert( 88 + HeaderName::from_static("x-client-build"), 89 + HeaderValue::from_static("600013"), 90 + ); 91 + headers.insert( 92 + HeaderName::from_static("x-client-platform"), 93 + HeaderValue::from_static("android"), 94 + ); 62 95 63 96 Self { 64 97 url, ··· 71 104 } 72 105 73 106 /// Logs in to the Strong backend using the provided username and password. 74 - pub async fn login(&mut self, username: &str, password: &str) -> Result<(), Box<dyn std::error::Error>> { 75 - let url = self.url.join("auth/login").unwrap(); 76 - 107 + pub async fn login( 108 + &mut self, 109 + username: &str, 110 + password: &str, 111 + ) -> Result<(), Box<dyn std::error::Error>> { 112 + let url = self.url.join("auth/login")?; 77 113 let body = json!({ 78 114 "usernameOrEmail": username, 79 115 "password": password 80 116 }); 81 117 82 - let response = self.client 118 + let response = self 119 + .client 83 120 .post(url) 84 121 .headers(self.headers.clone()) 85 122 .json(&body) 86 123 .send() 87 124 .await?; 88 - 89 125 let response_text = response.text().await?; 90 126 91 127 let parsed: LoginResponse = serde_json::from_str(&response_text)?; ··· 97 133 Ok(()) 98 134 } 99 135 100 - /// Refreshes the access token using the access and refresh token which were obtained during login. 101 - /// Should be called when you receive a 401 Unauthorized response from the Strong backend. 136 + /// Refreshes the access token using tokens obtained during login. 102 137 pub async fn refresh(&mut self) -> Result<(), Box<dyn std::error::Error>> { 103 - let url = self.url.join("auth/login/refresh").unwrap(); 104 - 138 + let url = self.url.join("auth/login/refresh")?; 105 139 let body = json!({ 106 140 "accessToken": self.access_token, 107 141 "refreshToken": self.refresh_token 108 142 }); 109 143 110 - let response = self.client 144 + // Ensure the access token exists 145 + let access_token = self.access_token.clone().ok_or("Missing access token")?; 146 + let response = self 147 + .client 111 148 .post(url) 112 - .bearer_auth(self.access_token.clone().unwrap()) 149 + .bearer_auth(&access_token) 113 150 .headers(self.headers.clone()) 114 151 .json(&body) 115 152 .send() 116 153 .await?; 117 154 118 - dbg!(response.status()); 119 - 155 + // Log the status (consider replacing with proper logging) 156 + eprintln!("Refresh status: {}", response.status()); 120 157 let response_text = response.text().await?; 121 - 122 158 let parsed: LoginResponse = serde_json::from_str(&response_text)?; 123 159 124 160 self.access_token = parsed.access_token; ··· 129 165 130 166 #[cfg(feature = "full")] 131 167 pub async fn refresh_by_tokens( 132 - &mut self, access_token: String, 133 - refresh_token: String 168 + &mut self, 169 + access_token: String, 170 + refresh_token: String, 134 171 ) -> Result<(), Box<dyn std::error::Error>> { 135 - let url = self.url.join("auth/login/refresh").unwrap(); 136 - 172 + let url = self.url.join("auth/login/refresh")?; 137 173 let body = json!({ 138 174 "accessToken": access_token.clone(), 139 175 "refreshToken": refresh_token, 140 176 }); 141 177 142 - let response = self.client 178 + let response = self 179 + .client 143 180 .post(url) 144 - .bearer_auth(access_token) 181 + .bearer_auth(&access_token) 145 182 .headers(self.headers.clone()) 146 183 .json(&body) 147 184 .send() 148 185 .await?; 149 - 150 - 151 186 let response_text = response.text().await?; 152 - 153 187 let parsed: LoginResponse = serde_json::from_str(&response_text)?; 154 188 155 189 self.access_token = parsed.access_token; ··· 158 192 Ok(()) 159 193 } 160 194 161 - pub async fn get_user(&self, continuation: &str, limit: i16, includes: Vec<Includes>) -> Result<UserResponse, Box<dyn std::error::Error>> { 162 - let user_id = &*self.user_id.clone().unwrap(); 163 - let mut url = self.url.join(format!("api/users/{user_id}").as_str()).unwrap(); 164 - 165 - url.set_query(Some(&format!("limit={}&continuation={}", limit, continuation))); 195 + pub async fn get_user( 196 + &self, 197 + continuation: &str, 198 + limit: i16, 199 + includes: Vec<Includes>, 200 + ) -> Result<UserResponse, Box<dyn std::error::Error>> { 201 + let user_id = self.user_id.as_ref().ok_or("Missing user id")?; 202 + let mut url = self.url.join(&format!("api/users/{user_id}"))?; 166 203 167 - for include in includes { 168 - url.set_query(Some(&format!("{}&include={}", url.query().unwrap(), include))); 204 + { 205 + // Use query_pairs_mut to build the query string. 206 + let mut query_pairs = url.query_pairs_mut(); 207 + query_pairs.append_pair("limit", &limit.to_string()); 208 + query_pairs.append_pair("continuation", continuation); 209 + for include in includes { 210 + query_pairs.append_pair("include", &include.to_string()); 211 + } 169 212 } 170 - 171 - dbg!(&url.to_string()); 213 + // Drop the mutable borrow here. 214 + eprintln!("Request URL: {}", url); 172 215 173 - let response = self.client 216 + let response = self 217 + .client 174 218 .get(url) 175 - .bearer_auth(self.access_token.clone().unwrap()) 219 + .bearer_auth(self.access_token.as_ref().ok_or("Missing access token")?) 176 220 .headers(self.headers.clone()) 177 221 .send() 178 222 .await?; 179 223 224 + // Capture the status before consuming the response. 225 + let status = response.status(); 180 226 let response_text = response.text().await?; 181 227 228 + if !status.is_success() { 229 + let api_error: ApiErrorResponse = serde_json::from_str(&response_text)?; 230 + return Err(Box::new(api_error)); 231 + } 182 232 183 233 let parsed: UserResponse = serde_json::from_str(&response_text)?; 184 - 185 234 Ok(parsed) 186 235 } 187 236 188 237 pub async fn get_measurements(&self) -> Result<(), Box<dyn std::error::Error>> { 189 - let user_id = &*self.user_id.clone().unwrap(); 190 - let url = self.url.join(format!("api/measurements/{user_id}").as_str()).unwrap(); 191 - 192 - let response = self.client 238 + let user_id = self.user_id.as_ref().ok_or("Missing user id")?; 239 + let url = self.url.join(&format!("api/measurements/{user_id}"))?; 240 + let response = self 241 + .client 193 242 .get(url) 194 - .bearer_auth(self.access_token.clone().unwrap()) 243 + .bearer_auth(self.access_token.as_ref().ok_or("Missing access token")?) 195 244 .headers(self.headers.clone()) 196 245 .send() 197 246 .await?; 198 - 199 247 let response_text = response.text().await?; 200 - 201 - dbg!(response_text); 202 - 248 + eprintln!("Measurements response: {}", response_text); 203 249 Ok(()) 204 250 } 205 251 206 252 pub async fn get_logs(&self) -> Result<(), Box<dyn std::error::Error>> { 207 - let user_id = &*self.user_id.clone().unwrap(); 208 - let url = self.url.join(format!("api/logs/{user_id}").as_str()).unwrap(); 209 - 210 - let response = self.client 253 + let user_id = self.user_id.as_ref().ok_or("Missing user id")?; 254 + let url = self.url.join(&format!("api/logs/{user_id}"))?; 255 + let response = self 256 + .client 211 257 .get(url) 212 - .bearer_auth(self.access_token.clone().unwrap()) 258 + .bearer_auth(self.access_token.as_ref().ok_or("Missing access token")?) 213 259 .headers(self.headers.clone()) 214 260 .send() 215 261 .await?; 216 - 217 262 let response_text = response.text().await?; 218 - 219 - dbg!(response_text); 220 - 263 + eprintln!("Logs response: {}", response_text); 221 264 Ok(()) 222 265 } 223 266 }
+23 -10
src/user_response.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 use serde_json::Value; 3 3 4 + #[allow(dead_code, unused)] 4 5 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 5 6 pub struct UserResponse { 6 7 #[serde(rename = "_links")] ··· 33 34 34 35 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 35 36 pub struct Embedded { 36 - pub measurement: Vec<Value>, 37 + pub measurement: Option<Vec<Value>>, 37 38 #[serde(rename = "measuredValue")] 38 - pub measured_value: Vec<Value>, 39 - pub template: Vec<Value>, 40 - pub log: Vec<Log>, 41 - pub tag: Vec<Value>, 42 - pub folder: Vec<Value>, 43 - pub widget: Vec<Value>, 39 + pub measured_value: Option<Vec<Value>>, 40 + pub template: Option<Vec<Value>>, 41 + pub log: Option<Vec<Log>>, 42 + pub tag: Option<Vec<Value>>, 43 + pub folder: Option<Vec<Value>>, 44 + pub widget: Option<Vec<Value>>, 44 45 } 45 46 46 47 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] ··· 71 72 pub custom: Option<String>, 72 73 } 73 74 75 + // implement Display for Name 76 + impl std::fmt::Display for Name { 77 + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 78 + match &self.en { 79 + Some(en) => write!(f, "{}", en), 80 + None => match &self.custom { 81 + Some(custom) => write!(f, "{}", custom), 82 + None => write!(f, "Unknown"), 83 + }, 84 + } 85 + } 86 + } 87 + 74 88 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 75 89 pub struct LogEmbedded { 76 90 #[serde(rename = "cellSetGroup")] ··· 89 103 } 90 104 91 105 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 92 - pub struct CellSetGroupEmbedded { 93 - } 106 + pub struct CellSetGroupEmbedded {} 94 107 95 108 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 96 109 pub struct CallSet { ··· 106 119 #[serde(rename = "cellType")] 107 120 pub cell_type: String, 108 121 pub value: Option<String>, 109 - } 122 + }