state
This commit is contained in:
parent
eda8e76a20
commit
45bcc13788
3 changed files with 678 additions and 174 deletions
|
|
@ -16,24 +16,55 @@ fi
|
||||||
: "${DATABASE_URL:?DATABASE_URL must be set}"
|
: "${DATABASE_URL:?DATABASE_URL must be set}"
|
||||||
|
|
||||||
echo "Available layers:"
|
echo "Available layers:"
|
||||||
ogrinfo "$INPUT" | sed -n '1,80p'
|
ogrinfo "$INPUT" | sed -n '1,120p'
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Importing vg250_krs..."
|
echo "Inspecting vg250_krs..."
|
||||||
|
ogrinfo "$INPUT" vg250_krs -so
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Importing only vg250_krs..."
|
||||||
|
|
||||||
|
# Remove an old failed import.
|
||||||
|
psql "$DATABASE_URL" <<'SQL'
|
||||||
|
DROP TABLE IF EXISTS vg250_krs_import CASCADE;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# IMPORTANT:
|
||||||
|
# The layer name is explicitly specified here.
|
||||||
ogr2ogr \
|
ogr2ogr \
|
||||||
-f PostgreSQL \
|
-f PostgreSQL \
|
||||||
"$DATABASE_URL" \
|
"$DATABASE_URL" \
|
||||||
"$INPUT" \
|
"$INPUT" \
|
||||||
|
vg250_krs \
|
||||||
-nln vg250_krs_import \
|
-nln vg250_krs_import \
|
||||||
-nlt PROMOTE_TO_MULTI \
|
-nlt PROMOTE_TO_MULTI \
|
||||||
-t_srs EPSG:4326 \
|
-t_srs EPSG:4326 \
|
||||||
|
-lco GEOMETRY_NAME=wkb_geometry \
|
||||||
-overwrite
|
-overwrite
|
||||||
|
|
||||||
echo "Import completed."
|
echo
|
||||||
|
echo "Source import completed."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Imported source columns:"
|
||||||
|
psql "$DATABASE_URL" <<'SQL'
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'vg250_krs_import'
|
||||||
|
ORDER BY ordinal_position;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Creating county boundaries..."
|
||||||
|
|
||||||
psql "$DATABASE_URL" <<'SQL'
|
psql "$DATABASE_URL" <<'SQL'
|
||||||
TRUNCATE county_boundaries RESTART IDENTITY;
|
BEGIN;
|
||||||
|
|
||||||
|
TRUNCATE county_crossings;
|
||||||
|
TRUNCATE county_boundaries RESTART IDENTITY CASCADE;
|
||||||
|
|
||||||
INSERT INTO county_boundaries (
|
INSERT INTO county_boundaries (
|
||||||
name,
|
name,
|
||||||
|
|
@ -41,9 +72,9 @@ INSERT INTO county_boundaries (
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF("GEN", ''),
|
NULLIF(TRIM(gen), ''),
|
||||||
NULLIF("BEZ", ''),
|
NULLIF(TRIM(bez), ''),
|
||||||
NULLIF("ARS", ''),
|
NULLIF(TRIM(ars), ''),
|
||||||
'unknown'
|
'unknown'
|
||||||
) AS name,
|
) AS name,
|
||||||
|
|
||||||
|
|
@ -52,13 +83,29 @@ SELECT
|
||||||
ST_MakeValid(wkb_geometry),
|
ST_MakeValid(wkb_geometry),
|
||||||
3
|
3
|
||||||
)
|
)
|
||||||
)::geometry(MultiPolygon,4326)
|
)::geometry(MultiPolygon, 4326) AS geometry
|
||||||
|
|
||||||
FROM vg250_krs_import
|
FROM vg250_krs_import
|
||||||
|
|
||||||
WHERE wkb_geometry IS NOT NULL;
|
WHERE
|
||||||
|
wkb_geometry IS NOT NULL
|
||||||
|
AND NOT ST_IsEmpty(wkb_geometry);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Removing temporary import table..."
|
||||||
|
|
||||||
|
psql "$DATABASE_URL" <<'SQL'
|
||||||
DROP TABLE vg250_krs_import;
|
DROP TABLE vg250_krs_import;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Recreating spatial index..."
|
||||||
|
|
||||||
|
psql "$DATABASE_URL" <<'SQL'
|
||||||
|
DROP INDEX IF EXISTS county_boundaries_geometry_idx;
|
||||||
|
|
||||||
CREATE INDEX county_boundaries_geometry_idx
|
CREATE INDEX county_boundaries_geometry_idx
|
||||||
ON county_boundaries
|
ON county_boundaries
|
||||||
|
|
@ -68,6 +115,32 @@ ANALYZE county_boundaries;
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Imported counties:"
|
echo "=========================================="
|
||||||
psql "$DATABASE_URL" \
|
echo " Imported counties"
|
||||||
-c "SELECT id, name FROM county_boundaries ORDER BY name;"
|
echo "=========================================="
|
||||||
|
|
||||||
|
psql "$DATABASE_URL" <<'SQL'
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COUNT(*) FILTER (WHERE geometry IS NOT NULL) AS with_geometry,
|
||||||
|
COUNT(*) FILTER (WHERE ST_IsValid(geometry)) AS valid_geometry
|
||||||
|
FROM county_boundaries;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "First 20 counties:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
psql "$DATABASE_URL" <<'SQL'
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ST_GeometryType(geometry) AS geometry_type,
|
||||||
|
ST_SRID(geometry) AS srid
|
||||||
|
FROM county_boundaries
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 20;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Import completed successfully."
|
||||||
|
|
|
||||||
431
src/intervals.rs
431
src/intervals.rs
|
|
@ -1,4 +1,4 @@
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
@ -26,7 +26,10 @@ impl IntervalsClient {
|
||||||
/// Create a client using an explicitly supplied personal API key.
|
/// Create a client using an explicitly supplied personal API key.
|
||||||
///
|
///
|
||||||
/// Primarily used by /dev/sync/{api_key}/{athlete_id}.
|
/// 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 {
|
Self {
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
config,
|
config,
|
||||||
|
|
@ -35,13 +38,17 @@ impl IntervalsClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base_url(&self) -> &str {
|
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> {
|
fn api_key(&self) -> Result<&str> {
|
||||||
self.api_key
|
self.api_key
|
||||||
.as_deref()
|
.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 {
|
fn api_url(&self, path: &str) -> String {
|
||||||
|
|
@ -52,30 +59,54 @@ impl IntervalsClient {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authenticated(&self, request: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
|
fn authenticated(
|
||||||
Ok(request.basic_auth("API_KEY", Some(self.api_key()?)))
|
&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
|
let response = self
|
||||||
.authenticated(self.client.get(&url))?
|
.authenticated(self.client.get(&url))?
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("Intervals.icu request failed")?;
|
.context(
|
||||||
|
"Intervals.icu request failed",
|
||||||
|
)?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|
||||||
let body = response
|
let body = response
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.context("cannot read Intervals.icu response")?;
|
.context(
|
||||||
|
"cannot read Intervals.icu response",
|
||||||
|
)?;
|
||||||
|
|
||||||
if !status.is_success() {
|
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)
|
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.
|
/// Get the athlete belonging to the configured API key.
|
||||||
|
|
@ -83,12 +114,18 @@ impl IntervalsClient {
|
||||||
/// GET /api/v1/athlete/0
|
/// GET /api/v1/athlete/0
|
||||||
pub async fn owner(&self) -> Result<Value> {
|
pub async fn owner(&self) -> Result<Value> {
|
||||||
let url = self.api_url("athlete/0");
|
let url = self.api_url("athlete/0");
|
||||||
|
|
||||||
self.get_json(url).await
|
self.get_json(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a specific athlete.
|
/// Get a specific athlete.
|
||||||
pub async fn athlete(&self, athlete_id: &str) -> Result<Value> {
|
pub async fn athlete(
|
||||||
let url = self.api_url(&format!("athlete/{athlete_id}"));
|
&self,
|
||||||
|
athlete_id: &str,
|
||||||
|
) -> Result<Value> {
|
||||||
|
let url = self.api_url(
|
||||||
|
&format!("athlete/{athlete_id}"),
|
||||||
|
);
|
||||||
|
|
||||||
self.get_json(url).await
|
self.get_json(url).await
|
||||||
}
|
}
|
||||||
|
|
@ -96,8 +133,20 @@ impl IntervalsClient {
|
||||||
/// Get a single activity.
|
/// Get a single activity.
|
||||||
///
|
///
|
||||||
/// GET /api/v1/activity/{id}
|
/// GET /api/v1/activity/{id}
|
||||||
pub async fn activity(&self, activity_id: &str) -> Result<Value> {
|
pub async fn activity(
|
||||||
let url = self.api_url(&format!("activity/{activity_id}?intervals=true"));
|
&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"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
self.get_json(url).await
|
self.get_json(url).await
|
||||||
}
|
}
|
||||||
|
|
@ -106,78 +155,180 @@ impl IntervalsClient {
|
||||||
///
|
///
|
||||||
/// GET /api/v1/activity/{id}/streams.json
|
/// GET /api/v1/activity/{id}/streams.json
|
||||||
///
|
///
|
||||||
/// We explicitly request the streams needed for GPS processing.
|
/// We explicitly request the streams needed for GPS
|
||||||
pub async fn streams(&self, activity_id: &str) -> Result<Value> {
|
/// processing.
|
||||||
let url = self.api_url(&format!(
|
pub async fn streams(
|
||||||
"activity/{activity_id}/streams.json?types=time,latlng"
|
&self,
|
||||||
));
|
activity_id: &str,
|
||||||
|
) -> Result<Value> {
|
||||||
|
let url = self.api_url(
|
||||||
|
&format!(
|
||||||
|
"activity/{activity_id}/streams.json?types=time,latlng"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
self.get_json(url).await
|
tracing::info!(
|
||||||
|
activity_id = %activity_id,
|
||||||
|
"requesting Intervals.icu streams"
|
||||||
|
);
|
||||||
|
|
||||||
|
let value =
|
||||||
|
self.get_json(url).await?;
|
||||||
|
|
||||||
|
let stream_count = value
|
||||||
|
.as_array()
|
||||||
|
.map(|streams| streams.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
activity_id = %activity_id,
|
||||||
|
stream_count,
|
||||||
|
time_points,
|
||||||
|
latitude_points,
|
||||||
|
longitude_points,
|
||||||
|
"received Intervals.icu streams"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a stream object by type.
|
/// Find a stream object by type.
|
||||||
pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> {
|
///
|
||||||
// Normal API response:
|
/// Normal Intervals.icu response:
|
||||||
//
|
///
|
||||||
// [
|
/// [
|
||||||
// {
|
/// {
|
||||||
// "type": "time",
|
/// "type": "time",
|
||||||
// "data": [...]
|
/// "data": [...]
|
||||||
// },
|
/// },
|
||||||
// {
|
/// {
|
||||||
// "type": "latlng",
|
/// "type": "latlng",
|
||||||
// "data": [...],
|
/// "data": [...],
|
||||||
// "data2": [...]
|
/// "data2": [...]
|
||||||
// }
|
/// }
|
||||||
// ]
|
/// ]
|
||||||
if let Some(array) = streams.as_array() {
|
///
|
||||||
return array
|
/// Also tolerates a dictionary-like response.
|
||||||
.iter()
|
pub fn find_stream<'a>(
|
||||||
.find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type));
|
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)
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also tolerate:
|
|
||||||
//
|
|
||||||
// {
|
|
||||||
// "time": {...},
|
|
||||||
// "latlng": {...}
|
|
||||||
// }
|
|
||||||
streams.get(stream_type)
|
streams.get(stream_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the `data` array of a normal stream.
|
/// Get the `data` array of a normal stream.
|
||||||
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
|
///
|
||||||
let stream = Self::find_stream(streams, stream_type)?;
|
/// For example:
|
||||||
|
///
|
||||||
|
/// {
|
||||||
|
/// "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,
|
||||||
|
)?;
|
||||||
|
|
||||||
stream.get("data").and_then(Value::as_array)
|
stream
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the latitude and longitude arrays from the
|
/// Get latitude and longitude arrays from the
|
||||||
/// Intervals.icu `latlng` stream.
|
/// Intervals.icu `latlng` stream.
|
||||||
///
|
///
|
||||||
/// Intervals.icu represents latlng as:
|
/// Intervals.icu represents latlng as:
|
||||||
///
|
///
|
||||||
|
/// {
|
||||||
|
/// "type": "latlng",
|
||||||
|
/// "data": [latitude, latitude, ...],
|
||||||
|
/// "data2": [longitude, longitude, ...]
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// Therefore:
|
||||||
|
///
|
||||||
/// data = latitude
|
/// data = latitude
|
||||||
/// data2 = longitude
|
/// data2 = longitude
|
||||||
///
|
///
|
||||||
pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> {
|
/// It is NOT:
|
||||||
let stream = Self::find_stream(streams, "latlng")?;
|
///
|
||||||
|
/// 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",
|
||||||
|
)?;
|
||||||
|
|
||||||
let lat = stream.get("data")?.as_array()?;
|
let latitudes = stream
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_array)?;
|
||||||
|
|
||||||
let lon = stream.get("data2")?.as_array()?;
|
let longitudes = stream
|
||||||
|
.get("data2")
|
||||||
|
.and_then(Value::as_array)?;
|
||||||
|
|
||||||
Some((lat, lon))
|
Some((
|
||||||
|
latitudes,
|
||||||
|
longitudes,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch an athlete's activities.
|
/// Fetch an athlete's activities.
|
||||||
///
|
///
|
||||||
/// `athlete_id = "0"` means the athlete belonging to
|
/// `athlete_id = "0"` means the athlete belonging to
|
||||||
/// the API key.
|
/// 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!(
|
let url = format!(
|
||||||
"{}?oldest={}&newest={}",
|
"{}?oldest={}&newest={}",
|
||||||
self.api_url(&format!("athlete/{athlete_id}/activities")),
|
self.api_url(
|
||||||
|
&format!(
|
||||||
|
"athlete/{athlete_id}/activities"
|
||||||
|
),
|
||||||
|
),
|
||||||
urlencoding::encode(oldest),
|
urlencoding::encode(oldest),
|
||||||
urlencoding::encode(newest),
|
urlencoding::encode(newest),
|
||||||
);
|
);
|
||||||
|
|
@ -186,39 +337,68 @@ impl IntervalsClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the activity start timestamp.
|
/// 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
|
let value = activity
|
||||||
.get("start_date")
|
.get("start_date")
|
||||||
.or_else(|| activity.get("start_date_local"))
|
.or_else(|| {
|
||||||
.or_else(|| activity.get("start_time"))
|
activity.get("start_date_local")
|
||||||
.ok_or_else(|| anyhow!("activity has no start timestamp"))?;
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
activity.get("start_time")
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"activity has no start timestamp"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let string = value
|
let string = value
|
||||||
.as_str()
|
.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)
|
parse_datetime(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to find an Intervals.icu interval corresponding
|
/// Try to find an Intervals.icu interval corresponding
|
||||||
/// to a GPS stream index.
|
/// to a GPS stream index.
|
||||||
pub fn matching_interval(activity: &Value, track_index: usize) -> Option<Value> {
|
pub fn matching_interval(
|
||||||
let intervals = activity.get("icu_intervals")?;
|
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 {
|
for interval in array {
|
||||||
let start = interval
|
let start = interval
|
||||||
.get("start_index")
|
.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());
|
return Some(interval.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -227,18 +407,25 @@ impl IntervalsClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OAuth methods retained for the normal multi-user flow.
|
/// 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
|
let client_id = self
|
||||||
.config
|
.config
|
||||||
.intervals_client_id
|
.intervals_client_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.context("INTERVALS_CLIENT_ID is not configured")?;
|
.context(
|
||||||
|
"INTERVALS_CLIENT_ID is not configured",
|
||||||
|
)?;
|
||||||
|
|
||||||
let redirect_uri = self
|
let redirect_uri = self
|
||||||
.config
|
.config
|
||||||
.intervals_redirect_uri
|
.intervals_redirect_uri
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.context("INTERVALS_REDIRECT_URI is not configured")?;
|
.context(
|
||||||
|
"INTERVALS_REDIRECT_URI is not configured",
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
|
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
|
||||||
|
|
@ -250,22 +437,32 @@ 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
|
let client_id = self
|
||||||
.config
|
.config
|
||||||
.intervals_client_id
|
.intervals_client_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.context("INTERVALS_CLIENT_ID is not configured")?;
|
.context(
|
||||||
|
"INTERVALS_CLIENT_ID is not configured",
|
||||||
|
)?;
|
||||||
|
|
||||||
let client_secret = self
|
let client_secret = self
|
||||||
.config
|
.config
|
||||||
.intervals_client_secret
|
.intervals_client_secret
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.context("INTERVALS_CLIENT_SECRET is not configured")?;
|
.context(
|
||||||
|
"INTERVALS_CLIENT_SECRET is not configured",
|
||||||
|
)?;
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.post(format!("{}/api/oauth/token", self.base_url()))
|
.post(format!(
|
||||||
|
"{}/api/oauth/token",
|
||||||
|
self.base_url()
|
||||||
|
))
|
||||||
.form(&[
|
.form(&[
|
||||||
("client_id", client_id),
|
("client_id", client_id),
|
||||||
("client_secret", client_secret),
|
("client_secret", client_secret),
|
||||||
|
|
@ -273,11 +470,15 @@ impl IntervalsClient {
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("OAuth token request failed")?;
|
.context(
|
||||||
|
"OAuth token request failed",
|
||||||
|
)?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|
||||||
let body = response.text().await?;
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
|
|
@ -290,11 +491,20 @@ impl IntervalsClient {
|
||||||
Ok(serde_json::from_str(&body)?)
|
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
|
let access_token = response
|
||||||
.get("access_token")
|
.get("access_token")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.context("OAuth response has no access_token")?
|
.context(
|
||||||
|
"OAuth response has no access_token",
|
||||||
|
)?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let scope = response
|
let scope = response
|
||||||
|
|
@ -305,7 +515,9 @@ impl IntervalsClient {
|
||||||
|
|
||||||
let athlete = response
|
let athlete = response
|
||||||
.get("athlete")
|
.get("athlete")
|
||||||
.context("OAuth response has no athlete")?;
|
.context(
|
||||||
|
"OAuth response has no athlete",
|
||||||
|
)?;
|
||||||
|
|
||||||
let athlete_id = athlete
|
let athlete_id = athlete
|
||||||
.get("id")
|
.get("id")
|
||||||
|
|
@ -321,27 +533,62 @@ impl IntervalsClient {
|
||||||
|
|
||||||
let display_name = athlete
|
let display_name = athlete
|
||||||
.get("name")
|
.get("name")
|
||||||
.or_else(|| athlete.get("display_name"))
|
.or_else(|| {
|
||||||
|
athlete.get("display_name")
|
||||||
|
})
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("Intervals.icu athlete")
|
.unwrap_or(
|
||||||
|
"Intervals.icu athlete",
|
||||||
|
)
|
||||||
.to_string();
|
.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>> {
|
fn parse_datetime(
|
||||||
if let Ok(value) = DateTime::parse_from_rfc3339(value) {
|
value: &str,
|
||||||
return Ok(value.with_timezone(&Utc));
|
) -> 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") {
|
if let Ok(value) =
|
||||||
return Ok(value.with_timezone(&Utc));
|
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") {
|
if let Ok(value) =
|
||||||
return Ok(DateTime::<Utc>::from_naive_utc_and_offset(value, Utc));
|
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
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
316
src/main.rs
316
src/main.rs
|
|
@ -8,20 +8,24 @@ mod model;
|
||||||
mod web;
|
mod web;
|
||||||
mod webhook;
|
mod webhook;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use crate::{config::Config, intervals::IntervalsClient, model::TrackPoint};
|
use crate::{
|
||||||
|
config::Config,
|
||||||
|
intervals::IntervalsClient,
|
||||||
|
model::TrackPoint,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
|
|
@ -115,7 +119,6 @@ async fn dev_sync(
|
||||||
) -> Result<Json<Value>, axum::http::StatusCode> {
|
) -> Result<Json<Value>, axum::http::StatusCode> {
|
||||||
match dev_sync_inner(state, api_key, athlete_id).await {
|
match dev_sync_inner(state, api_key, athlete_id).await {
|
||||||
Ok(result) => Ok(Json(result)),
|
Ok(result) => Ok(Json(result)),
|
||||||
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %error,
|
error = %error,
|
||||||
|
|
@ -129,18 +132,17 @@ async fn dev_sync(
|
||||||
|
|
||||||
/// One-segment variant:
|
/// One-segment variant:
|
||||||
///
|
///
|
||||||
/// /dev/sync/{api_key}
|
/// GET /dev/sync/{api_key}
|
||||||
///
|
///
|
||||||
/// Equivalent to:
|
/// Equivalent to:
|
||||||
///
|
///
|
||||||
/// /dev/sync/{api_key}/0
|
/// GET /dev/sync/{api_key}/0
|
||||||
async fn dev_sync_one(
|
async fn dev_sync_one(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(api_key): Path<String>,
|
Path(api_key): Path<String>,
|
||||||
) -> Result<Json<Value>, axum::http::StatusCode> {
|
) -> Result<Json<Value>, axum::http::StatusCode> {
|
||||||
match dev_sync_inner(state, api_key, "0".to_string()).await {
|
match dev_sync_inner(state, api_key, "0".to_string()).await {
|
||||||
Ok(result) => Ok(Json(result)),
|
Ok(result) => Ok(Json(result)),
|
||||||
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %error,
|
error = %error,
|
||||||
|
|
@ -152,7 +154,11 @@ async fn dev_sync_one(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) -> Result<Value> {
|
async fn dev_sync_inner(
|
||||||
|
state: AppState,
|
||||||
|
api_key: String,
|
||||||
|
athlete_id: String,
|
||||||
|
) -> Result<Value> {
|
||||||
if api_key.trim().is_empty() {
|
if api_key.trim().is_empty() {
|
||||||
return Err(anyhow!("API key is empty"));
|
return Err(anyhow!("API key is empty"));
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +173,8 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
|
||||||
* This client is explicitly authenticated using the API key
|
* This client is explicitly authenticated using the API key
|
||||||
* supplied to the development endpoint.
|
* supplied to the development endpoint.
|
||||||
*/
|
*/
|
||||||
let client = IntervalsClient::with_api_key(state.config.clone(), api_key.clone());
|
let client =
|
||||||
|
IntervalsClient::with_api_key(state.config.clone(), api_key.clone());
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Resolve athlete 0 through Intervals.icu.
|
* Resolve athlete 0 through Intervals.icu.
|
||||||
|
|
@ -197,11 +204,9 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
|
||||||
};
|
};
|
||||||
|
|
||||||
let newest = Utc::now().date_naive();
|
let newest = Utc::now().date_naive();
|
||||||
|
|
||||||
let oldest = newest - Duration::days(state.config.dev_sync_days);
|
let oldest = newest - Duration::days(state.config.dev_sync_days);
|
||||||
|
|
||||||
let oldest_string = oldest.format("%Y-%m-%d").to_string();
|
let oldest_string = oldest.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
let newest_string = newest.format("%Y-%m-%d").to_string();
|
let newest_string = newest.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
@ -212,13 +217,14 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* IMPORTANT:
|
* Use the resolved athlete ID here, not the original "0".
|
||||||
*
|
|
||||||
* Use the resolved athlete ID here, not the original
|
|
||||||
* "0" value.
|
|
||||||
*/
|
*/
|
||||||
let activities = client
|
let activities = client
|
||||||
.activities(&resolved_athlete_id, &oldest_string, &newest_string)
|
.activities(
|
||||||
|
&resolved_athlete_id,
|
||||||
|
&oldest_string,
|
||||||
|
&newest_string,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.context("cannot fetch development activities")?;
|
.context("cannot fetch development activities")?;
|
||||||
|
|
||||||
|
|
@ -235,14 +241,21 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
|
||||||
/*
|
/*
|
||||||
* Make sure there is a corresponding local athlete row.
|
* Make sure there is a corresponding local athlete row.
|
||||||
*
|
*
|
||||||
* We store the development API key as access_token because
|
* The development API key is stored as access_token so that
|
||||||
* process_activity() uses the local athlete record and the
|
* process_activity() can use exactly the same credential.
|
||||||
* existing activity-processing pipeline expects that field.
|
|
||||||
*/
|
*/
|
||||||
ensure_dev_athlete(&state, &resolved_athlete_id, &client, &api_key).await?;
|
ensure_dev_athlete(
|
||||||
|
&state,
|
||||||
|
&resolved_athlete_id,
|
||||||
|
&client,
|
||||||
|
&api_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
for activity in activities_array {
|
for activity in activities_array {
|
||||||
let Some(activity_id) = activity.get("id").and_then(Value::as_str) else {
|
let Some(activity_id) =
|
||||||
|
activity.get("id").and_then(Value::as_str)
|
||||||
|
else {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
|
|
||||||
results.push(json!({
|
results.push(json!({
|
||||||
|
|
@ -339,9 +352,6 @@ async fn ensure_dev_athlete(
|
||||||
/*
|
/*
|
||||||
* If this athlete already exists, update the development
|
* If this athlete already exists, update the development
|
||||||
* credential.
|
* credential.
|
||||||
*
|
|
||||||
* This is particularly useful if the row was originally
|
|
||||||
* created by an earlier development sync with an empty token.
|
|
||||||
*/
|
*/
|
||||||
let existing = sqlx::query(
|
let existing = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -401,11 +411,18 @@ async fn ensure_dev_athlete(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Process one activity.
|
||||||
|
///
|
||||||
|
/// The API credential is retrieved from the local athlete record.
|
||||||
|
/// This is important for development sync because the API key
|
||||||
|
/// supplied to /dev/sync/{api_key} is stored in access_token.
|
||||||
pub async fn process_activity(
|
pub async fn process_activity(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
intervals_athlete_id: String,
|
intervals_athlete_id: String,
|
||||||
activity_id: String,
|
activity_id: String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
let athlete = sqlx::query(
|
let athlete = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -419,8 +436,6 @@ pub async fn process_activity(
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
use sqlx::Row;
|
|
||||||
|
|
||||||
let Some(athlete) = athlete else {
|
let Some(athlete) = athlete else {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"no local athlete for Intervals.icu athlete {}",
|
"no local athlete for Intervals.icu athlete {}",
|
||||||
|
|
@ -430,14 +445,21 @@ pub async fn process_activity(
|
||||||
|
|
||||||
let athlete_db_id: i64 = athlete.try_get("id")?;
|
let athlete_db_id: i64 = athlete.try_get("id")?;
|
||||||
|
|
||||||
|
let access_token: String = athlete
|
||||||
|
.try_get("access_token")
|
||||||
|
.context("local athlete has no access token")?;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The normal activity-processing path uses the configured
|
* IMPORTANT:
|
||||||
* Intervals client.
|
|
||||||
*
|
*
|
||||||
* The development endpoint has already ensured that the
|
* Use the athlete's stored credential rather than creating
|
||||||
* athlete exists locally and that its credential is stored.
|
* IntervalsClient::new(), which would use only the globally
|
||||||
|
* configured INTERVALS_API_KEY.
|
||||||
*/
|
*/
|
||||||
let client = IntervalsClient::new(state.config.clone());
|
let client = IntervalsClient::with_api_key(
|
||||||
|
state.config.clone(),
|
||||||
|
access_token,
|
||||||
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Fetch activity metadata.
|
* Fetch activity metadata.
|
||||||
|
|
@ -445,19 +467,27 @@ pub async fn process_activity(
|
||||||
let activity = client
|
let activity = client
|
||||||
.activity(&activity_id)
|
.activity(&activity_id)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("cannot fetch Intervals.icu activity {}", activity_id))?;
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot fetch Intervals.icu activity {}",
|
||||||
|
activity_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let start_time = IntervalsClient::activity_start(&activity)?;
|
let start_time = IntervalsClient::activity_start(&activity)?;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Fetch GPS/time streams.
|
* Fetch GPS/time streams.
|
||||||
*/
|
*/
|
||||||
let streams = client.streams(&activity_id).await.with_context(|| {
|
let streams = client
|
||||||
format!(
|
.streams(&activity_id)
|
||||||
"cannot fetch streams for Intervals.icu activity {}",
|
.await
|
||||||
activity_id
|
.with_context(|| {
|
||||||
)
|
format!(
|
||||||
})?;
|
"cannot fetch streams for Intervals.icu activity {}",
|
||||||
|
activity_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let points = parse_track(&streams, start_time)?;
|
let points = parse_track(&streams, start_time)?;
|
||||||
|
|
||||||
|
|
@ -485,19 +515,46 @@ pub async fn process_activity(
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
let activity_db_id =
|
let activity_db_id =
|
||||||
db::ensure_activity(&mut tx, athlete_db_id, &activity_id, start_time, &activity).await?;
|
db::ensure_activity(
|
||||||
|
&mut tx,
|
||||||
|
athlete_db_id,
|
||||||
|
&activity_id,
|
||||||
|
start_time,
|
||||||
|
&activity,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
db::delete_activity_crossings(&mut tx, activity_db_id).await?;
|
db::delete_activity_crossings(
|
||||||
|
&mut tx,
|
||||||
|
activity_db_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut crossing_count = 0usize;
|
let mut crossing_count = 0usize;
|
||||||
|
|
||||||
for pair in points.windows(2) {
|
for pair in points.windows(2) {
|
||||||
let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?;
|
let crossings =
|
||||||
|
geo::crossings_between(
|
||||||
|
&state.db,
|
||||||
|
&pair[0],
|
||||||
|
&pair[1],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
for crossing in crossings {
|
for crossing in crossings {
|
||||||
let interval = IntervalsClient::matching_interval(&activity, crossing.track_index);
|
let interval =
|
||||||
|
IntervalsClient::matching_interval(
|
||||||
|
&activity,
|
||||||
|
crossing.track_index,
|
||||||
|
);
|
||||||
|
|
||||||
db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?;
|
db::save_crossing(
|
||||||
|
&mut tx,
|
||||||
|
activity_db_id,
|
||||||
|
&crossing,
|
||||||
|
interval.as_ref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
crossing_count += 1;
|
crossing_count += 1;
|
||||||
|
|
||||||
|
|
@ -512,12 +569,17 @@ pub async fn process_activity(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db::mark_activity_processed(&mut tx, activity_db_id).await?;
|
db::mark_activity_processed(
|
||||||
|
&mut tx,
|
||||||
|
activity_db_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
activity_id = %activity_id,
|
activity_id = %activity_id,
|
||||||
|
points = points.len(),
|
||||||
crossings = crossing_count,
|
crossings = crossing_count,
|
||||||
"activity processed"
|
"activity processed"
|
||||||
);
|
);
|
||||||
|
|
@ -525,14 +587,65 @@ pub async fn process_activity(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPoint>> {
|
/// Parse Intervals.icu GPS streams.
|
||||||
let times = IntervalsClient::stream_values(streams, "time")
|
///
|
||||||
.ok_or_else(|| anyhow!("Intervals.icu response has no time stream"))?;
|
/// Intervals.icu returns the streams as an array similar to:
|
||||||
|
///
|
||||||
|
/// [
|
||||||
|
/// {
|
||||||
|
/// "type": "time",
|
||||||
|
/// "data": [0, 1, 2, 3]
|
||||||
|
/// },
|
||||||
|
/// {
|
||||||
|
/// "type": "latlng",
|
||||||
|
/// "data": [51.5, 51.5001, 51.5002, 51.5003],
|
||||||
|
/// "data2": [-0.1, -0.1001, -0.1002, -0.1003]
|
||||||
|
/// }
|
||||||
|
/// ]
|
||||||
|
///
|
||||||
|
/// IMPORTANT:
|
||||||
|
///
|
||||||
|
/// `latlng.data` is latitude.
|
||||||
|
///
|
||||||
|
/// `latlng.data2` is longitude.
|
||||||
|
///
|
||||||
|
/// It is NOT:
|
||||||
|
///
|
||||||
|
/// `latlng.data = [[lat, lon], [lat, lon], ...]`
|
||||||
|
fn parse_track(
|
||||||
|
streams: &Value,
|
||||||
|
start_time: DateTime<Utc>,
|
||||||
|
) -> Result<Vec<TrackPoint>> {
|
||||||
|
let times = IntervalsClient::stream_values(
|
||||||
|
streams,
|
||||||
|
"time",
|
||||||
|
)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"Intervals.icu response has no time stream"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let latlng = IntervalsClient::stream_values(streams, "latlng")
|
let (latitudes, longitudes) =
|
||||||
.ok_or_else(|| anyhow!("Intervals.icu response has no latlng stream"))?;
|
IntervalsClient::latlng_values(streams)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"Intervals.icu response has no usable latlng stream"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let length = times.len().min(latlng.len());
|
let length = times
|
||||||
|
.len()
|
||||||
|
.min(latitudes.len())
|
||||||
|
.min(longitudes.len());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
time_points = times.len(),
|
||||||
|
latitude_points = latitudes.len(),
|
||||||
|
longitude_points = longitudes.len(),
|
||||||
|
usable_points = length,
|
||||||
|
"parsing Intervals.icu GPS streams"
|
||||||
|
);
|
||||||
|
|
||||||
let mut result = Vec::with_capacity(length);
|
let mut result = Vec::with_capacity(length);
|
||||||
|
|
||||||
|
|
@ -541,26 +654,25 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(coordinate) = latlng[index].as_array() else {
|
let Some(lat) = latitudes[index].as_f64() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if coordinate.len() != 2 {
|
let Some(lon) = longitudes[index].as_f64() else {
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
let Some(lat) = coordinate[0].as_f64() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(lon) = coordinate[1].as_f64() else {
|
|
||||||
continue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if !lat.is_finite() || !lon.is_finite() || !seconds.is_finite() {
|
if !seconds.is_finite()
|
||||||
|
|| !lat.is_finite()
|
||||||
|
|| !lon.is_finite()
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Intervals.icu time values are seconds from the
|
||||||
|
* beginning of the activity.
|
||||||
|
*/
|
||||||
let millis = (seconds * 1000.0).round() as i64;
|
let millis = (seconds * 1000.0).round() as i64;
|
||||||
|
|
||||||
result.push(TrackPoint {
|
result.push(TrackPoint {
|
||||||
|
|
@ -571,6 +683,11 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
points = result.len(),
|
||||||
|
"finished parsing Intervals.icu GPS stream"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,22 +700,89 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn interpolation_is_millisecond_precise() {
|
fn interpolation_is_millisecond_precise() {
|
||||||
let a = Utc.timestamp_millis_opt(1000).unwrap();
|
let a = Utc.timestamp_millis_opt(1000).unwrap();
|
||||||
|
|
||||||
let b = Utc.timestamp_millis_opt(2000).unwrap();
|
let b = Utc.timestamp_millis_opt(2000).unwrap();
|
||||||
|
|
||||||
let result = interpolate_time(a, b, 0.376);
|
let result = interpolate_time(a, b, 0.376);
|
||||||
|
|
||||||
assert_eq!(result.timestamp_millis(), 1376);
|
assert_eq!(
|
||||||
|
result.timestamp_millis(),
|
||||||
|
1376
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpolation_rounds_to_nearest_ms() {
|
fn interpolation_rounds_to_nearest_ms() {
|
||||||
let a = Utc.timestamp_millis_opt(1000).unwrap();
|
let a = Utc.timestamp_millis_opt(1000).unwrap();
|
||||||
|
|
||||||
let b = Utc.timestamp_millis_opt(2000).unwrap();
|
let b = Utc.timestamp_millis_opt(2000).unwrap();
|
||||||
|
|
||||||
let result = interpolate_time(a, b, 0.3764);
|
let result = interpolate_time(a, b, 0.3764);
|
||||||
|
|
||||||
assert_eq!(result.timestamp_millis(), 1376);
|
assert_eq!(
|
||||||
|
result.timestamp_millis(),
|
||||||
|
1376
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_intervals_latlng_data_and_data2() {
|
||||||
|
let streams = serde_json::json!([
|
||||||
|
{
|
||||||
|
"type": "time",
|
||||||
|
"data": [0, 1, 2, 3]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "latlng",
|
||||||
|
"data": [
|
||||||
|
51.5000,
|
||||||
|
51.5001,
|
||||||
|
51.5002,
|
||||||
|
51.5003
|
||||||
|
],
|
||||||
|
"data2": [
|
||||||
|
-0.1000,
|
||||||
|
-0.1001,
|
||||||
|
-0.1002,
|
||||||
|
-0.1003
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let start_time = Utc
|
||||||
|
.timestamp_opt(1_000_000, 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let points =
|
||||||
|
super::parse_track(
|
||||||
|
&streams,
|
||||||
|
start_time,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(points.len(), 4);
|
||||||
|
|
||||||
|
assert_eq!(points[0].index, 0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(points[0].lat - 51.5000).abs() < 0.000001
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(points[0].lon - (-0.1000)).abs() < 0.000001
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(points[1].index, 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(points[1].lat - 51.5001).abs() < 0.000001
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(points[1].lon - (-0.1001)).abs() < 0.000001
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
points[1].time.timestamp_millis(),
|
||||||
|
start_time.timestamp_millis() + 1000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue