Compare commits
No commits in common. "d6dc15b4a3c6e4f57b1195014ca49a110cf1267f" and "36e10cf8d29a86e10dae711dddef60b1ea668059" have entirely different histories.
d6dc15b4a3
...
36e10cf8d2
7 changed files with 6 additions and 408 deletions
16
README.md
16
README.md
|
|
@ -1,16 +0,0 @@
|
||||||
# Khollisé Worker
|
|
||||||
|
|
||||||
## Job Types
|
|
||||||
|
|
||||||
| job_id | description |
|
|
||||||
|--------|------------------------------------------------------------------|
|
|
||||||
| 0 | Fetch ONE colle (with class_name, colle_id and colle_secret) |
|
|
||||||
| 1 | Fetch class colles (with class_name and optionally go to date) |
|
|
||||||
| 2 | Fetch upcoming colles (with class_name) |
|
|
||||||
| 3 | Fetch sumbittable meals |
|
|
||||||
| 4 | Submit meals for user (must include bj_username and bj_password) |
|
|
||||||
| 5 | Test authentication (must include bj_username and bj_password) |
|
|
||||||
|
|
||||||
Actually, all jobs require `class_name` to be specified.
|
|
||||||
|
|
||||||
<!-- TODO: Add documentation for used Redis keys -->
|
|
||||||
74
src/api.rs
74
src/api.rs
|
|
@ -70,74 +70,6 @@ pub async fn post_upcoming_colles(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_submittable_meals(
|
|
||||||
meals: &[Meal],
|
|
||||||
config: &Settings,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let api_url = get_config(config, "api");
|
|
||||||
let api_token = get_config(config, "token");
|
|
||||||
|
|
||||||
let url = format!("{api_url}/meals");
|
|
||||||
let meals_json = json!({
|
|
||||||
"meals": &meals
|
|
||||||
});
|
|
||||||
println!("Posting submittable meals: {:?}", meals_json);
|
|
||||||
|
|
||||||
let response = Client::new()
|
|
||||||
.post(&url)
|
|
||||||
.json(&meals_json)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
// Bearer token for authentication
|
|
||||||
.header("Authorization", format!("Bearer {api_token}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
// Check if the response is successful
|
|
||||||
if !response.status().is_success() {
|
|
||||||
eprintln!(
|
|
||||||
"Failed to post submittable meals: HTTP {}",
|
|
||||||
response.status(),
|
|
||||||
);
|
|
||||||
eprint!("Response: {:?}", response.text().await?);
|
|
||||||
return Err("Failed to post submittable meals".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn post_selected_meals(
|
|
||||||
meals: &[Meal],
|
|
||||||
user_id: &i64,
|
|
||||||
config: &Settings,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let api_url = get_config(config, "api");
|
|
||||||
let api_token = get_config(config, "token");
|
|
||||||
|
|
||||||
let url = format!("{api_url}/internals/meals-registrations");
|
|
||||||
let meals_json = json!({
|
|
||||||
"meals": &meals,
|
|
||||||
"userId": user_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = Client::new()
|
|
||||||
.post(&url)
|
|
||||||
.json(&meals_json)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
// Bearer token for authentication
|
|
||||||
.header("Authorization", format!("Bearer {api_token}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
// Check if the response is successful
|
|
||||||
if !response.status().is_success() {
|
|
||||||
eprintln!("Failed to post selected meals: HTTP {}", response.status(),);
|
|
||||||
eprint!("Response: {:?}", response.text().await?);
|
|
||||||
return Err("Failed to post selected meals".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct Colle {
|
pub struct Colle {
|
||||||
pub date: NaiveDateTime,
|
pub date: NaiveDateTime,
|
||||||
|
|
@ -158,9 +90,3 @@ pub struct ColleAttachment {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct Meal {
|
|
||||||
pub date: String, // "YYYY-MM-DD"
|
|
||||||
pub meal_type: String, // "lunch" or "dinner"
|
|
||||||
}
|
|
||||||
|
|
|
||||||
145
src/main.rs
145
src/main.rs
|
|
@ -1,12 +1,9 @@
|
||||||
use std::{str::FromStr, sync::Arc};
|
use std::{str::FromStr, sync::Arc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{Meal, post_colle, post_selected_meals, post_submittable_meals, post_upcoming_colles},
|
api::{post_colle, post_upcoming_colles},
|
||||||
configuration::{get_config, get_cron, list_classes, load_config},
|
configuration::{get_config, get_cron, list_classes, load_config},
|
||||||
parser::{
|
parser::{authenticate, fetch_class_colles, fetch_colle, fetch_upcoming_colles},
|
||||||
authenticate, fetch_class_colles, fetch_colle, fetch_upcoming_colles, get_available_meals,
|
|
||||||
login, request_session,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use cron::Schedule;
|
use cron::Schedule;
|
||||||
|
|
@ -182,117 +179,6 @@ async fn process_job(
|
||||||
eprintln!("Failed to fetch colle {}: {}", colle_id, e);
|
eprintln!("Failed to fetch colle {}: {}", colle_id, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if job["type"] == 4 || job["type"] == 5 {
|
|
||||||
let user_id = job["user_id"].as_number().unwrap().as_i64().unwrap_or(-1);
|
|
||||||
if user_id == -1 {
|
|
||||||
eprintln!("Job does not contain a valid user ID.");
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::InvalidClientConfig,
|
|
||||||
"Job missing valid user ID",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate (job data contains password and username)
|
|
||||||
let username = job["bj_username"].as_str().unwrap_or_default();
|
|
||||||
let password = job["bj_password"].as_str().unwrap_or_default();
|
|
||||||
if username.is_empty() || password.is_empty() {
|
|
||||||
eprintln!("Job does not contain username or password.");
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::InvalidClientConfig,
|
|
||||||
"Job missing username or password",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let session = request_session().await;
|
|
||||||
if let Err(err) = session {
|
|
||||||
eprintln!("Failed to request session: {}", err);
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::AuthenticationFailed,
|
|
||||||
"Session request failed",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let session_id = session.unwrap();
|
|
||||||
|
|
||||||
let login_result = login(&username, &password, &session_id).await;
|
|
||||||
if let Err(err) = login_result {
|
|
||||||
if job["type"] == 5 {
|
|
||||||
// Set auth failed in Redis
|
|
||||||
let auth_key = format!("auth_success_{}", user_id);
|
|
||||||
con.set_ex(&auth_key, "ERROR", 300)?;
|
|
||||||
}
|
|
||||||
eprintln!("Failed to login: {}", err);
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::AuthenticationFailed,
|
|
||||||
"Login failed",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if job["type"] == 4 {
|
|
||||||
// Fetch already selected meals
|
|
||||||
let selected_meals = get_available_meals(&session_id, true).await;
|
|
||||||
if let Err(err) = selected_meals {
|
|
||||||
eprintln!("Failed to fetch available meals: {}", err);
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::ResponseError,
|
|
||||||
"Failed to fetch available meals",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let selected_meals = selected_meals.unwrap();
|
|
||||||
|
|
||||||
// Add job["meal"] (ONLY ONE MEAL) to selected meals
|
|
||||||
let mut meals_parsed = selected_meals;
|
|
||||||
let meal_to_add = Meal {
|
|
||||||
date: job["meal"]["date"].as_str().unwrap_or_default().to_string(),
|
|
||||||
meal_type: job["meal"]["meal_type"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("dinner")
|
|
||||||
.to_string(),
|
|
||||||
};
|
|
||||||
if !meals_parsed
|
|
||||||
.iter()
|
|
||||||
.any(|m| m.date == meal_to_add.date && m.meal_type == meal_to_add.meal_type)
|
|
||||||
{
|
|
||||||
meals_parsed.push(meal_to_add);
|
|
||||||
|
|
||||||
// Submit meals
|
|
||||||
let res = parser::submit_meals(&meals_parsed, &session_id).await;
|
|
||||||
if res.is_err() {
|
|
||||||
eprintln!("Failed to submit meals: {}", res.unwrap_err());
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::ResponseError,
|
|
||||||
"Failed to submit meals",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
println!("Meals submitted successfully.");
|
|
||||||
} else {
|
|
||||||
println!("Meal already selected, skipping addition.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let meals = get_available_meals(&session_id, true).await;
|
|
||||||
if meals.is_err() {
|
|
||||||
eprintln!("Failed to fetch available meals: {}", meals.unwrap_err());
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::ResponseError,
|
|
||||||
"Failed to fetch available meals",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let meals = meals.unwrap();
|
|
||||||
|
|
||||||
// Post selected meals to API
|
|
||||||
let res = post_selected_meals(&meals, &user_id, config).await;
|
|
||||||
if res.is_err() {
|
|
||||||
eprintln!("Failed to post selected meals: {}", res.unwrap_err());
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::ResponseError,
|
|
||||||
"Failed to post selected meals",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
println!("Posted {} selected meals successfully.", meals.len());
|
|
||||||
} else if job["type"] == 5 {
|
|
||||||
// Set auth successful in Redis
|
|
||||||
let auth_key = format!("auth_success_{}", user_id);
|
|
||||||
con.set_ex(&auth_key, "SUCCESS", 300)?;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Authenticate
|
// Authenticate
|
||||||
let session = authenticate(class_name, &mut *con, config).await;
|
let session = authenticate(class_name, &mut *con, config).await;
|
||||||
|
|
@ -370,33 +256,6 @@ async fn process_job(
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
println!("Posted upcoming colles successfully.");
|
println!("Posted upcoming colles successfully.");
|
||||||
} else if job["type"] == 3 {
|
|
||||||
println!("Fetching available meals...");
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Fetch available meals
|
|
||||||
*/
|
|
||||||
match get_available_meals(&session, false).await {
|
|
||||||
Ok(meals) => {
|
|
||||||
if meals.is_empty() {
|
|
||||||
println!("No available meals found.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// Post available meals to API
|
|
||||||
let res = post_submittable_meals(&meals, config).await;
|
|
||||||
if res.is_err() {
|
|
||||||
eprintln!("Failed to post submittable meals: {}", res.unwrap_err());
|
|
||||||
return Err(redis::RedisError::from((
|
|
||||||
redis::ErrorKind::ResponseError,
|
|
||||||
"Failed to post submittable meals",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
println!("Posted {} submittable meals successfully.", meals.len());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to fetch available meals: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*
|
/*
|
||||||
* Handle unknown job types
|
* Handle unknown job types
|
||||||
* This is a catch-all for any job types that are not recognized.
|
* This is a catch-all for any job types that are not recognized.
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ pub async fn login(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if session_id.is_empty() {
|
if session_id.is_empty() {
|
||||||
return Err("Invalid credentials".into());
|
return Err("Failed to get session ID".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(session_id.join("; "))
|
Ok(session_id.join("; "))
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
mod auth;
|
mod auth;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod colles;
|
mod colles;
|
||||||
mod repas;
|
|
||||||
|
|
||||||
pub use colles::fetch_class_colles;
|
pub use colles::fetch_class_colles;
|
||||||
pub use colles::fetch_colle;
|
pub use colles::fetch_colle;
|
||||||
pub use colles::fetch_upcoming_colles;
|
pub use colles::fetch_upcoming_colles;
|
||||||
|
|
||||||
pub use auth::authenticate;
|
pub use auth::authenticate;
|
||||||
pub use auth::login;
|
|
||||||
pub use auth::request_session;
|
|
||||||
|
|
||||||
pub use repas::get_available_meals;
|
|
||||||
pub use repas::submit_meals;
|
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
|
||||||
use reqwest::Client;
|
|
||||||
use scraper::{Html, Selector};
|
|
||||||
|
|
||||||
use crate::{api::Meal, parser::utils::parse_french_date};
|
|
||||||
|
|
||||||
pub async fn get_available_meals(
|
|
||||||
session: &str,
|
|
||||||
filter_selected: bool,
|
|
||||||
) -> Result<Vec<Meal>, Box<dyn std::error::Error>> {
|
|
||||||
let url = "https://bjcolle.fr/no_waste.php";
|
|
||||||
let response = Client::new()
|
|
||||||
.get(url)
|
|
||||||
.header("Cookie", session)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.text()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let document = Html::parse_document(&response);
|
|
||||||
|
|
||||||
// Select each table row
|
|
||||||
let row_selector = Selector::parse("#Choix > table > tbody > tr").unwrap();
|
|
||||||
let label_selector = Selector::parse("label").unwrap();
|
|
||||||
let input_selector = Selector::parse("input[type=\"checkbox\"]").unwrap();
|
|
||||||
|
|
||||||
let mut meals = Vec::new();
|
|
||||||
|
|
||||||
for row in document.select(&row_selector) {
|
|
||||||
// Extract the label text (meal name)
|
|
||||||
if let Some(label) = row.select(&label_selector).next() {
|
|
||||||
let meal_name = label
|
|
||||||
.text()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Extract the input checkbox
|
|
||||||
if let Some(input) = row.select(&input_selector).next() {
|
|
||||||
let is_checked = input.value().attr("checked").is_some();
|
|
||||||
|
|
||||||
// Apply filtering: if filter_selected = true, skip unchecked
|
|
||||||
if filter_selected && !is_checked {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !meal_name.is_empty() {
|
|
||||||
if let Some(meal) = parse_meal(&meal_name) {
|
|
||||||
meals.push(meal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(meals)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_meal(name: &str) -> Option<Meal> {
|
|
||||||
// Normalize input
|
|
||||||
let trimmed = name.trim();
|
|
||||||
|
|
||||||
// Detect meal type
|
|
||||||
let meal_type = if trimmed.starts_with("Déjeuner") {
|
|
||||||
"lunch"
|
|
||||||
} else if trimmed.starts_with("Dîner") {
|
|
||||||
"dinner"
|
|
||||||
} else {
|
|
||||||
return None; // Unknown meal type
|
|
||||||
}
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Extract date string (after "du ") and format it YYYY-MM-DD
|
|
||||||
let date_part = trimmed.splitn(2, "du ").nth(1)?;
|
|
||||||
let date = parse_french_date(date_part).unwrap();
|
|
||||||
let date = date.format("%Y-%m-%d").to_string();
|
|
||||||
|
|
||||||
Some(Meal { date, meal_type })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map chrono weekday to French first letter
|
|
||||||
fn french_day_letter(date: NaiveDate) -> char {
|
|
||||||
match date.weekday() {
|
|
||||||
chrono::Weekday::Mon => 'l', // Lundi
|
|
||||||
chrono::Weekday::Tue => 'm', // Mardi
|
|
||||||
chrono::Weekday::Wed => 'm', // Mercredi
|
|
||||||
chrono::Weekday::Thu => 'j', // Jeudi
|
|
||||||
chrono::Weekday::Fri => 'v', // Vendredi
|
|
||||||
chrono::Weekday::Sat => 's', // Samedi
|
|
||||||
chrono::Weekday::Sun => 'd', // Dimanche
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map chrono month to French month name
|
|
||||||
fn french_month(month: u32) -> &'static str {
|
|
||||||
match month {
|
|
||||||
1 => "janvier",
|
|
||||||
2 => "février",
|
|
||||||
3 => "mars",
|
|
||||||
4 => "avril",
|
|
||||||
5 => "mai",
|
|
||||||
6 => "juin",
|
|
||||||
7 => "juillet",
|
|
||||||
8 => "août",
|
|
||||||
9 => "septembre",
|
|
||||||
10 => "octobre",
|
|
||||||
11 => "novembre",
|
|
||||||
12 => "décembre",
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn submit_meals(meals: &[Meal], session: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let url = "https://bjcolle.fr/no_waste.php";
|
|
||||||
let client = Client::new();
|
|
||||||
|
|
||||||
let mut form: HashMap<String, String> = HashMap::new();
|
|
||||||
|
|
||||||
// Mandatory field
|
|
||||||
form.insert(
|
|
||||||
"VALIDER_CHOIX".to_string(),
|
|
||||||
"Valider+(même+si+rien+n'est+coché)".to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for meal in meals {
|
|
||||||
let meal_date = NaiveDate::parse_from_str(&meal.date, "%Y-%m-%d")?;
|
|
||||||
let day_letter = french_day_letter(meal_date);
|
|
||||||
|
|
||||||
let key = match meal.meal_type.as_str() {
|
|
||||||
"lunch" => format!("{}m", day_letter), // midi
|
|
||||||
"dinner" => format!("{}s", day_letter), // soir
|
|
||||||
other => {
|
|
||||||
eprintln!("Unknown meal_type: {}", other);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format like "30+août"
|
|
||||||
let value = format!("{}+{}", meal_date.day(), french_month(meal_date.month()));
|
|
||||||
|
|
||||||
form.insert(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = client
|
|
||||||
.post(url)
|
|
||||||
.header("Cookie", session)
|
|
||||||
.form(&form)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !res.status().is_success() {
|
|
||||||
eprintln!("Failed to submit meals: HTTP {}", res.status());
|
|
||||||
eprint!("Response: {:?}", res.text().await?);
|
|
||||||
return Err("Failed to submit meals".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime};
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
@ -24,7 +24,7 @@ pub fn parse_french_date(french_date: &str) -> Result<NaiveDate, Box<dyn Error>>
|
||||||
|
|
||||||
// Split by spaces
|
// Split by spaces
|
||||||
let parts: Vec<&str> = french_date.split_whitespace().collect();
|
let parts: Vec<&str> = french_date.split_whitespace().collect();
|
||||||
if parts.len() < 3 {
|
if parts.len() < 4 {
|
||||||
return Err("Date string too short".into());
|
return Err("Date string too short".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,11 +42,7 @@ pub fn parse_french_date(french_date: &str) -> Result<NaiveDate, Box<dyn Error>>
|
||||||
.ok_or_else(|| format!("Unknown month: {}", parts[2]))?;
|
.ok_or_else(|| format!("Unknown month: {}", parts[2]))?;
|
||||||
|
|
||||||
// Year
|
// Year
|
||||||
let year = if let Some(s) = parts.get(3) {
|
let year: i32 = parts[3].parse()?;
|
||||||
s.parse()?
|
|
||||||
} else {
|
|
||||||
chrono::Local::now().year()
|
|
||||||
};
|
|
||||||
|
|
||||||
let date = NaiveDate::from_ymd_opt(year, *month, day).ok_or("Invalid date components")?;
|
let date = NaiveDate::from_ymd_opt(year, *month, day).ok_or("Invalid date components")?;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue