garmin_cli/cli/commands/
activities.rs1use crate::client::GarminClient;
4use crate::config::CredentialStore;
5use crate::error::{GarminError, Result};
6use crate::models::ActivitySummary;
7use std::path::Path;
8
9use super::auth::refresh_token;
10
11pub async fn list(limit: u32, start: u32, profile: Option<String>) -> Result<()> {
13 let store = CredentialStore::new(profile)?;
14 let (oauth1, oauth2) = refresh_token(&store).await?;
15
16 let client = GarminClient::new(&oauth1.domain);
17 let path = format!(
18 "/activitylist-service/activities/search/activities?limit={}&start={}",
19 limit, start
20 );
21
22 let activities: Vec<ActivitySummary> = client.get_json(&oauth2, &path).await?;
23
24 if activities.is_empty() {
25 println!("No activities found.");
26 return Ok(());
27 }
28
29 println!(
31 "{:<12} {:<10} {:<15} {:>10} {:>12} {:>8}",
32 "ID", "Date", "Type", "Distance", "Duration", "HR"
33 );
34 println!("{}", "-".repeat(75));
35
36 for activity in &activities {
38 let distance = activity
39 .distance_km()
40 .map(|d| format!("{:.2} km", d))
41 .unwrap_or_else(|| "-".to_string());
42
43 let hr = activity
44 .average_hr
45 .map(|h| format!("{:.0}", h))
46 .unwrap_or_else(|| "-".to_string());
47
48 println!(
49 "{:<12} {:<10} {:<15} {:>10} {:>12} {:>8}",
50 activity.activity_id,
51 activity.date(),
52 truncate(&activity.type_key(), 15),
53 distance,
54 activity.duration_formatted(),
55 hr
56 );
57 }
58
59 println!("\nShowing {} activities", activities.len());
60
61 Ok(())
62}
63
64pub async fn get(id: u64, profile: Option<String>) -> Result<()> {
66 let store = CredentialStore::new(profile)?;
67 let (oauth1, oauth2) = refresh_token(&store).await?;
68
69 let client = GarminClient::new(&oauth1.domain);
70 let path = format!("/activity-service/activity/{}", id);
71
72 let activity: serde_json::Value = client.get_json(&oauth2, &path).await?;
73
74 println!("{}", serde_json::to_string_pretty(&activity)?);
76
77 Ok(())
78}
79
80pub async fn download(
82 id: u64,
83 format: &str,
84 output: Option<String>,
85 profile: Option<String>,
86) -> Result<()> {
87 let store = CredentialStore::new(profile)?;
88 let (oauth1, oauth2) = refresh_token(&store).await?;
89
90 let client = GarminClient::new(&oauth1.domain);
91
92 let (path, extension) = match format.to_lowercase().as_str() {
94 "fit" => (format!("/download-service/files/activity/{}", id), "zip"),
95 "gpx" => (
96 format!("/download-service/export/gpx/activity/{}", id),
97 "gpx",
98 ),
99 "tcx" => (
100 format!("/download-service/export/tcx/activity/{}", id),
101 "tcx",
102 ),
103 "kml" => (
104 format!("/download-service/export/kml/activity/{}", id),
105 "kml",
106 ),
107 _ => {
108 return Err(GarminError::invalid_response(format!(
109 "Unknown format: {}. Supported: fit, gpx, tcx, kml",
110 format
111 )));
112 }
113 };
114
115 println!(
116 "Downloading activity {} as {}...",
117 id,
118 format.to_uppercase()
119 );
120
121 let bytes = client.download(&oauth2, &path).await?;
122
123 let output_path = output.unwrap_or_else(|| format!("activity_{}.{}", id, extension));
125
126 tokio::fs::write(&output_path, &bytes)
128 .await
129 .map_err(|e| GarminError::invalid_response(format!("Failed to write file: {}", e)))?;
130
131 println!("Saved to: {}", output_path);
132 println!("Size: {} bytes", bytes.len());
133
134 Ok(())
135}
136
137pub async fn upload(file: &str, profile: Option<String>) -> Result<()> {
139 let store = CredentialStore::new(profile)?;
140 let (oauth1, oauth2) = refresh_token(&store).await?;
141
142 let file_path = Path::new(file);
143
144 if !file_path.exists() {
145 return Err(GarminError::invalid_response(format!(
146 "File not found: {}",
147 file
148 )));
149 }
150
151 let client = GarminClient::new(&oauth1.domain);
152
153 println!("Uploading {}...", file);
154
155 let result = client
156 .upload(&oauth2, "/upload-service/upload/.fit", file_path)
157 .await?;
158
159 if let Some(detailed) = result.get("detailedImportResult") {
161 if let Some(successes) = detailed.get("successes").and_then(|s| s.as_array()) {
162 if !successes.is_empty() {
163 println!("Upload successful!");
164 for success in successes {
165 if let Some(id) = success.get("internalId").and_then(|i| i.as_u64()) {
166 println!("Created activity ID: {}", id);
167 }
168 }
169 return Ok(());
170 }
171 }
172
173 if let Some(failures) = detailed.get("failures").and_then(|f| f.as_array()) {
174 if !failures.is_empty() {
175 println!("Upload had failures:");
176 for failure in failures {
177 println!(" {}", failure);
178 }
179 }
180 }
181 }
182
183 println!("Response: {}", serde_json::to_string_pretty(&result)?);
185
186 Ok(())
187}
188
189fn truncate(s: &str, max_len: usize) -> String {
191 if s.len() <= max_len {
192 s.to_string()
193 } else {
194 format!("{}...", &s[..max_len - 3])
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn test_truncate() {
204 assert_eq!(truncate("short", 10), "short");
205 assert_eq!(truncate("very long string here", 10), "very lo...");
206 }
207}