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.

refactor: rename strong-api-dump to strong-api-fetch and update related configurations

+184 -132
+1 -1
.gitignore
··· 2 2 .env 3 3 .idea 4 4 response.json 5 - /docker/clickhouse-data 5 + /docker-clickhouse/clickhouse-data 6 6 /response_*.json
+1 -1
Cargo.lock
··· 1229 1229 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1230 1230 1231 1231 [[package]] 1232 - name = "strong-api-dump" 1232 + name = "strong-api-fetch" 1233 1233 version = "0.1.0" 1234 1234 dependencies = [ 1235 1235 "clickhouse",
+1 -1
Cargo.toml
··· 2 2 resolver = "3" 3 3 members = [ 4 4 "strong-api-lib", 5 - "strong-api-dump" 5 + "strong-api-fetch" 6 6 ]
+29
Dockerfile
··· 1 + FROM rust:bookworm AS builder 2 + WORKDIR /usr/src/strong-api-fetch 3 + COPY . . 4 + RUN cd strong-api-fetch && cargo install --path . 5 + 6 + FROM debian:bookworm-slim 7 + RUN apt-get update && apt-get install -y \ 8 + openssl \ 9 + ca-certificates \ 10 + curl \ 11 + cron 12 + 13 + WORKDIR /usr/strong-api-fetch 14 + COPY --from=builder /usr/local/cargo/bin/strong-api-fetch /usr/bin/strong-api-fetch 15 + 16 + # Add the cron job: run every 12 hours and log output 17 + RUN echo "*/1 * * * * /usr/bin/strong-api-fetch >> /var/log/cron.log 2>&1" > /etc/cron.d/strong-api-fetch 18 + 19 + # Ensure the cron job file has proper permissions 20 + RUN chmod 0644 /etc/cron.d/strong-api-fetch 21 + 22 + # Install the new cron job 23 + RUN crontab /etc/cron.d/strong-api-fetch 24 + 25 + # Create the log file so that it exists when cron writes to it 26 + RUN touch /var/log/cron.log 27 + 28 + # Run cron in the foreground 29 + CMD ["cron", "-f"]
+1 -1
README.MD
··· 1 - # Strong API data dump 1 + # Strong API data fetch 2 2 3 3 This repo provides access to the Strong API to save workouts to your own database. 4 4 The URL to the API backend is not provided here, but you can find it through the Strong app.
+15
docker-compose.yml
··· 1 + services: 2 + strong-api-fetch: 3 + image: strong-api-fetch:latest 4 + build: 5 + context: . 6 + container_name: strong-api-fetch 7 + volumes: 8 + - ./:/usr/strong-api-fetch 9 + - ./log.log:/etc/cron.d/strong-api-fetch 10 + networks: 11 + - strong-api-network 12 + 13 + networks: 14 + strong-api-network: 15 + driver: bridge
docker/docker-compose.yml docker-clickhouse/docker-compose.yml
+3 -1
docker/init.sql docker-clickhouse/init.sql
··· 9 9 start_date DateTime64(3) DEFAULT now(), 10 10 end_date DateTime64(3) DEFAULT now(), 11 11 exercise_id UUID, 12 + exercise_nr UInt32, 12 13 exercise_name String, 13 14 set_id UUID, 15 + set_nr UInt32, 14 16 weight Float32 DEFAULT 0.0, 15 17 reps UInt32, 16 18 rpe Float32 DEFAULT 0.0 17 19 ) 18 20 ENGINE = ReplacingMergeTree() 19 - ORDER BY (start_date, workout_id, exercise_id, set_id); 21 + ORDER BY (start_date, workout_id, exercise_nr, exercise_id, set_nr);
+1 -1
strong-api-dump/Cargo.toml strong-api-fetch/Cargo.toml
··· 1 1 [package] 2 - name = "strong-api-dump" 2 + name = "strong-api-fetch" 3 3 version = "0.1.0" 4 4 edition = "2024" 5 5
+8 -2
strong-api-dump/src/clickhouse_saver.rs strong-api-fetch/src/clickhouse_saver.rs
··· 1 1 use clickhouse::Row; 2 2 use serde::{Deserialize, Serialize}; 3 3 use std::error::Error; 4 - use strong_api_lib::data_transformer::Workout; 4 + use strong_api_lib::data_transformer::{Exercise, Workout}; 5 5 use time::OffsetDateTime; 6 6 use time::format_description::well_known::Rfc3339; 7 7 use uuid::Uuid; ··· 19 19 pub end_date: OffsetDateTime, 20 20 #[serde(with = "clickhouse::serde::uuid")] 21 21 pub exercise_id: Uuid, 22 + pub exercise_nr: u32, 22 23 pub exercise_name: String, 23 24 #[serde(with = "clickhouse::serde::uuid")] 24 25 pub set_id: Uuid, 26 + pub set_nr: u32, 25 27 pub weight: f32, 26 28 pub reps: u32, 27 29 pub rpe: f32, ··· 63 65 let mut insert = self.client.insert(&self.table_name)?; 64 66 65 67 for exercise in &workout.exercises { 68 + let exercise_nr = workout.exercises.iter().position(|x| x.id == exercise.id).unwrap() as u32; 66 69 for set in &exercise.sets { 67 70 let start_dt = OffsetDateTime::parse( 68 71 &workout.start_date.clone().unwrap_or_default(), 69 72 &Rfc3339, 70 73 )?; 74 + 71 75 let end_dt = OffsetDateTime::parse( 72 76 &workout.start_date.clone().unwrap_or_default(), 73 77 &Rfc3339, 74 78 )?; 75 79 76 - dbg!(&start_dt); 80 + let set_nr = exercise.sets.iter().position(|x| x.id == set.id).unwrap() as u32; 77 81 78 82 let row = WorkoutSet { 79 83 workout_id: Uuid::parse_str(&workout.id).expect("workout_id UUID parse failed"), ··· 85 89 start_date: start_dt, 86 90 end_date: end_dt, 87 91 exercise_id: Uuid::parse_str(&exercise.id).expect("exercise UUID parse failed"), 92 + exercise_nr, 88 93 exercise_name: exercise.name.clone(), 89 94 set_id: Uuid::parse_str(&set.id).expect("set UUID parse failed"), 95 + set_nr, 90 96 weight: set.weight.unwrap_or(0.0), 91 97 reps: set.reps, 92 98 rpe: set.rpe.unwrap_or(0.0),
-92
strong-api-dump/src/main.rs
··· 1 - mod clickhouse_saver; 2 - 3 - use dotenv::dotenv; 4 - use reqwest::Url; 5 - use std::env; 6 - use strong_api_lib::data_transformer::DataTransformer; 7 - use strong_api_lib::json_response::UserResponse; 8 - use strong_api_lib::strong_api::{Includes, StrongApi}; 9 - 10 - #[tokio::main] 11 - async fn main() -> Result<(), Box<dyn std::error::Error>> { 12 - dotenv().ok(); 13 - 14 - #[allow(unused_variables)] 15 - let username = env::var("STRONG_USER").expect("STRONG_USER must be set"); 16 - #[allow(unused_variables)] 17 - let password = env::var("STRONG_PASS").expect("STRONG_PASS must be set"); 18 - let strong_backend = env::var("STRONG_BACKEND").expect("STRONG_BACKEND must be set"); 19 - let url = Url::parse(&strong_backend).expect("STRONG_BACKEND is not a valid URL"); 20 - 21 - let start = std::time::Instant::now(); 22 - 23 - let mut strong_api = StrongApi::new(url); 24 - let clickhouse_saver = clickhouse_saver::ClickHouseSaver::new( 25 - env::var("CLICKHOUSE_URL") 26 - .expect("CLICKHOUSE_URL must be set") 27 - .as_str(), 28 - env::var("CLICKHOUSE_USER") 29 - .expect("CLICKHOUSE_USER must be set") 30 - .as_str(), 31 - env::var("CLICKHOUSE_PASS") 32 - .expect("CLICKHOUSE_PASS must be set") 33 - .as_str(), 34 - env::var("CLICKHOUSE_DATABASE") 35 - .expect("CLICKHOUSE_USER must be set") 36 - .as_str(), 37 - env::var("CLICKHOUSE_TABLE") 38 - .expect("CLICKHOUSE_TABLE must be set") 39 - .as_str(), 40 - ); 41 - 42 - /*strong_api 43 - .login(username.as_str(), password.as_str()) 44 - .await?; 45 - */ 46 - let measurements_response; 47 - // check if measurements.json file exist, if not, fetch the data from the API 48 - if !std::path::Path::new("measurements.json").exists() { 49 - println!("Fetching measurements from API"); 50 - let measurements_response_page1 = strong_api.get_measurements(1).await?; 51 - let measurements_response_page2 = strong_api.get_measurements(2).await?; 52 - measurements_response = measurements_response_page1.merge(measurements_response_page2); 53 - let measurements_json = serde_json::to_string(&measurements_response)?; 54 - std::fs::write("measurements.json", measurements_json)?; 55 - } else { 56 - println!("Reading measurements from file"); 57 - let measurements_json = std::fs::read_to_string("measurements.json")?; 58 - measurements_response = serde_json::from_str(&measurements_json)?; 59 - } 60 - 61 - //let user = strong_api.get_user("", 500, vec![Includes::Log]).await?; 62 - 63 - let response_text = std::fs::read_to_string("response_1742332448.json")?; 64 - let user: UserResponse = serde_json::from_str(&response_text)?; 65 - 66 - println!( 67 - "Measurements count: {}/{}", 68 - &measurements_response.embedded.measurements.len(), 69 - &measurements_response.total 70 - ); 71 - 72 - let data_transformer = DataTransformer::new().with_measurements_response(measurements_response); 73 - 74 - let workouts = data_transformer 75 - .get_measurements_from_logs(&user.embedded.log) 76 - .expect("Couldn't read workouts"); 77 - 78 - println!("Workout count: {}", workouts.len()); 79 - 80 - for workout in workouts.iter() { 81 - clickhouse_saver 82 - .save_workout(workout) 83 - .await 84 - .expect("Couldn't save workout"); 85 - } 86 - 87 - let end = start.elapsed(); 88 - 89 - println!("Time elapsed: {:?}", end); 90 - 91 - Ok(()) 92 - }
+123
strong-api-fetch/src/main.rs
··· 1 + mod clickhouse_saver; 2 + 3 + use dotenv::dotenv; 4 + use reqwest::Url; 5 + use std::env; 6 + use std::fs; 7 + use std::path::Path; 8 + use strong_api_lib::data_transformer::{DataTransformer, Workout}; 9 + use strong_api_lib::json_response::MeasurementsResponse; 10 + use strong_api_lib::strong_api::{Includes, StrongApi}; 11 + 12 + #[tokio::main] 13 + async fn main() -> Result<(), Box<dyn std::error::Error>> { 14 + dotenv().ok(); 15 + 16 + // Load configuration from environment variables. 17 + let config = load_config()?; 18 + let url = Url::parse(&config.strong_backend) 19 + .expect("STRONG_BACKEND is not a valid URL"); 20 + 21 + // Initialize the API and ClickHouse saver. 22 + let mut strong_api = StrongApi::new(url); 23 + let clickhouse_saver = create_clickhouse_saver(&config); 24 + 25 + // Log in to the API. 26 + strong_api.login(config.username.as_str(), config.password.as_str()).await?; 27 + 28 + // Get the measurements (either from file or API). 29 + let measurements_response = get_measurements_response(&mut strong_api).await?; 30 + 31 + // Fetch user data with logs. 32 + let user = strong_api.get_user("", 500, vec![Includes::Log]).await?; 33 + 34 + println!( 35 + "Measurements count: {}/{}", 36 + measurements_response.embedded.measurements.len(), 37 + measurements_response.total 38 + ); 39 + 40 + // Transform the measurements into workouts. 41 + let data_transformer = DataTransformer::new().with_measurements_response(measurements_response); 42 + let workouts = data_transformer 43 + .get_measurements_from_logs(&user.embedded.log) 44 + .expect("Couldn't read workouts"); 45 + 46 + println!("Workout count: {}", workouts.len()); 47 + 48 + // Save each workout using the ClickHouse saver. 49 + save_workouts(&workouts, &clickhouse_saver).await?; 50 + 51 + Ok(()) 52 + } 53 + 54 + /// Holds all configuration values loaded from the environment. 55 + struct Config { 56 + username: String, 57 + password: String, 58 + strong_backend: String, 59 + clickhouse_url: String, 60 + clickhouse_user: String, 61 + clickhouse_pass: String, 62 + clickhouse_database: String, 63 + clickhouse_table: String, 64 + } 65 + 66 + /// Load configuration values from environment variables. 67 + fn load_config() -> Result<Config, Box<dyn std::error::Error>> { 68 + Ok(Config { 69 + username: env::var("STRONG_USER")?, 70 + password: env::var("STRONG_PASS")?, 71 + strong_backend: env::var("STRONG_BACKEND")?, 72 + clickhouse_url: env::var("CLICKHOUSE_URL")?, 73 + clickhouse_user: env::var("CLICKHOUSE_USER")?, 74 + clickhouse_pass: env::var("CLICKHOUSE_PASS")?, 75 + clickhouse_database: env::var("CLICKHOUSE_DATABASE")?, 76 + clickhouse_table: env::var("CLICKHOUSE_TABLE")?, 77 + }) 78 + } 79 + 80 + /// Create a new ClickHouseSaver instance using the provided configuration. 81 + fn create_clickhouse_saver(config: &Config) -> clickhouse_saver::ClickHouseSaver { 82 + clickhouse_saver::ClickHouseSaver::new( 83 + config.clickhouse_url.as_str(), 84 + config.clickhouse_user.as_str(), 85 + config.clickhouse_pass.as_str(), 86 + config.clickhouse_database.as_str(), 87 + config.clickhouse_table.as_str(), 88 + ) 89 + } 90 + 91 + /// Retrieve the measurements response either by reading from a file or fetching from the API. 92 + async fn get_measurements_response( 93 + strong_api: &mut StrongApi, 94 + ) -> Result<MeasurementsResponse, Box<dyn std::error::Error>> { 95 + if !Path::new("measurements.json").exists() { 96 + println!("Fetching measurements from API"); 97 + let measurements_response_page1 = strong_api.get_measurements(1).await?; 98 + let measurements_response_page2 = strong_api.get_measurements(2).await?; 99 + let measurements_response = measurements_response_page1.merge(measurements_response_page2); 100 + let measurements_json = serde_json::to_string(&measurements_response)?; 101 + fs::write("measurements.json", measurements_json)?; 102 + Ok(measurements_response) 103 + } else { 104 + println!("Reading measurements from file"); 105 + let measurements_json = fs::read_to_string("measurements.json")?; 106 + let measurements_response = serde_json::from_str(&measurements_json)?; 107 + Ok(measurements_response) 108 + } 109 + } 110 + 111 + /// Save all workouts to ClickHouse. 112 + async fn save_workouts( 113 + workouts: &[Workout], 114 + clickhouse_saver: &clickhouse_saver::ClickHouseSaver, 115 + ) -> Result<(), Box<dyn std::error::Error>> { 116 + for workout in workouts { 117 + clickhouse_saver 118 + .save_workout(workout) 119 + .await 120 + .expect("Couldn't save workout"); 121 + } 122 + Ok(()) 123 + }
+1 -32
strong-api-lib/src/strong_api.rs
··· 208 208 let status = response.status(); 209 209 let response_text = response.text().await?; 210 210 211 - // write the response to a file, named after the timestamp 212 - let timestamp = std::time::SystemTime::now() 213 - .duration_since(std::time::UNIX_EPOCH) 214 - .unwrap() 215 - .as_secs(); 216 - 217 - let filename = format!("response_{}.json", timestamp); 218 - std::fs::write(&filename, &response_text)?; 219 - 220 211 if !status.is_success() { 221 212 let api_error: ApiErrorResponse = serde_json::from_str(&response_text)?; 222 213 return Err(Box::new(api_error)); ··· 236 227 ) -> Result<MeasurementsResponse, Box<dyn std::error::Error>> { 237 228 let mut url = self.url.join("api/measurements")?; 238 229 239 - let mut headers = HeaderMap::new(); 240 - headers.insert( 241 - HeaderName::from_static("user-agent"), 242 - HeaderValue::from_static("Strong Android"), 243 - ); 244 - headers.insert( 245 - HeaderName::from_static("content-type"), 246 - HeaderValue::from_static("application/json"), 247 - ); 248 - headers.insert( 249 - HeaderName::from_static("accept"), 250 - HeaderValue::from_static("application/json"), 251 - ); 252 - headers.insert( 253 - HeaderName::from_static("x-client-build"), 254 - HeaderValue::from_static("600013"), 255 - ); 256 - headers.insert( 257 - HeaderName::from_static("x-client-platform"), 258 - HeaderValue::from_static("android"), 259 - ); 260 - 261 230 { 262 231 let mut query_pairs = url.query_pairs_mut(); 263 232 query_pairs.append_pair("page", &page.to_string()); 264 233 } 265 234 266 - let response = self.client.get(url).headers(headers.clone()).send().await?; 235 + let response = self.client.get(url).headers(Self::default_headers()).send().await?; 267 236 let response_text = response.text().await?; 268 237 269 238 let response: MeasurementsResponse = serde_json::from_str(&response_text)?;