Working on fixing metars, airport layout, etc
This commit is contained in:
@@ -76,3 +76,9 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_airport_favorites (
|
||||||
|
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (username, icao)
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ use std::future::Future;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use super::{SESSION_COOKIE_NAME, Session};
|
use super::{SESSION_COOKIE_NAME, Session};
|
||||||
use crate::{error::Error, users::User};
|
use crate::error::Error;
|
||||||
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http};
|
use actix_web::{Error as ActixError, FromRequest, HttpRequest, dev::Payload, http};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::account::user::User;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Auth {
|
pub struct Auth {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use argon2::{
|
use argon2::{
|
||||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHash, PasswordHasher,
|
||||||
password_hash::{SaltString, rand_core::OsRng},
|
PasswordVerifier,
|
||||||
};
|
};
|
||||||
use rand::distr::Alphanumeric;
|
use rand::distr::Alphanumeric;
|
||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
@@ -11,10 +11,13 @@ mod email_token;
|
|||||||
mod model;
|
mod model;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod user;
|
||||||
|
mod user_favorites;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use routes::init_routes;
|
pub use routes::init_routes;
|
||||||
pub use session::*;
|
pub use session::*;
|
||||||
|
pub use user::*;
|
||||||
|
|
||||||
use crate::error::{ApiResult, Error};
|
use crate::error::{ApiResult, Error};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
account::{SESSION_COOKIE_NAME, Session, verify_hash},
|
account::{SESSION_COOKIE_NAME, Session, verify_hash},
|
||||||
error::Error,
|
error::Error,
|
||||||
users::{LoginRequest, RegisterRequest, User, UserResponse},
|
|
||||||
};
|
};
|
||||||
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
|
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web, delete};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use utoipa_actix_web::scope;
|
use utoipa_actix_web::scope;
|
||||||
@@ -11,7 +10,8 @@ use utoipa_actix_web::service_config::ServiceConfig;
|
|||||||
|
|
||||||
use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email};
|
use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email};
|
||||||
use crate::account::{Auth, csprng};
|
use crate::account::{Auth, csprng};
|
||||||
use crate::users::UpdateUser;
|
use crate::account::user::{LoginRequest, RegisterRequest, UpdateUser, User, UserResponse};
|
||||||
|
use crate::account::user_favorites::UserFavorite;
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
tag = "account",
|
tag = "account",
|
||||||
@@ -168,7 +168,7 @@ async fn resend_email_verification(req: HttpRequest, auth: Auth) -> HttpResponse
|
|||||||
None => return HttpResponse::Unauthorized().finish(),
|
None => return HttpResponse::Unauthorized().finish(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cannot reverify if user is already verified
|
// Cannot reverify if the user is already verified
|
||||||
if user.email_verified {
|
if user.email_verified {
|
||||||
return HttpResponse::Conflict().finish();
|
return HttpResponse::Conflict().finish();
|
||||||
}
|
}
|
||||||
@@ -547,6 +547,63 @@ async fn confirm_password_reset(
|
|||||||
HttpResponse::Ok().finish()
|
HttpResponse::Ok().finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
tag = "account",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Successful Response"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("session_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[get("/profile/favorites")]
|
||||||
|
async fn get_favorites(auth: Auth) -> HttpResponse {
|
||||||
|
let username = auth.user.username;
|
||||||
|
match UserFavorite::select_all(&username).await {
|
||||||
|
Ok(favorites) => HttpResponse::Ok().json(favorites),
|
||||||
|
Err(err) => ResponseError::error_response(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
tag = "account",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Successful Response"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("session_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[post("/profile/favorites/{icao}")]
|
||||||
|
async fn add_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
||||||
|
let username = auth.user.username;
|
||||||
|
match UserFavorite::insert(&username, &icao.into_inner()).await {
|
||||||
|
Ok(_) => HttpResponse::Ok().finish(),
|
||||||
|
Err(err) => ResponseError::error_response(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
tag = "account",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Successful Response"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("session_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#[delete("/profile/favorites/{icao}")]
|
||||||
|
async fn remove_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
||||||
|
let username = auth.user.username;
|
||||||
|
match UserFavorite::delete(&username, &icao.into_inner()).await {
|
||||||
|
Ok(_) => HttpResponse::Ok().finish(),
|
||||||
|
Err(err) => ResponseError::error_response(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init_routes(config: &mut ServiceConfig) {
|
pub fn init_routes(config: &mut ServiceConfig) {
|
||||||
config.service(
|
config.service(
|
||||||
scope::scope("/account")
|
scope::scope("/account")
|
||||||
@@ -559,6 +616,9 @@ pub fn init_routes(config: &mut ServiceConfig) {
|
|||||||
.service(session_refresh)
|
.service(session_refresh)
|
||||||
.service(change_password)
|
.service(change_password)
|
||||||
.service(reset_password)
|
.service(reset_password)
|
||||||
.service(confirm_password_reset),
|
.service(confirm_password_reset)
|
||||||
|
.service(get_favorites)
|
||||||
|
.service(add_favorite)
|
||||||
|
.service(remove_favorite),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
67
api/src/account/user_favorites.rs
Normal file
67
api/src/account/user_favorites.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use crate::db;
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
|
||||||
|
const TABLE_NAME: &str = "user_airport_favorites";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct UserFavorite {
|
||||||
|
pub username: String,
|
||||||
|
pub icao: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserFavorite {
|
||||||
|
pub async fn select_all(username: &str) -> ApiResult<Vec<String>> {
|
||||||
|
let pool = db::pool();
|
||||||
|
let user_favorites: Vec<UserFavorite> = sqlx::query_as::<_, UserFavorite>(&format!(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM {} WHERE username = $1
|
||||||
|
"#,
|
||||||
|
TABLE_NAME
|
||||||
|
))
|
||||||
|
.bind(username)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let favorites = user_favorites
|
||||||
|
.iter()
|
||||||
|
.map(|uf| uf.icao.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(favorites)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(username: &str, icao: &str) -> ApiResult<()> {
|
||||||
|
let pool = db::pool();
|
||||||
|
sqlx::query(&format!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO {} (
|
||||||
|
username, icao
|
||||||
|
) VALUES ($1, $2)
|
||||||
|
"#,
|
||||||
|
TABLE_NAME
|
||||||
|
))
|
||||||
|
.bind(username)
|
||||||
|
.bind(icao)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(username: &str, icao: &str) -> ApiResult<()> {
|
||||||
|
let pool = db::pool();
|
||||||
|
sqlx::query(&format!(
|
||||||
|
r#"
|
||||||
|
DELETE FROM {} WHERE username = $1 AND icao = $2
|
||||||
|
"#,
|
||||||
|
TABLE_NAME
|
||||||
|
))
|
||||||
|
.bind(username)
|
||||||
|
.bind(icao)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use crate::metars::Metar;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use futures_util::try_join;
|
use futures_util::try_join;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Postgres, QueryBuilder};
|
use sqlx::{Execute, Postgres, QueryBuilder};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
@@ -263,7 +263,7 @@ impl Airport {
|
|||||||
"SELECT {} FROM {} WHERE icao = $1",
|
"SELECT {} FROM {} WHERE icao = $1",
|
||||||
DEFAULT_COLUMNS, TABLE_NAME
|
DEFAULT_COLUMNS, TABLE_NAME
|
||||||
))
|
))
|
||||||
.bind(icao)
|
.bind(icao.to_uppercase())
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -340,8 +340,16 @@ impl Airport {
|
|||||||
QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
|
QueryBuilder::<Postgres>::new(format!("SELECT {} FROM {}", DEFAULT_COLUMNS, TABLE_NAME));
|
||||||
|
|
||||||
let mut has_where = false;
|
let mut has_where = false;
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "icao", &query.icaos);
|
let icaos = match &query.icaos {
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "iata", &query.iatas);
|
Some(icaos) => Some(icaos.to_uppercase()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Self::push_condition_array(&mut builder, &mut has_where, "icao", &icaos);
|
||||||
|
let iatas = match &query.iatas {
|
||||||
|
Some(iatas) => Some(iatas.to_uppercase()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Self::push_condition_array(&mut builder, &mut has_where, "iata", &iatas);
|
||||||
Self::push_condition_array(
|
Self::push_condition_array(
|
||||||
&mut builder,
|
&mut builder,
|
||||||
&mut has_where,
|
&mut has_where,
|
||||||
@@ -360,7 +368,11 @@ impl Airport {
|
|||||||
"municipality",
|
"municipality",
|
||||||
&query.municipalities,
|
&query.municipalities,
|
||||||
);
|
);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "local", &query.locals);
|
let locals = match &query.locals {
|
||||||
|
Some(locals) => Some(locals.to_uppercase()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Self::push_condition_array(&mut builder, &mut has_where, "local", &locals);
|
||||||
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
Self::push_condition_array(&mut builder, &mut has_where, "category", &query.categories);
|
||||||
Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name);
|
Self::push_condition_like(&mut builder, &mut has_where, "name", &query.name);
|
||||||
Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?;
|
Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?;
|
||||||
@@ -395,7 +407,7 @@ impl Airport {
|
|||||||
return Ok(airports);
|
return Ok(airports);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk update airport sub-fields
|
// Bulk update airport subfields
|
||||||
let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
|
let icaos: Vec<String> = airports.iter().map(|a| a.icao.to_uppercase()).collect();
|
||||||
|
|
||||||
let runway_future = Runway::select_all_map(&icaos);
|
let runway_future = Runway::select_all_map(&icaos);
|
||||||
@@ -550,9 +562,9 @@ impl Airport {
|
|||||||
|
|
||||||
for chunk in airport_rows.chunks(chunk_size) {
|
for chunk in airport_rows.chunks(chunk_size) {
|
||||||
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
|
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
|
||||||
"INSERT INTO airports (icao, iata, local, name, category, \
|
format!("INSERT INTO {} (icao, iata, local, name, category, \
|
||||||
iso_country, iso_region, municipality, elevation_ft, \
|
iso_country, iso_region, municipality, elevation_ft, \
|
||||||
longitude, latitude, geometry, has_tower, has_beacon, public) ",
|
longitude, latitude, geometry, has_tower, has_beacon, public) ", TABLE_NAME),
|
||||||
);
|
);
|
||||||
query_builder.push_values(chunk, |mut b, row| {
|
query_builder.push_values(chunk, |mut b, row| {
|
||||||
b.push_bind(&row.icao)
|
b.push_bind(&row.icao)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use futures_util::stream::StreamExt as _;
|
use futures_util::stream::StreamExt as _;
|
||||||
|
|
||||||
use crate::airports::{AirportQuery, UpdateAirport};
|
use crate::airports::{AirportQuery, UpdateAirport};
|
||||||
use crate::users::ADMIN_ROLE;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
account::{Auth, verify_role},
|
account::{Auth, verify_role},
|
||||||
airports::Airport,
|
airports::Airport,
|
||||||
@@ -12,6 +11,7 @@ use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use utoipa_actix_web::scope;
|
use utoipa_actix_web::scope;
|
||||||
use utoipa_actix_web::service_config::ServiceConfig;
|
use utoipa_actix_web::service_config::ServiceConfig;
|
||||||
|
use crate::account::ADMIN_ROLE;
|
||||||
|
|
||||||
#[derive(ToSchema)]
|
#[derive(ToSchema)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
|
|||||||
@@ -64,25 +64,25 @@ impl ResponseError for Error {
|
|||||||
|
|
||||||
impl From<std::io::Error> for Error {
|
impl From<std::io::Error> for Error {
|
||||||
fn from(error: std::io::Error) -> Self {
|
fn from(error: std::io::Error) -> Self {
|
||||||
Self::new(500, format!("Unknown IO error: {}", error))
|
Self::new(500, format!("Unknown IO error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<chrono::ParseError> for Error {
|
impl From<chrono::ParseError> for Error {
|
||||||
fn from(error: chrono::ParseError) -> Self {
|
fn from(error: chrono::ParseError) -> Self {
|
||||||
Self::new(500, format!("Parse error: {}", error))
|
Self::new(500, format!("Chrono parse error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<core::num::ParseIntError> for Error {
|
impl From<core::num::ParseIntError> for Error {
|
||||||
fn from(error: core::num::ParseIntError) -> Self {
|
fn from(error: core::num::ParseIntError) -> Self {
|
||||||
Self::new(500, format!("Parse error: {}", error))
|
Self::new(500, format!("Integer parse error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<core::num::ParseFloatError> for Error {
|
impl From<core::num::ParseFloatError> for Error {
|
||||||
fn from(error: core::num::ParseFloatError) -> Self {
|
fn from(error: core::num::ParseFloatError) -> Self {
|
||||||
Self::new(500, format!("Parse error: {}", error))
|
Self::new(500, format!("Float parse error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ impl From<std::env::VarError> for Error {
|
|||||||
fn from(error: std::env::VarError) -> Self {
|
fn from(error: std::env::VarError) -> Self {
|
||||||
Self::new(
|
Self::new(
|
||||||
500,
|
500,
|
||||||
format!("Unknown environment variable error: {}", error),
|
format!("Unknown environment variable error: {:?}", error),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,9 +100,9 @@ impl From<reqwest::Error> for Error {
|
|||||||
match error.status() {
|
match error.status() {
|
||||||
Some(status_code) => {
|
Some(status_code) => {
|
||||||
if status_code.is_client_error() {
|
if status_code.is_client_error() {
|
||||||
Self::new(500, format!("Client reqwest error: {}", error))
|
Self::new(500, format!("Client reqwest error: {:?}", error))
|
||||||
} else if status_code.is_server_error() {
|
} else if status_code.is_server_error() {
|
||||||
Self::new(500, format!("Server reqwest error: {}", error))
|
Self::new(500, format!("Server reqwest error: {:?}", error))
|
||||||
} else {
|
} else {
|
||||||
Self::new(500, format!("Unknown reqwest error: {:?}", error))
|
Self::new(500, format!("Unknown reqwest error: {:?}", error))
|
||||||
}
|
}
|
||||||
@@ -114,19 +114,19 @@ impl From<reqwest::Error> for Error {
|
|||||||
|
|
||||||
impl From<serde_json::Error> for Error {
|
impl From<serde_json::Error> for Error {
|
||||||
fn from(error: serde_json::Error) -> Self {
|
fn from(error: serde_json::Error) -> Self {
|
||||||
Self::new(500, format!("Unknown serde_json error: {}", error))
|
Self::new(500, format!("Unknown serde_json error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<argon2::password_hash::Error> for Error {
|
impl From<argon2::password_hash::Error> for Error {
|
||||||
fn from(error: argon2::password_hash::Error) -> Self {
|
fn from(error: argon2::password_hash::Error) -> Self {
|
||||||
Self::new(500, format!("Unknown argon2 error: {}", error))
|
Self::new(500, format!("Unknown argon2 error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<redis::RedisError> for Error {
|
impl From<redis::RedisError> for Error {
|
||||||
fn from(error: redis::RedisError) -> Self {
|
fn from(error: redis::RedisError) -> Self {
|
||||||
Self::new(500, format!("Unknown redis error: {}", error))
|
Self::new(500, format!("Unknown redis error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,18 +134,18 @@ impl From<s3::error::S3Error> for Error {
|
|||||||
fn from(error: s3::error::S3Error) -> Self {
|
fn from(error: s3::error::S3Error) -> Self {
|
||||||
match error {
|
match error {
|
||||||
s3::error::S3Error::Credentials(err) => {
|
s3::error::S3Error::Credentials(err) => {
|
||||||
Self::new(500, format!("Unknown s3 credentials error: {}", err))
|
Self::new(500, format!("Unknown s3 credentials error: {:?}", err))
|
||||||
}
|
}
|
||||||
s3::error::S3Error::FromUtf8(err) => {
|
s3::error::S3Error::FromUtf8(err) => {
|
||||||
Self::new(500, format!("Unknown s3 from utf8 error: {}", err))
|
Self::new(500, format!("Unknown s3 from utf8 error: {:?}", err))
|
||||||
}
|
}
|
||||||
s3::error::S3Error::FmtError(err) => Self::new(500, format!("Unknown s3 fmt error: {}", err)),
|
s3::error::S3Error::FmtError(err) => Self::new(500, format!("Unknown s3 fmt error: {:?}", err)),
|
||||||
s3::error::S3Error::HeaderToStr(err) => {
|
s3::error::S3Error::HeaderToStr(err) => {
|
||||||
Self::new(500, format!("Unknown s3 header to str error: {}", err))
|
Self::new(500, format!("Unknown s3 header to str error: {:?}", err))
|
||||||
}
|
}
|
||||||
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
|
s3::error::S3Error::HmacInvalidLength(err) => Self::new(
|
||||||
500,
|
500,
|
||||||
format!("Unknown s3 hmac invalid length error: {}", err),
|
format!("Unknown s3 hmac invalid length error: {:?}", err),
|
||||||
),
|
),
|
||||||
s3::error::S3Error::Http(error) => Self::new(error.status_code().as_u16(), error.to_string()),
|
s3::error::S3Error::Http(error) => Self::new(error.status_code().as_u16(), error.to_string()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -158,7 +158,7 @@ impl From<s3::error::S3Error> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::new(500, format!("Unknown s3 error: {}", error))
|
Self::new(500, format!("Unknown s3 error: {:?}", error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,3 +228,9 @@ impl From<String> for Error {
|
|||||||
Self::new(500, error)
|
Self::new(500, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<regex::Error> for Error {
|
||||||
|
fn from(error: regex::Error) -> Self {
|
||||||
|
Self::new(500, error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::account::hash;
|
use crate::account::{hash, ADMIN_ROLE};
|
||||||
use crate::http_client::HttpClient;
|
use crate::http_client::HttpClient;
|
||||||
use crate::users::{ADMIN_ROLE, User};
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_web::{App, HttpServer, middleware::Logger, web};
|
use actix_web::{App, HttpServer, middleware::Logger, web};
|
||||||
use dotenv::from_filename;
|
use dotenv::from_filename;
|
||||||
@@ -10,6 +9,7 @@ use utoipa::openapi::SecurityRequirement;
|
|||||||
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
|
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
|
||||||
use utoipa_actix_web::{AppExt, scope};
|
use utoipa_actix_web::{AppExt, scope};
|
||||||
use utoipa_swagger_ui::{Config, SwaggerUi};
|
use utoipa_swagger_ui::{Config, SwaggerUi};
|
||||||
|
use crate::account::User;
|
||||||
|
|
||||||
mod account;
|
mod account;
|
||||||
mod airports;
|
mod airports;
|
||||||
@@ -20,7 +20,6 @@ mod metars;
|
|||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod smtp;
|
mod smtp;
|
||||||
mod system;
|
mod system;
|
||||||
mod users;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
@@ -31,15 +30,7 @@ struct AppState {
|
|||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
initialize_environment()?;
|
initialize_environment()?;
|
||||||
db::initialize().await?;
|
db::initialize().await?;
|
||||||
|
scheduler::run();
|
||||||
let client = Arc::new(HttpClient::default()?);
|
|
||||||
|
|
||||||
let scheduler_client = client.clone();
|
|
||||||
let interval = env::var("METAR_INTERVAL")
|
|
||||||
.unwrap_or("300".to_string())
|
|
||||||
.parse::<u64>()
|
|
||||||
.unwrap_or(300);
|
|
||||||
scheduler::update_metars(scheduler_client, interval);
|
|
||||||
|
|
||||||
// Initialize admin user
|
// Initialize admin user
|
||||||
let admin_username = env::var("ADMIN_USERNAME");
|
let admin_username = env::var("ADMIN_USERNAME");
|
||||||
@@ -78,6 +69,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let client = Arc::new(HttpClient::default()?);
|
||||||
let state = AppState { client };
|
let state = AppState { client };
|
||||||
let host = "0.0.0.0";
|
let host = "0.0.0.0";
|
||||||
let port = env::var("API_PORT").unwrap_or("5000".to_string());
|
let port = env::var("API_PORT").unwrap_or("5000".to_string());
|
||||||
@@ -99,7 +91,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.configure(airports::init_routes)
|
.configure(airports::init_routes)
|
||||||
.configure(metars::init_routes)
|
.configure(metars::init_routes)
|
||||||
.configure(account::init_routes)
|
.configure(account::init_routes)
|
||||||
.configure(users::init_routes)
|
|
||||||
.configure(system::init_routes),
|
.configure(system::init_routes),
|
||||||
)
|
)
|
||||||
.split_for_parts();
|
.split_for_parts();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod metar_check;
|
mod metar_check;
|
||||||
mod model;
|
mod model;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
pub use metar_check::*;
|
pub use metar_check::*;
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ use crate::airports::{Airport, UpdateAirport};
|
|||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::http_client::HttpClient;
|
use crate::http_client::HttpClient;
|
||||||
use crate::metars::MetarCheck;
|
use crate::metars::MetarCheck;
|
||||||
|
use crate::metars::utils::parse_metar_time;
|
||||||
use crate::{db, error::ApiResult};
|
use crate::{db, error::ApiResult};
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use reqwest::header::ETAG;
|
use reqwest::header::ETAG;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -13,6 +14,7 @@ use std::fmt::Display;
|
|||||||
use std::io::{Cursor, Read};
|
use std::io::{Cursor, Read};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
use sqlx::{Postgres, QueryBuilder};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
|
static TIME_OFFSET: OnceLock<i64> = OnceLock::new();
|
||||||
@@ -302,6 +304,39 @@ impl MetarRow {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn insert_all(metars: Vec<Metar>) -> ApiResult<()> {
|
||||||
|
let pool = db::pool();
|
||||||
|
let chunk_size = 1000;
|
||||||
|
|
||||||
|
for chunk in metars.chunks(chunk_size) {
|
||||||
|
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
|
||||||
|
format!("INSERT INTO {} (icao, observation_time, raw_text, data) ", TABLE_NAME));
|
||||||
|
query_builder.push_values(chunk, |mut b, metar | {
|
||||||
|
let row: Self = match metar.to_row() {
|
||||||
|
Ok(row) => row,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to serialize METAR data: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
b.push_bind(row.icao)
|
||||||
|
.push_bind(row.observation_time)
|
||||||
|
.push_bind(row.raw_text)
|
||||||
|
.push_bind(row.data);
|
||||||
|
});
|
||||||
|
query_builder.push(
|
||||||
|
" ON CONFLICT (icao, observation_time) DO UPDATE SET \
|
||||||
|
raw_text = EXCLUDED.raw_text, \
|
||||||
|
data = EXCLUDED.data",
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = query_builder.build();
|
||||||
|
query.execute(pool).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metar {
|
impl Metar {
|
||||||
@@ -342,7 +377,7 @@ impl Metar {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove METAR at start of text
|
// Remove METAR at the start of the text
|
||||||
if metar_parts[0].to_string() == "METAR".to_string() {
|
if metar_parts[0].to_string() == "METAR".to_string() {
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
@@ -354,10 +389,18 @@ impl Metar {
|
|||||||
// Date/Time
|
// Date/Time
|
||||||
let observation_time = metar_parts[0];
|
let observation_time = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
let observation_time = Self::parse_time(observation_time)?;
|
match parse_metar_time(observation_time) {
|
||||||
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
|
Ok(observation_time) => {
|
||||||
Ok(datetime) => datetime.with_timezone(&Utc),
|
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
|
||||||
Err(err) => return Err(err.into()),
|
Ok(datetime) => datetime.with_timezone(&Utc),
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
return Err(Error::new(
|
||||||
|
err.status,
|
||||||
|
format!("Unexpected observation time field '{}': {}; {}", observation_time, metar_string, err)));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -375,9 +418,8 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wind Direction and Speed
|
// Wind Direction and Speed
|
||||||
let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}(?:KT|MPS)$").unwrap();
|
let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}(?:KT|MPS)$")?;
|
||||||
let wind_gust_re =
|
let wind_gust_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}(?:KT|MPS)$")?;
|
||||||
regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}(?:KT|MPS)$").unwrap();
|
|
||||||
// Handle input error where there is a space between the numbers and units
|
// Handle input error where there is a space between the numbers and units
|
||||||
let mut value: Option<String> = None;
|
let mut value: Option<String> = None;
|
||||||
if metar_parts.len() >= 2
|
if metar_parts.len() >= 2
|
||||||
@@ -411,9 +453,9 @@ impl Metar {
|
|||||||
let mut wind_speed_kt = wind[3..5].to_string();
|
let mut wind_speed_kt = wind[3..5].to_string();
|
||||||
// Convert m/s to kt
|
// Convert m/s to kt
|
||||||
if wind.len() == 8 {
|
if wind.len() == 8 {
|
||||||
wind_speed_kt = (wind_speed_kt.parse::<f64>().unwrap() * 1.94384).to_string();
|
wind_speed_kt = (wind_speed_kt.parse::<f64>()? * 1.94384).to_string();
|
||||||
}
|
}
|
||||||
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>().unwrap());
|
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>()?);
|
||||||
} else if wind_gust_re.is_match(&wind) {
|
} else if wind_gust_re.is_match(&wind) {
|
||||||
let wind_dir_degrees = &wind[0..3];
|
let wind_dir_degrees = &wind[0..3];
|
||||||
metar.wind_dir_degrees = Some(wind_dir_degrees.to_string());
|
metar.wind_dir_degrees = Some(wind_dir_degrees.to_string());
|
||||||
@@ -421,26 +463,26 @@ impl Metar {
|
|||||||
let mut wind_gust_kt = wind[6..8].to_string();
|
let mut wind_gust_kt = wind[6..8].to_string();
|
||||||
// Convert m/s to kt
|
// Convert m/s to kt
|
||||||
if wind.len() == 9 {
|
if wind.len() == 9 {
|
||||||
wind_speed_kt = (wind_speed_kt.parse::<f64>().unwrap() * 1.94384).to_string();
|
wind_speed_kt = (wind_speed_kt.parse::<f64>()? * 1.94384).to_string();
|
||||||
wind_gust_kt = (wind_gust_kt.parse::<f64>().unwrap() * 1.94384).to_string();
|
wind_gust_kt = (wind_gust_kt.parse::<f64>()? * 1.94384).to_string();
|
||||||
}
|
}
|
||||||
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>().unwrap());
|
metar.wind_speed_kt = Some(wind_speed_kt.parse::<f64>()?);
|
||||||
metar.wind_gust_kt = Some(wind_gust_kt.parse::<f64>().unwrap());
|
metar.wind_gust_kt = Some(wind_gust_kt.parse::<f64>()?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variable Wind Direction
|
// Variable Wind Direction
|
||||||
let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$").unwrap();
|
let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$")?;
|
||||||
if !metar_parts.is_empty() && variable_wind_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && variable_wind_re.is_match(metar_parts[0]) {
|
||||||
metar.variable_wind_dir_degrees = Some(metar_parts[0].to_string());
|
metar.variable_wind_dir_degrees = Some(metar_parts[0].to_string());
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visibility
|
// Visibility
|
||||||
let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM$").unwrap();
|
let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM$")?;
|
||||||
let visibility_re_m = regex::Regex::new(r"^[0-9]{4}(:?N|NE|NW|S|SE|SW)?$").unwrap();
|
let visibility_re_m = regex::Regex::new(r"^[0-9]{4}(:?N|NE|NW|S|SE|SW)?$")?;
|
||||||
if !metar_parts.is_empty() && visibility_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && visibility_re.is_match(metar_parts[0]) {
|
||||||
let visibility_str = &metar_parts[0][0..metar_parts[0].len() - 2];
|
let visibility_str = &metar_parts[0][0..metar_parts[0].len() - 2];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -474,59 +516,68 @@ impl Metar {
|
|||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
|
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
let visibility_left = visibility_parts[0];
|
if visibility_parts.len() == 1 {
|
||||||
// Parse the right-hand of visibility, with or without an SM suffix
|
metar.visibility_statute_mi = Some(visibility_parts[0].to_string());
|
||||||
let visibility_right_string = match visibility_parts[1].strip_suffix("SM") {
|
} else if visibility_parts.len() == 2 {
|
||||||
Some(s) => s,
|
let visibility_left = visibility_parts[0];
|
||||||
None => {
|
// Parse the right-hand of visibility, with or without an SM suffix
|
||||||
if visibility_parts[1].chars().all(|c| c.is_numeric() || c == '.') {
|
let visibility_right_string = match visibility_parts[1].strip_suffix("SM") {
|
||||||
visibility_parts[1]
|
Some(s) => s,
|
||||||
} else {
|
None => {
|
||||||
log::warn!(
|
if visibility_parts[1]
|
||||||
"Skipping invalid visibility field '{}' ({})",
|
.chars()
|
||||||
metar_parts[0],
|
.all(|c| c.is_numeric() || c == '.')
|
||||||
metar_string
|
{
|
||||||
);
|
visibility_parts[1]
|
||||||
continue;
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Skipping unexpected visibility field '{:?}' ({})",
|
||||||
|
visibility_parts,
|
||||||
|
metar_string
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let visibility_right = visibility_right_string.parse::<f64>()?;
|
||||||
|
let visibility = if visibility_left.starts_with("M") {
|
||||||
|
format!(
|
||||||
|
"M{}",
|
||||||
|
visibility_whole
|
||||||
|
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
||||||
|
)
|
||||||
|
} else if visibility_left.starts_with("P") {
|
||||||
|
format!(
|
||||||
|
"P{}",
|
||||||
|
visibility_whole
|
||||||
|
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}",
|
||||||
|
visibility_whole + (visibility_left.parse::<f64>()? / visibility_right)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
metar.visibility_statute_mi = Some(visibility);
|
||||||
|
} else if !metar_parts.is_empty() && visibility_re_m.is_match(metar_parts[0]) {
|
||||||
|
// Convert meters to statute miles
|
||||||
|
let visibility = metar_parts[0];
|
||||||
|
metar_parts.remove(0);
|
||||||
|
if &visibility[0..4] == "9999" {
|
||||||
|
metar.visibility_statute_mi = Some("P10".to_string());
|
||||||
|
} else {
|
||||||
|
let visibility = visibility[0..4].parse::<f64>()? * 0.000621371;
|
||||||
|
metar.visibility_statute_mi = Some(format!("{:.2}", visibility));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let visibility_right = visibility_right_string.parse::<f64>()?;
|
|
||||||
let visibility = if visibility_left.starts_with("M") {
|
|
||||||
format!(
|
|
||||||
"M{}",
|
|
||||||
visibility_whole
|
|
||||||
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
|
||||||
)
|
|
||||||
} else if visibility_left.starts_with("P") {
|
|
||||||
format!(
|
|
||||||
"P{}",
|
|
||||||
visibility_whole
|
|
||||||
+ (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!(
|
log::warn!("Skipping unexpected visibility field '{}' ({})", metar_parts[0], metar_string);
|
||||||
"{}",
|
|
||||||
visibility_whole + (visibility_left.parse::<f64>()? / visibility_right)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
metar.visibility_statute_mi = Some(visibility);
|
|
||||||
} else if !metar_parts.is_empty() && visibility_re_m.is_match(metar_parts[0]) {
|
|
||||||
// Convert meters to statute miles
|
|
||||||
let visibility = metar_parts[0];
|
|
||||||
metar_parts.remove(0);
|
|
||||||
if &visibility[0..4] == "9999" {
|
|
||||||
metar.visibility_statute_mi = Some("P10".to_string());
|
|
||||||
} else {
|
|
||||||
let visibility = visibility[0..4].parse::<f64>()? * 0.000621371;
|
|
||||||
metar.visibility_statute_mi = Some(format!("{:.2}", visibility));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runway Visual Range
|
// Runway Visual Range
|
||||||
let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$").unwrap();
|
let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$")?;
|
||||||
let variable_rvr_re =
|
let variable_rvr_re =
|
||||||
regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$").unwrap();
|
regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$")?;
|
||||||
while !metar_parts.is_empty()
|
while !metar_parts.is_empty()
|
||||||
&& (rvr_re.is_match(metar_parts[0]) || variable_rvr_re.is_match(metar_parts[0]))
|
&& (rvr_re.is_match(metar_parts[0]) || variable_rvr_re.is_match(metar_parts[0]))
|
||||||
{
|
{
|
||||||
@@ -567,63 +618,10 @@ impl Metar {
|
|||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sky Condition
|
metar.parse_sky_condition(&mut metar_parts);
|
||||||
if !metar_parts.is_empty() && metar_parts[0] == "CAVOK" {
|
|
||||||
metar.sky_condition.push(SkyCondition {
|
|
||||||
sky_cover: "CLR".to_string(),
|
|
||||||
cloud_base_ft_agl: None,
|
|
||||||
significant_convective_clouds: None,
|
|
||||||
});
|
|
||||||
metar_parts.remove(0);
|
|
||||||
}
|
|
||||||
let sky_condition_re =
|
|
||||||
regex::Regex::new(r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$")
|
|
||||||
.unwrap();
|
|
||||||
while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) {
|
|
||||||
let mut sky_condition_string = metar_parts[0];
|
|
||||||
metar_parts.remove(0);
|
|
||||||
|
|
||||||
if sky_condition_string.ends_with("///") {
|
|
||||||
sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sky_condition = SkyCondition::default();
|
|
||||||
let mut vv_offset = 0;
|
|
||||||
if &sky_condition_string[0..2] == "VV" {
|
|
||||||
sky_condition.sky_cover = "VV".to_string();
|
|
||||||
vv_offset = 1;
|
|
||||||
} else {
|
|
||||||
sky_condition.sky_cover = sky_condition_string[0..3].to_string();
|
|
||||||
}
|
|
||||||
if sky_condition_string.len() > 3 - vv_offset {
|
|
||||||
// Parse out the next three digits
|
|
||||||
let cloud_base_ft_agl = &sky_condition_string[3 - vv_offset..6 - vv_offset];
|
|
||||||
if cloud_base_ft_agl == "///" {
|
|
||||||
sky_condition.cloud_base_ft_agl = None;
|
|
||||||
} else {
|
|
||||||
sky_condition.cloud_base_ft_agl = match cloud_base_ft_agl.parse::<i32>() {
|
|
||||||
Ok(c) => Some(c * 100),
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!(
|
|
||||||
"Unable to parse cloud base in {}: {}",
|
|
||||||
sky_condition_string,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if sky_condition_string.len() > 6 - vv_offset {
|
|
||||||
// Parse out the next two digits
|
|
||||||
let scc = &sky_condition_string[6 - vv_offset..8 - vv_offset];
|
|
||||||
sky_condition.significant_convective_clouds = Some(scc.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metar.sky_condition.push(sky_condition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temperature and Dewpoint
|
// Temperature and Dewpoint
|
||||||
let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$").unwrap();
|
let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$")?;
|
||||||
if !metar_parts.is_empty() && temp_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && temp_re.is_match(metar_parts[0]) {
|
||||||
let temp_string = metar_parts[0];
|
let temp_string = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -665,7 +663,7 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Altimeter
|
// Altimeter
|
||||||
let altim_re = regex::Regex::new(r"^A[0-9]{4}$").unwrap();
|
let altim_re = regex::Regex::new(r"^A[0-9]{4}$")?;
|
||||||
if !metar_parts.is_empty() && altim_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && altim_re.is_match(metar_parts[0]) {
|
||||||
let altim = metar_parts[0];
|
let altim = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -673,7 +671,7 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pressure
|
// Pressure
|
||||||
let pressure_re = regex::Regex::new(r"^Q[0-9]{4}$").unwrap();
|
let pressure_re = regex::Regex::new(r"^Q[0-9]{4}$")?;
|
||||||
if !metar_parts.is_empty() && pressure_re.is_match(metar_parts[0]) {
|
if !metar_parts.is_empty() && pressure_re.is_match(metar_parts[0]) {
|
||||||
let pressure = metar_parts[0];
|
let pressure = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -705,8 +703,8 @@ impl Metar {
|
|||||||
if metar_parts.is_empty() {
|
if metar_parts.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let slp_re = regex::Regex::new(r"^SLP([0-9]{3})$").unwrap();
|
let slp_re = regex::Regex::new(r"^SLP([0-9]{3})$")?;
|
||||||
let hourly_temp_re = regex::Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$").unwrap();
|
let hourly_temp_re = regex::Regex::new(r"^T[01][0-9]{3}[01][0-9]{3}$")?;
|
||||||
let remark = metar_parts[0];
|
let remark = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
if remark == "AO1" || remark == "AO2" {
|
if remark == "AO1" || remark == "AO2" {
|
||||||
@@ -801,7 +799,7 @@ impl Metar {
|
|||||||
|
|
||||||
// Skip unexpected fields
|
// Skip unexpected fields
|
||||||
if !metar_parts.is_empty() {
|
if !metar_parts.is_empty() {
|
||||||
log::warn!(
|
log::trace!(
|
||||||
"Skipping unexpected field: '{}' ({})",
|
"Skipping unexpected field: '{}' ({})",
|
||||||
metar_parts[0],
|
metar_parts[0],
|
||||||
metar_string
|
metar_string
|
||||||
@@ -909,76 +907,68 @@ impl Metar {
|
|||||||
Ok(metar)
|
Ok(metar)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_time(observation_time: &str) -> ApiResult<String> {
|
fn parse_sky_condition(&mut self, metar_parts: &mut Vec<&str>) {
|
||||||
if observation_time.len() != 7 {
|
// Check if sky condition is CAVOK
|
||||||
return Err(Error::new(
|
if !metar_parts.is_empty() && metar_parts[0] == "CAVOK" {
|
||||||
500,
|
self.sky_condition.push(SkyCondition {
|
||||||
format!("Unable to parse observation time in {}", observation_time),
|
sky_cover: "CLR".to_string(),
|
||||||
));
|
cloud_base_ft_agl: None,
|
||||||
|
significant_convective_clouds: None,
|
||||||
|
});
|
||||||
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
let observation_day = match observation_time[0..2].parse::<u32>() {
|
|
||||||
Ok(day) => day,
|
let sky_condition_re = regex::Regex::new(
|
||||||
Err(err) => return Err(err.into()),
|
r"^(?:CLR|SKC|NSC|NCD|(?:FEW|SCT|BKN|OVC|VV)([0-9/]{3})?(?:CB|TCU)?)(?:///)?$",
|
||||||
};
|
)
|
||||||
let observation_hour = match observation_time[2..4].parse::<u32>() {
|
.unwrap();
|
||||||
Ok(hour) => hour,
|
|
||||||
Err(err) => return Err(err.into()),
|
while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) {
|
||||||
};
|
// Get the next METAR part
|
||||||
let observation_minute = match observation_time[4..6].parse::<u32>() {
|
let mut sky_condition_string = metar_parts[0];
|
||||||
Ok(minute) => minute,
|
metar_parts.remove(0);
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
};
|
// Remove trailing slashes
|
||||||
let current_time = Utc::now().naive_utc();
|
if sky_condition_string.ends_with("///") {
|
||||||
let current_year = current_time.year();
|
sky_condition_string = &sky_condition_string[..sky_condition_string.len() - 3];
|
||||||
let current_month = current_time.month();
|
|
||||||
let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
Error::new(
|
|
||||||
500,
|
|
||||||
format!(
|
|
||||||
"Invalid date with day {} for current month",
|
|
||||||
observation_day
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) {
|
|
||||||
Some(date) => date,
|
|
||||||
None => {
|
|
||||||
return Err(Error::new(
|
|
||||||
500,
|
|
||||||
format!(
|
|
||||||
"Invalid time for time '{}': hour {}, minute {}",
|
|
||||||
observation_time, observation_hour, observation_minute
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let obs_datetime = if candidate_date > current_time {
|
let mut sky_condition = SkyCondition::default();
|
||||||
// Subtract one month. (Handle year rollover carefully.)
|
// Handle sky cover and optionally vertical visibility
|
||||||
let (month, year) = if current_month == 1 {
|
let mut vv_offset = 0;
|
||||||
(12, current_year - 1)
|
if &sky_condition_string[0..2] == "VV" {
|
||||||
|
sky_condition.sky_cover = "VV".to_string();
|
||||||
|
vv_offset = 1;
|
||||||
} else {
|
} else {
|
||||||
(current_month - 1, current_year)
|
sky_condition.sky_cover = sky_condition_string[0..3].to_string();
|
||||||
};
|
}
|
||||||
|
if sky_condition_string.len() > 3 - vv_offset {
|
||||||
|
if sky_condition_string.len() < 6 - vv_offset {
|
||||||
|
// Parse out the significant convective clouds
|
||||||
|
let scc = &sky_condition_string[3 - vv_offset..];
|
||||||
|
sky_condition.significant_convective_clouds = Some(scc.to_string());
|
||||||
|
} else {
|
||||||
|
// Parse out the next three digits
|
||||||
|
let cloud_base_ft_agl = &sky_condition_string[3 - vv_offset..6 - vv_offset];
|
||||||
|
sky_condition.cloud_base_ft_agl = match cloud_base_ft_agl.parse::<i32>() {
|
||||||
|
Ok(c) => Some(c * 100),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Unable to parse cloud base in {}: {}",
|
||||||
|
sky_condition_string,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let adjusted_date =
|
// Parse out the significant convective clouds
|
||||||
NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| {
|
let scc = &sky_condition_string[6 - vv_offset..];
|
||||||
Error::new(
|
sky_condition.significant_convective_clouds = Some(scc.to_string());
|
||||||
500,
|
}
|
||||||
format!(
|
}
|
||||||
"Invalid date with day {} for month {}",
|
self.sky_condition.push(sky_condition);
|
||||||
observation_day, month
|
}
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
adjusted_date
|
|
||||||
.and_hms_opt(observation_hour, observation_minute, 0)
|
|
||||||
.unwrap()
|
|
||||||
} else {
|
|
||||||
candidate_date
|
|
||||||
};
|
|
||||||
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_cached_remote_metars(
|
pub async fn get_cached_remote_metars(
|
||||||
@@ -1004,7 +994,7 @@ impl Metar {
|
|||||||
let mut output: Vec<Metar> = Vec::new();
|
let mut output: Vec<Metar> = Vec::new();
|
||||||
|
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
// Split off first column
|
// Split off the first column
|
||||||
let raw_text = line.splitn(2, ',').next().unwrap();
|
let raw_text = line.splitn(2, ',').next().unwrap();
|
||||||
match Metar::parse(raw_text) {
|
match Metar::parse(raw_text) {
|
||||||
Ok(m) => output.push(m),
|
Ok(m) => output.push(m),
|
||||||
@@ -1017,7 +1007,7 @@ impl Metar {
|
|||||||
match new_etag {
|
match new_etag {
|
||||||
Some(etag) => Ok((output, etag)),
|
Some(etag) => Ok((output, etag)),
|
||||||
None => match etag {
|
None => match etag {
|
||||||
Some(etag) => Ok((output, etag)),
|
Some(etag) => Ok((output, etag.to_string())),
|
||||||
None => Ok((output, String::new())),
|
None => Ok((output, String::new())),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1212,9 +1202,7 @@ impl Metar {
|
|||||||
log::warn!("Unable to get cached remote METAR data; {}", err);
|
log::warn!("Unable to get cached remote METAR data; {}", err);
|
||||||
(vec![], String::new())
|
(vec![], String::new())
|
||||||
});
|
});
|
||||||
for remote_metar in remote_metars.clone() {
|
MetarRow::insert_all(remote_metars).await?;
|
||||||
remote_metar.insert().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(etag)
|
Ok(etag)
|
||||||
}
|
}
|
||||||
@@ -1234,51 +1222,20 @@ impl Metar {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_time() {
|
|
||||||
for day in 1..=31 {
|
|
||||||
for hour in 0..24 {
|
|
||||||
for minute in 0..60 {
|
|
||||||
// METAR form "DDHHMMZ"
|
|
||||||
let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute);
|
|
||||||
let result = Metar::parse_time(&obs_time);
|
|
||||||
match result {
|
|
||||||
Ok(datetime_str) => {
|
|
||||||
// "YYYY-MM-DDTHH:MM:00Z"
|
|
||||||
assert_eq!(
|
|
||||||
datetime_str.len(),
|
|
||||||
20,
|
|
||||||
"Unexpected length for input {} yielded {}",
|
|
||||||
obs_time,
|
|
||||||
datetime_str
|
|
||||||
);
|
|
||||||
// Remove the trailing 'Z' and parse
|
|
||||||
let trimmed = &datetime_str[..19];
|
|
||||||
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"Parsing '{}' from input {} failed: {}",
|
|
||||||
trimmed, obs_time, e
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_err) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_metar() {
|
async fn test_metar_parse() {
|
||||||
let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990
|
let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT \
|
||||||
RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR
|
-RA BR BKN015 OVC025 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 \
|
||||||
SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string();
|
RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 \
|
||||||
|
TSNO $"
|
||||||
|
.to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string();
|
metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 \
|
||||||
|
SLP126 T02500217 $"
|
||||||
|
.to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
@@ -1288,12 +1245,24 @@ SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string();
|
|||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string();
|
metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 \
|
||||||
|
10133 20078 53002 PNO $"
|
||||||
|
.to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 SLP090 P0001 60004 T00001017 10000 21011 53026".to_string();
|
metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 \
|
||||||
|
SLP090 P0001 60004 T00001017 10000 21011 53026"
|
||||||
|
.to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
dbg!(&metar.observation_time);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
|
metar_string = "KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 \
|
||||||
|
SCTCB FEW123TCU 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 \
|
||||||
|
RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $"
|
||||||
|
.to_string();
|
||||||
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
|
dbg!(&metar.observation_time);
|
||||||
|
dbg!(&metar.sky_condition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
api/src/metars/utils.rs
Normal file
113
api/src/metars/utils.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::error::{ApiResult, Error};
|
||||||
|
use chrono::{Datelike, NaiveDate, Utc};
|
||||||
|
|
||||||
|
pub fn parse_metar_time(observation_time: &str) -> ApiResult<String> {
|
||||||
|
if observation_time.len() != 7 {
|
||||||
|
return Err(Error::new(
|
||||||
|
500,
|
||||||
|
format!("Unable to parse observation time in {}", observation_time),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let observation_day = match observation_time[0..2].parse::<u32>() {
|
||||||
|
Ok(day) => day,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let observation_hour = match observation_time[2..4].parse::<u32>() {
|
||||||
|
Ok(hour) => hour,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let observation_minute = match observation_time[4..6].parse::<u32>() {
|
||||||
|
Ok(minute) => minute,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let current_time = Utc::now().naive_utc();
|
||||||
|
let current_year = current_time.year();
|
||||||
|
let current_month = current_time.month();
|
||||||
|
let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::new(
|
||||||
|
500,
|
||||||
|
format!(
|
||||||
|
"Invalid date with day {} for current month",
|
||||||
|
observation_day
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let candidate_date = match candidate_date.and_hms_opt(observation_hour, observation_minute, 0) {
|
||||||
|
Some(date) => date,
|
||||||
|
None => {
|
||||||
|
return Err(Error::new(
|
||||||
|
500,
|
||||||
|
format!(
|
||||||
|
"Invalid time for time '{}': hour {}, minute {}",
|
||||||
|
observation_time, observation_hour, observation_minute
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let obs_datetime = if candidate_date > current_time {
|
||||||
|
// Subtract one month. (Handle year rollover carefully.)
|
||||||
|
let (month, year) = if current_month == 1 {
|
||||||
|
(12, current_year - 1)
|
||||||
|
} else {
|
||||||
|
(current_month - 1, current_year)
|
||||||
|
};
|
||||||
|
|
||||||
|
let adjusted_date = NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| {
|
||||||
|
Error::new(
|
||||||
|
500,
|
||||||
|
format!(
|
||||||
|
"Invalid date with day {} for month {}",
|
||||||
|
observation_day, month
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
adjusted_date
|
||||||
|
.and_hms_opt(observation_hour, observation_minute, 0)
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
candidate_date
|
||||||
|
};
|
||||||
|
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_metar_time() {
|
||||||
|
for day in 1..=31 {
|
||||||
|
for hour in 0..24 {
|
||||||
|
for minute in 0..60 {
|
||||||
|
// METAR form "DDHHMMZ"
|
||||||
|
let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute);
|
||||||
|
let result = parse_metar_time(&obs_time);
|
||||||
|
match result {
|
||||||
|
Ok(datetime_str) => {
|
||||||
|
// "YYYY-MM-DDTHH:MM:00Z"
|
||||||
|
assert_eq!(
|
||||||
|
datetime_str.len(),
|
||||||
|
20,
|
||||||
|
"Unexpected length for input {} yielded {}",
|
||||||
|
obs_time,
|
||||||
|
datetime_str
|
||||||
|
);
|
||||||
|
// Remove the trailing 'Z' and parse
|
||||||
|
let trimmed = &datetime_str[..19];
|
||||||
|
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"Parsing '{}' from input {} failed: {}",
|
||||||
|
trimmed, obs_time, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
|
use std::env;
|
||||||
use crate::http_client::HttpClient;
|
use crate::http_client::HttpClient;
|
||||||
use crate::metars::Metar;
|
use crate::metars::Metar;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
|
|
||||||
pub fn update_metars(client: Arc<HttpClient>, seconds: u64) {
|
pub fn run() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async {
|
||||||
// Create interval ticker
|
let client = match HttpClient::default() {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to create HTTP client: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let seconds = env::var("METAR_INTERVAL")
|
||||||
|
.unwrap_or("300".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(300);
|
||||||
|
|
||||||
|
// Create an interval ticker
|
||||||
let mut interval = interval(Duration::from_secs(seconds));
|
let mut interval = interval(Duration::from_secs(seconds));
|
||||||
let mut etag = None;
|
let mut etag = None;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
mod model;
|
|
||||||
mod routes;
|
|
||||||
|
|
||||||
pub use model::*;
|
|
||||||
pub use routes::init_routes;
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// use actix_multipart::Multipart;
|
|
||||||
// use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
|
|
||||||
// use futures_util::StreamExt;
|
|
||||||
|
|
||||||
// use crate::{
|
|
||||||
// auth::Auth,
|
|
||||||
// db::{delete_file, get_file, upload_file},
|
|
||||||
// error::ServiceError,
|
|
||||||
// users::User,
|
|
||||||
// };
|
|
||||||
|
|
||||||
// #[get("/favorites")]
|
|
||||||
// async fn get_favorites(auth: Auth) -> HttpResponse {
|
|
||||||
// match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => return HttpResponse::Ok().json(user.favorites),
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[post("/favorites/{icao}")]
|
|
||||||
// async fn add_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
|
||||||
// match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => {
|
|
||||||
// if user.favorites.contains(&icao) {
|
|
||||||
// // Check if the airport ICAO is already in the user's favorites
|
|
||||||
// return HttpResponse::Conflict().finish();
|
|
||||||
// } else {
|
|
||||||
// // Add the airport ICAO to the user's favorites
|
|
||||||
// let mut favorites = user.favorites;
|
|
||||||
// favorites.push(icao.into_inner());
|
|
||||||
// match User::update_favorites(&user.email, favorites) {
|
|
||||||
// Ok(_) => return HttpResponse::Ok().finish(),
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[delete("/favorites/{icao}")]
|
|
||||||
// async fn delete_favorite(icao: web::Path<String>, auth: Auth) -> HttpResponse {
|
|
||||||
// let icao: String = icao.into_inner();
|
|
||||||
// match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => {
|
|
||||||
// if user.favorites.contains(&icao) {
|
|
||||||
// // Check if the airport ICAO is already in the user's favorites
|
|
||||||
// let mut favorites = user.favorites;
|
|
||||||
// favorites.retain(|x| x != &icao);
|
|
||||||
// match User::update_favorites(&user.email, favorites) {
|
|
||||||
// Ok(_) => return HttpResponse::Ok().finish(),
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// // Remove the airport ICAO from the user's favorites
|
|
||||||
// return HttpResponse::Conflict().finish();
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[post("/picture")]
|
|
||||||
// async fn set_picture(mut payload: Multipart, auth: Auth) -> HttpResponse {
|
|
||||||
// while let Some(item) = payload.next().await {
|
|
||||||
// let mut bytes = web::BytesMut::new();
|
|
||||||
// let mut field = match item {
|
|
||||||
// Ok(field) => field,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// let content_type = field.content_disposition();
|
|
||||||
// let filename = match content_type.unwrap().get_filename() {
|
|
||||||
// Some(name) => match name.split(".").last() {
|
|
||||||
// Some(ext) => match ext {
|
|
||||||
// "apng" | "avif" | "gif" | "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" | "png" | "svg"
|
|
||||||
// | "webp" => name,
|
|
||||||
// _ => {
|
|
||||||
// return ResponseError::error_response(&ServiceError::new(
|
|
||||||
// 400,
|
|
||||||
// "File extension is not supported".to_string(),
|
|
||||||
// ))
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// None => {
|
|
||||||
// return ResponseError::error_response(&ServiceError::new(
|
|
||||||
// 400,
|
|
||||||
// "Unknown file extension".to_string(),
|
|
||||||
// ))
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// None => {
|
|
||||||
// return ResponseError::error_response(&ServiceError::new(
|
|
||||||
// 400,
|
|
||||||
// "File name is not provided".to_string(),
|
|
||||||
// ))
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
// let path = format!("users/{}/{}", auth.user.email, filename);
|
|
||||||
|
|
||||||
// while let Some(chunk) = field.next().await {
|
|
||||||
// let data = match chunk {
|
|
||||||
// Ok(data) => data,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// bytes.extend_from_slice(&data);
|
|
||||||
// }
|
|
||||||
// match upload_file(&path, &bytes).await {
|
|
||||||
// Ok(_) => {
|
|
||||||
// match User::update_profile_picture(&auth.user.email, Some(&path)) {
|
|
||||||
// Ok(_) => {}
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// HttpResponse::Ok().finish()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[get("/picture")]
|
|
||||||
// async fn get_picture(auth: Auth) -> HttpResponse {
|
|
||||||
// let user = match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => user,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// if let Some(path) = user.profile_picture {
|
|
||||||
// match get_file(&path).await {
|
|
||||||
// Ok(bytes) => HttpResponse::Ok().body(bytes),
|
|
||||||
// Err(err) => ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// HttpResponse::NotFound().finish()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[delete("/picture")]
|
|
||||||
// async fn delete_picture(auth: Auth) -> HttpResponse {
|
|
||||||
// let user = match User::get_by_email(&auth.user.email) {
|
|
||||||
// Ok(user) => user,
|
|
||||||
// Err(err) => return ResponseError::error_response(&err),
|
|
||||||
// };
|
|
||||||
// if let Some(path) = user.profile_picture {
|
|
||||||
// match delete_file(&path).await {
|
|
||||||
// Ok(_) => match User::update_profile_picture(&auth.user.email, None) {
|
|
||||||
// Ok(_) => HttpResponse::Ok().finish(),
|
|
||||||
// Err(err) => ResponseError::error_response(&err),
|
|
||||||
// },
|
|
||||||
// Err(err) => ResponseError::error_response(&err),
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// HttpResponse::NotFound().finish()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
use utoipa_actix_web::service_config::ServiceConfig;
|
|
||||||
|
|
||||||
pub fn init_routes(_config: &mut ServiceConfig) {
|
|
||||||
// config.service(
|
|
||||||
// web::scope("users")
|
|
||||||
// .service(get_favorites)
|
|
||||||
// .service(add_favorite)
|
|
||||||
// .service(delete_favorite)
|
|
||||||
// .service(set_picture)
|
|
||||||
// .service(get_picture)
|
|
||||||
// .service(delete_picture),
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
Badge,
|
Badge,
|
||||||
Box, Button,
|
Box,
|
||||||
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
Group, Stack,
|
Group,
|
||||||
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsList,
|
TabsList,
|
||||||
Text,
|
Text,
|
||||||
@@ -77,15 +79,15 @@ export function AirportDrawer({
|
|||||||
>
|
>
|
||||||
<Drawer.Content>
|
<Drawer.Content>
|
||||||
<Drawer.Header>
|
<Drawer.Header>
|
||||||
<UnstyledButton
|
{user && (
|
||||||
onClick={() => toggleFavorite(airport.icao)}
|
<UnstyledButton
|
||||||
aria-label={isFavorite ? 'Unfavorite airport' : 'Favorite airport'}
|
onClick={() => toggleFavorite(airport.icao)}
|
||||||
style={{ padding: 4 }}
|
aria-label={isFavorite ? 'Unfavorite airport' : 'Favorite airport'}
|
||||||
>
|
style={{ padding: 4 }}
|
||||||
{isFavorite
|
>
|
||||||
? <IconStarFilled size={24} color="#faca15" />
|
{isFavorite ? <IconStarFilled size={24} color='#faca15' /> : <IconStar size={24} />}
|
||||||
: <IconStar size={24} />}
|
</UnstyledButton>
|
||||||
</UnstyledButton>
|
)}
|
||||||
<Drawer.Title>
|
<Drawer.Title>
|
||||||
<Text size={'xl'}>{airport.name}</Text>
|
<Text size={'xl'}>{airport.name}</Text>
|
||||||
</Drawer.Title>
|
</Drawer.Title>
|
||||||
@@ -117,7 +119,7 @@ export function AirportDrawer({
|
|||||||
<TabsList grow>
|
<TabsList grow>
|
||||||
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
|
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
|
||||||
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
||||||
{ user && <Tabs.Tab value={'manage'}>Manage</Tabs.Tab> }
|
{user && <Tabs.Tab value={'manage'}>Manage</Tabs.Tab>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Tabs.Panel value={'info'}>
|
<Tabs.Panel value={'info'}>
|
||||||
<AirportInfo map={map} airport={airport} />
|
<AirportInfo map={map} airport={airport} />
|
||||||
@@ -128,17 +130,17 @@ export function AirportDrawer({
|
|||||||
{user && (
|
{user && (
|
||||||
<Tabs.Panel value={'manage'}>
|
<Tabs.Panel value={'manage'}>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<Stack mt="md">
|
<Stack mt='md'>
|
||||||
<Button onClick={() => {}}>Update METAR</Button>
|
<Button onClick={() => {}}>Update METAR</Button>
|
||||||
<Button onClick={() => {}}>Edit Airport</Button>
|
<Button onClick={() => {}}>Edit Airport</Button>
|
||||||
<Button color="red" onClick={() => {}}>
|
<Button color='red' onClick={() => {}}>
|
||||||
Delete Airport
|
Delete Airport
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Stack mt="md">
|
<Stack mt='md'>
|
||||||
<Button onClick={() => {}}>Request Edit</Button>
|
<Button onClick={() => {}}>Request Edit</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
)}
|
)}
|
||||||
@@ -236,7 +238,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) {
|
|||||||
|
|
||||||
function WeatherInfo({ metar }: { metar?: Metar }) {
|
function WeatherInfo({ metar }: { metar?: Metar }) {
|
||||||
if (!metar) {
|
if (!metar) {
|
||||||
return <>No METAR observation available/</>
|
return <>No METAR observation available/</>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -262,7 +264,7 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{metar.wind_dir_degrees && metar.wind_speed_kt != null && (
|
{metar.wind_dir_degrees && metar.wind_speed_kt != null && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Wind:</strong> {metar.wind_dir_degrees}° at {metar.wind_speed_kt} kt
|
<strong>Wind:</strong> {metar.wind_dir_degrees}° at {metar.wind_speed_kt} kt
|
||||||
{metar.wind_gust_kt && `, gusts ${metar.wind_gust_kt} kt`}
|
{metar.wind_gust_kt && `, gusts ${metar.wind_gust_kt} kt`}
|
||||||
{metar.variable_wind_dir_degrees && ` (variable ${metar.variable_wind_dir_degrees})`}
|
{metar.variable_wind_dir_degrees && ` (variable ${metar.variable_wind_dir_degrees})`}
|
||||||
@@ -270,20 +272,20 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.visibility_statute_mi && (
|
{metar.visibility_statute_mi && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Visibility:</strong> {metar.visibility_statute_mi} statute miles
|
<strong>Visibility:</strong> {metar.visibility_statute_mi} statute miles
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(metar.temp_c != null || metar.dew_point_c != null) && (
|
{(metar.temp_c != null || metar.dew_point_c != null) && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Temp / Dew Point:</strong> {metar.temp_c}°C / {metar.dew_point_c}°C
|
<strong>Temp / Dew Point:</strong> {metar.temp_c}°C / {metar.dew_point_c}°C
|
||||||
{metar.estimated_humidity != null && ` (${metar.estimated_humidity}% RH)`}
|
{metar.estimated_humidity != null && ` (${metar.estimated_humidity}% RH)`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(metar.altimeter_in_hg != null || metar.sea_level_pressure_mb != null) && (
|
{(metar.altimeter_in_hg != null || metar.sea_level_pressure_mb != null) && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Pressure:</strong>
|
<strong>Pressure:</strong>
|
||||||
{metar.altimeter_in_hg != null && ` Alt ${metar.altimeter_in_hg} inHg`}
|
{metar.altimeter_in_hg != null && ` Alt ${metar.altimeter_in_hg} inHg`}
|
||||||
{metar.sea_level_pressure_mb != null && `, SLP ${metar.sea_level_pressure_mb} mb`}
|
{metar.sea_level_pressure_mb != null && `, SLP ${metar.sea_level_pressure_mb} mb`}
|
||||||
@@ -291,13 +293,13 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.weather_phenomena.length > 0 && (
|
{metar.weather_phenomena.length > 0 && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Weather:</strong> {metar.weather_phenomena.join(', ')}
|
<strong>Weather:</strong> {metar.weather_phenomena.join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.sky_condition.length > 0 && (
|
{metar.sky_condition.length > 0 && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Sky:</strong>{' '}
|
<strong>Sky:</strong>{' '}
|
||||||
{metar.sky_condition
|
{metar.sky_condition
|
||||||
.map((s) => `${s.sky_cover}${s.cloud_base_ft_agl ? ` at ${s.cloud_base_ft_agl} ft` : ''}`)
|
.map((s) => `${s.sky_cover}${s.cloud_base_ft_agl ? ` at ${s.cloud_base_ft_agl} ft` : ''}`)
|
||||||
@@ -305,19 +307,19 @@ function WeatherInfo({ metar }: { metar?: Metar }) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(metar.max_temp_c != null && metar.min_temp_c != null) && (
|
{metar.max_temp_c != null && metar.min_temp_c != null && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Max / Min:</strong> {metar.max_temp_c}°C / {metar.min_temp_c}°C
|
<strong>Max / Min:</strong> {metar.max_temp_c}°C / {metar.min_temp_c}°C
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metar.density_altutude != null && (
|
{metar.density_altutude != null && (
|
||||||
<Text mb="sm">
|
<Text mb='sm'>
|
||||||
<strong>Density Altitude:</strong> {metar.density_altutude} ft
|
<strong>Density Altitude:</strong> {metar.density_altutude} ft
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function airportCategoryToText(category: AirportCategory): string {
|
function airportCategoryToText(category: AirportCategory): string {
|
||||||
|
|||||||
63
ui/src/components/AirportSearch.tsx
Normal file
63
ui/src/components/AirportSearch.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { getAirports } from '@lib/airport.ts';
|
||||||
|
import { Autocomplete } from '@mantine/core';
|
||||||
|
|
||||||
|
export interface AirportSearchProps {
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AirportSearch({ limit = 5 }: AirportSearchProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debounced] = useDebouncedValue(search, 300);
|
||||||
|
const [data, setData] = useState<{ key: string; value: string; label: string }[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debounced) {
|
||||||
|
setData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetch(): Promise<{ key: string; value: string; label: string }[]> {
|
||||||
|
try {
|
||||||
|
const icaoResponse = await getAirports({
|
||||||
|
icaos: [debounced],
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
const nameResponse = await getAirports({
|
||||||
|
name: debounced,
|
||||||
|
limit: limit - 1,
|
||||||
|
});
|
||||||
|
let combined = [...icaoResponse.data, ...nameResponse.data];
|
||||||
|
combined = combined.slice(0, limit);
|
||||||
|
return combined.map((airport) => ({
|
||||||
|
key: airport.icao,
|
||||||
|
value: airport.icao,
|
||||||
|
label: `${airport.icao} - ${airport.name}`,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('airport search failed', err);
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch().then(d => {
|
||||||
|
setData(d);
|
||||||
|
console.log(d)
|
||||||
|
});
|
||||||
|
}, [debounced, limit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
placeholder='Enter airport name or ICAO'
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
data={data}
|
||||||
|
limit={limit}
|
||||||
|
onOptionSubmit={() => {}}
|
||||||
|
radius={'xl'}
|
||||||
|
onBlur={() => setSearch('')}
|
||||||
|
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Autocomplete, Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
|
import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
|
||||||
import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks';
|
import { useDisclosure, useMediaQuery, useToggle } from '@mantine/hooks';
|
||||||
import classes from './Header.module.css';
|
import classes from './Header.module.css';
|
||||||
import { HeaderModal } from '@components/Header/HeaderModal.tsx';
|
import { HeaderModal } from '@components/Header/HeaderModal.tsx';
|
||||||
@@ -6,7 +6,8 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { login, logout, register } from '@lib/account.ts';
|
import { login, logout, register } from '@lib/account.ts';
|
||||||
import HeaderUser from '@components/Header/HeaderUser.tsx';
|
import HeaderUser from '@components/Header/HeaderUser.tsx';
|
||||||
import { useUserContext } from '@components/context/UserContext.tsx';
|
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||||
import { Link } from 'react-router';
|
import { Link, matchPath, useLocation, useNavigate } from 'react-router';
|
||||||
|
import { AirportSearch } from '@components/AirportSearch.tsx';
|
||||||
|
|
||||||
// const links = [
|
// const links = [
|
||||||
// { link: '/', label: 'Map' },
|
// { link: '/', label: 'Map' },
|
||||||
@@ -14,11 +15,18 @@ import { Link } from 'react-router';
|
|||||||
// { link: '/metars', label: 'Metars' }
|
// { link: '/metars', label: 'Metars' }
|
||||||
// ];
|
// ];
|
||||||
|
|
||||||
|
const protectedPages = [
|
||||||
|
'/administration',
|
||||||
|
'/profile'
|
||||||
|
]
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { user, setUser } = useUserContext();
|
const { user, setUser } = useUserContext();
|
||||||
const [opened, { toggle }] = useDisclosure(false);
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
// const [active, setActive] = useState(links[0].link);
|
// const [active, setActive] = useState(links[0].link);
|
||||||
|
|
||||||
// const navItems = links.map((link) => (
|
// const navItems = links.map((link) => (
|
||||||
@@ -63,7 +71,14 @@ export function Header() {
|
|||||||
async function logoutUser(): Promise<void> {
|
async function logoutUser(): Promise<void> {
|
||||||
await logout();
|
await logout();
|
||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
window.location.reload();
|
|
||||||
|
// See if the current page is a protected page
|
||||||
|
const isProtected = protectedPages.some(pattern =>
|
||||||
|
matchPath(pattern, pathname)
|
||||||
|
)
|
||||||
|
if (isProtected) {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerUser({
|
async function registerUser({
|
||||||
@@ -145,7 +160,8 @@ export function Header() {
|
|||||||
{/*</Group>*/}
|
{/*</Group>*/}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Group align='center' gap='xs'>
|
<Group align='center' gap='xs'>
|
||||||
<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />
|
<AirportSearch />
|
||||||
|
{/*<Autocomplete placeholder={'Enter airport name or ICAO'} limit={5} />*/}
|
||||||
{user ? (
|
{user ? (
|
||||||
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
|
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const UserContext = createContext<UserContextType>({
|
|||||||
setUser: () => {},
|
setUser: () => {},
|
||||||
loading: true,
|
loading: true,
|
||||||
favorites: [],
|
favorites: [],
|
||||||
toggleFavorite: () => {},
|
toggleFavorite: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useUserContext(): UserContextType {
|
export function useUserContext(): UserContextType {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
import { UserContext } from './UserContext.tsx';
|
import { UserContext } from './UserContext.tsx';
|
||||||
import { profile } from '@lib/account.ts';
|
import { addFavorite, getFavorites, profile, removeFavorite } from '@lib/account.ts';
|
||||||
import { User } from '@lib/account.types.ts';
|
import { User } from '@lib/account.types.ts';
|
||||||
import { Center, Loader } from '@mantine/core';
|
import { Center, Loader } from '@mantine/core';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
@@ -9,19 +9,26 @@ const sessionExpirationName = 'session_expiration';
|
|||||||
|
|
||||||
export function UserProvider({ children }: { children: ReactNode }) {
|
export function UserProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | undefined>(undefined);
|
const [user, setUser] = useState<User | undefined>(undefined);
|
||||||
const [favorites, setFavorites] = useState<string[]>(() => {
|
const [favorites, setFavorites] = useState<string[]>([]);
|
||||||
return JSON.parse(localStorage.getItem('favorites') || '[]')
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const toggleFavorite = (icao: string) => {
|
async function toggleFavorite(icao: string) {
|
||||||
setFavorites((prev) => {
|
setFavorites((prev) => {
|
||||||
const next = prev.includes(icao)
|
const isFav = prev.includes(icao)
|
||||||
|
const next = isFav
|
||||||
? prev.filter((i) => i !== icao)
|
? prev.filter((i) => i !== icao)
|
||||||
: [...prev, icao]
|
: [...prev, icao]
|
||||||
localStorage.setItem('favorites', JSON.stringify(next))
|
|
||||||
|
;(isFav
|
||||||
|
? removeFavorite(icao)
|
||||||
|
: addFavorite(icao)
|
||||||
|
).catch((err) => {
|
||||||
|
console.error('Sync failed, rolling back', err)
|
||||||
|
setFavorites(prev)
|
||||||
|
})
|
||||||
|
|
||||||
return next
|
return next
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,6 +55,14 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user != undefined) {
|
||||||
|
getFavorites().then(f => {
|
||||||
|
setFavorites(f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ user, setUser, loading, favorites, toggleFavorite }}>
|
<UserContext.Provider value={{ user, setUser, loading, favorites, toggleFavorite }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getRequest, postRequest } from '.';
|
import { deleteRequest, getRequest, postRequest } from '.';
|
||||||
import { RegisterUser, User } from './account.types';
|
import { RegisterUser, User } from './account.types';
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<User | undefined> {
|
export async function login(username: string, password: string): Promise<User | undefined> {
|
||||||
@@ -31,3 +31,20 @@ export async function profile(): Promise<User | undefined> {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFavorites(): Promise<string[]> {
|
||||||
|
const response = await getRequest('account/profile/favorites');
|
||||||
|
if (response?.status === 200) {
|
||||||
|
return response.json();
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFavorite(icao: string): Promise<Response> {
|
||||||
|
return await postRequest(`account/profile/favorites/${icao}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFavorite(icao: string): Promise<Response> {
|
||||||
|
return await deleteRequest(`account/profile/favorites/${icao}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export async function getAirports({
|
|||||||
icaos: icaos ?? undefined,
|
icaos: icaos ?? undefined,
|
||||||
name: name ?? undefined,
|
name: name ?? undefined,
|
||||||
metars: metars ?? undefined,
|
metars: metars ?? undefined,
|
||||||
limit,
|
limit: limit,
|
||||||
page
|
page: page
|
||||||
});
|
});
|
||||||
return response?.json() || { data: [] };
|
return response?.json() || { data: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { API_URL } from '@lib/constants.ts';
|
import { API_URL } from '@lib/constants.ts';
|
||||||
|
|
||||||
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
export async function getRequest(endpoint: string, params: any = {}): Promise<Response> {
|
||||||
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
||||||
const urlParams = new URLSearchParams(params);
|
const urlParams = new URLSearchParams(params);
|
||||||
const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
|
const url = urlParams && urlParams.size > 0 ? `${API_URL}/${endpoint}?${urlParams}` : `${API_URL}/${endpoint}`;
|
||||||
@@ -11,7 +11,7 @@ export async function getRequest(endpoint: string, params: Record<string, any> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PostOptions {
|
interface PostOptions {
|
||||||
headers?: Record<string, any>;
|
headers?: Record<string, string>;
|
||||||
type?: 'json' | 'form';
|
type?: 'json' | 'form';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +61,8 @@ export async function putRequest(endpoint: string, body?: any, options?: PostOpt
|
|||||||
|
|
||||||
export async function deleteRequest(endpoint: string): Promise<Response> {
|
export async function deleteRequest(endpoint: string): Promise<Response> {
|
||||||
const url = `${API_URL}/${endpoint}`;
|
const url = `${API_URL}/${endpoint}`;
|
||||||
const response = await fetch(url, {
|
return await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user