add map and clicks

This commit is contained in:
Jonas Rabenstein 2026-08-12 17:57:25 +02:00
commit 3cfd5bdd6c
8 changed files with 1195 additions and 992 deletions

View file

@ -1,4 +1,4 @@
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, NaiveDateTime, Utc};
use reqwest::Client;
use serde_json::Value;
@ -26,10 +26,7 @@ impl IntervalsClient {
/// Create a client using an explicitly supplied personal API key.
///
/// Primarily used by /dev/sync/{api_key}/{athlete_id}.
pub fn with_api_key(
config: Config,
api_key: impl Into<String>,
) -> Self {
pub fn with_api_key(config: Config, api_key: impl Into<String>) -> Self {
Self {
client: Client::new(),
config,
@ -38,17 +35,13 @@ impl IntervalsClient {
}
fn base_url(&self) -> &str {
self.config
.intervals_base_url
.trim_end_matches('/')
self.config.intervals_base_url.trim_end_matches('/')
}
fn api_key(&self) -> Result<&str> {
self.api_key
.as_deref()
.context(
"Intervals.icu API key is not configured",
)
.context("Intervals.icu API key is not configured")
}
fn api_url(&self, path: &str) -> String {
@ -59,54 +52,30 @@ impl IntervalsClient {
)
}
fn authenticated(
&self,
request: reqwest::RequestBuilder,
) -> Result<reqwest::RequestBuilder> {
Ok(
request.basic_auth(
"API_KEY",
Some(self.api_key()?),
)
)
fn authenticated(&self, request: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
Ok(request.basic_auth("API_KEY", Some(self.api_key()?)))
}
async fn get_json(
&self,
url: String,
) -> Result<Value> {
async fn get_json(&self, url: String) -> Result<Value> {
let response = self
.authenticated(self.client.get(&url))?
.send()
.await
.context(
"Intervals.icu request failed",
)?;
.context("Intervals.icu request failed")?;
let status = response.status();
let body = response
.text()
.await
.context(
"cannot read Intervals.icu response",
)?;
.context("cannot read Intervals.icu response")?;
if !status.is_success() {
return Err(anyhow!(
"Intervals.icu returned HTTP {}: {}",
status,
body
));
return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body));
}
serde_json::from_str(&body)
.with_context(|| {
format!(
"invalid JSON returned by Intervals.icu: {}",
body
)
})
.with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body))
}
/// Get the athlete belonging to the configured API key.
@ -119,13 +88,8 @@ impl IntervalsClient {
}
/// Get a specific athlete.
pub async fn athlete(
&self,
athlete_id: &str,
) -> Result<Value> {
let url = self.api_url(
&format!("athlete/{athlete_id}"),
);
pub async fn athlete(&self, athlete_id: &str) -> Result<Value> {
let url = self.api_url(&format!("athlete/{athlete_id}"));
self.get_json(url).await
}
@ -133,20 +97,13 @@ impl IntervalsClient {
/// Get a single activity.
///
/// GET /api/v1/activity/{id}
pub async fn activity(
&self,
activity_id: &str,
) -> Result<Value> {
pub async fn activity(&self, activity_id: &str) -> Result<Value> {
/*
* Ask Intervals.icu to include interval information,
* which is later used to associate county crossings
* with intervals.
*/
let url = self.api_url(
&format!(
"activity/{activity_id}?intervals=true"
),
);
let url = self.api_url(&format!("activity/{activity_id}?intervals=true"));
self.get_json(url).await
}
@ -157,43 +114,27 @@ impl IntervalsClient {
///
/// We explicitly request the streams needed for GPS
/// processing.
pub async fn streams(
&self,
activity_id: &str,
) -> Result<Value> {
let url = self.api_url(
&format!(
"activity/{activity_id}/streams.json?types=time,latlng"
),
);
pub async fn streams(&self, activity_id: &str) -> Result<Value> {
let url = self.api_url(&format!(
"activity/{activity_id}/streams.json?types=time,latlng"
));
tracing::info!(
activity_id = %activity_id,
"requesting Intervals.icu streams"
);
let value =
self.get_json(url).await?;
let value = self.get_json(url).await?;
let stream_count = value
.as_array()
.map(|streams| streams.len())
.unwrap_or(0);
let stream_count = value.as_array().map(|streams| streams.len()).unwrap_or(0);
let time_points =
Self::stream_values(
&value,
"time",
)
let time_points = Self::stream_values(&value, "time")
.map(|values| values.len())
.unwrap_or(0);
let (latitude_points, longitude_points) =
Self::latlng_values(&value)
.map(|(lat, lon)| {
(lat.len(), lon.len())
})
.unwrap_or((0, 0));
let (latitude_points, longitude_points) = Self::latlng_values(&value)
.map(|(lat, lon)| (lat.len(), lon.len()))
.unwrap_or((0, 0));
tracing::info!(
activity_id = %activity_id,
@ -224,21 +165,11 @@ impl IntervalsClient {
/// ]
///
/// Also tolerates a dictionary-like response.
pub fn find_stream<'a>(
streams: &'a Value,
stream_type: &str,
) -> Option<&'a Value> {
if let Some(array) =
streams.as_array()
{
return array.iter().find(
|stream| {
stream
.get("type")
.and_then(Value::as_str)
== Some(stream_type)
},
);
pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> {
if let Some(array) = streams.as_array() {
return array
.iter()
.find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type));
}
streams.get(stream_type)
@ -252,19 +183,10 @@ impl IntervalsClient {
/// "type": "time",
/// "data": [0, 1, 2, 3]
/// }
pub fn stream_values<'a>(
streams: &'a Value,
stream_type: &str,
) -> Option<&'a Vec<Value>> {
let stream =
Self::find_stream(
streams,
stream_type,
)?;
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
let stream = Self::find_stream(streams, stream_type)?;
stream
.get("data")
.and_then(Value::as_array)
stream.get("data").and_then(Value::as_array)
}
/// Get latitude and longitude arrays from the
@ -286,49 +208,24 @@ impl IntervalsClient {
/// It is NOT:
///
/// data = [[lat, lon], [lat, lon], ...]
pub fn latlng_values<'a>(
streams: &'a Value,
) -> Option<(
&'a Vec<Value>,
&'a Vec<Value>,
)> {
let stream =
Self::find_stream(
streams,
"latlng",
)?;
pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> {
let stream = Self::find_stream(streams, "latlng")?;
let latitudes = stream
.get("data")
.and_then(Value::as_array)?;
let latitudes = stream.get("data").and_then(Value::as_array)?;
let longitudes = stream
.get("data2")
.and_then(Value::as_array)?;
let longitudes = stream.get("data2").and_then(Value::as_array)?;
Some((
latitudes,
longitudes,
))
Some((latitudes, longitudes))
}
/// Fetch an athlete's activities.
///
/// `athlete_id = "0"` means the athlete belonging to
/// the API key.
pub async fn activities(
&self,
athlete_id: &str,
oldest: &str,
newest: &str,
) -> Result<Value> {
pub async fn activities(&self, athlete_id: &str, oldest: &str, newest: &str) -> Result<Value> {
let url = format!(
"{}?oldest={}&newest={}",
self.api_url(
&format!(
"athlete/{athlete_id}/activities"
),
),
self.api_url(&format!("athlete/{athlete_id}/activities"),),
urlencoding::encode(oldest),
urlencoding::encode(newest),
);
@ -337,68 +234,39 @@ impl IntervalsClient {
}
/// Extract the activity start timestamp.
pub fn activity_start(
activity: &Value,
) -> Result<DateTime<Utc>> {
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
let value = activity
.get("start_date")
.or_else(|| {
activity.get("start_date_local")
})
.or_else(|| {
activity.get("start_time")
})
.ok_or_else(|| {
anyhow!(
"activity has no start timestamp"
)
})?;
.or_else(|| activity.get("start_date_local"))
.or_else(|| activity.get("start_time"))
.ok_or_else(|| anyhow!("activity has no start timestamp"))?;
let string = value
.as_str()
.ok_or_else(|| {
anyhow!(
"activity start timestamp is not a string"
)
})?;
.ok_or_else(|| anyhow!("activity start timestamp is not a string"))?;
parse_datetime(string)
}
/// Try to find an Intervals.icu interval corresponding
/// to a GPS stream index.
pub fn matching_interval(
activity: &Value,
track_index: usize,
) -> Option<Value> {
let intervals =
activity.get("icu_intervals")?;
pub fn matching_interval(activity: &Value, track_index: usize) -> Option<Value> {
let intervals = activity.get("icu_intervals")?;
let array =
intervals.as_array()?;
let array = intervals.as_array()?;
for interval in array {
let start = interval
.get("start_index")
.or_else(|| {
interval.get("start")
});
.or_else(|| interval.get("start"));
let end = interval
.get("end_index")
.or_else(|| {
interval.get("end")
});
let end = interval.get("end_index").or_else(|| interval.get("end"));
let start =
start.and_then(Value::as_u64)?;
let start = start.and_then(Value::as_u64)?;
let end =
end.and_then(Value::as_u64)?;
let end = end.and_then(Value::as_u64)?;
if (start as usize) <= track_index
&& track_index <= end as usize
{
if (start as usize) <= track_index && track_index <= end as usize {
return Some(interval.clone());
}
}
@ -407,25 +275,18 @@ impl IntervalsClient {
}
/// OAuth methods retained for the normal multi-user flow.
pub fn oauth_authorize_url(
&self,
state: &str,
) -> Result<String> {
pub fn oauth_authorize_url(&self, state: &str) -> Result<String> {
let client_id = self
.config
.intervals_client_id
.as_deref()
.context(
"INTERVALS_CLIENT_ID is not configured",
)?;
.context("INTERVALS_CLIENT_ID is not configured")?;
let redirect_uri = self
.config
.intervals_redirect_uri
.as_deref()
.context(
"INTERVALS_REDIRECT_URI is not configured",
)?;
.context("INTERVALS_REDIRECT_URI is not configured")?;
Ok(format!(
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
@ -437,32 +298,22 @@ impl IntervalsClient {
))
}
pub async fn exchange_code(
&self,
code: &str,
) -> Result<Value> {
pub async fn exchange_code(&self, code: &str) -> Result<Value> {
let client_id = self
.config
.intervals_client_id
.as_deref()
.context(
"INTERVALS_CLIENT_ID is not configured",
)?;
.context("INTERVALS_CLIENT_ID is not configured")?;
let client_secret = self
.config
.intervals_client_secret
.as_deref()
.context(
"INTERVALS_CLIENT_SECRET is not configured",
)?;
.context("INTERVALS_CLIENT_SECRET is not configured")?;
let response = self
.client
.post(format!(
"{}/api/oauth/token",
self.base_url()
))
.post(format!("{}/api/oauth/token", self.base_url()))
.form(&[
("client_id", client_id),
("client_secret", client_secret),
@ -470,15 +321,11 @@ impl IntervalsClient {
])
.send()
.await
.context(
"OAuth token request failed",
)?;
.context("OAuth token request failed")?;
let status = response.status();
let body = response
.text()
.await?;
let body = response.text().await?;
if !status.is_success() {
return Err(anyhow!(
@ -491,20 +338,11 @@ impl IntervalsClient {
Ok(serde_json::from_str(&body)?)
}
pub fn token_data(
response: &Value,
) -> Result<(
String,
String,
String,
String,
)> {
pub fn token_data(response: &Value) -> Result<(String, String, String, String)> {
let access_token = response
.get("access_token")
.and_then(Value::as_str)
.context(
"OAuth response has no access_token",
)?
.context("OAuth response has no access_token")?
.to_string();
let scope = response
@ -515,9 +353,7 @@ impl IntervalsClient {
let athlete = response
.get("athlete")
.context(
"OAuth response has no athlete",
)?;
.context("OAuth response has no athlete")?;
let athlete_id = athlete
.get("id")
@ -533,62 +369,27 @@ impl IntervalsClient {
let display_name = athlete
.get("name")
.or_else(|| {
athlete.get("display_name")
})
.or_else(|| athlete.get("display_name"))
.and_then(Value::as_str)
.unwrap_or(
"Intervals.icu athlete",
)
.unwrap_or("Intervals.icu athlete")
.to_string();
Ok((
access_token,
scope,
athlete_id,
display_name,
))
Ok((access_token, scope, athlete_id, display_name))
}
}
fn parse_datetime(
value: &str,
) -> Result<DateTime<Utc>> {
if let Ok(value) =
DateTime::parse_from_rfc3339(value)
{
return Ok(
value.with_timezone(&Utc)
);
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
if let Ok(value) = DateTime::parse_from_rfc3339(value) {
return Ok(value.with_timezone(&Utc));
}
if let Ok(value) =
DateTime::parse_from_str(
value,
"%Y-%m-%dT%H:%M:%S%z",
)
{
return Ok(
value.with_timezone(&Utc)
);
if let Ok(value) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%z") {
return Ok(value.with_timezone(&Utc));
}
if let Ok(value) =
NaiveDateTime::parse_from_str(
value,
"%Y-%m-%dT%H:%M:%S",
)
{
return Ok(
DateTime::<Utc>::from_naive_utc_and_offset(
value,
Utc,
)
);
if let Ok(value) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") {
return Ok(DateTime::<Utc>::from_naive_utc_and_offset(value, Utc));
}
Err(anyhow!(
"cannot parse activity timestamp: {}",
value
))
Err(anyhow!("cannot parse activity timestamp: {}", value))
}