Compare commits

...

2 Commits

Author SHA1 Message Date
8926cd7ecf feat: New V3 version 2026-02-07 13:53:54 +01:00
0176945bd6 perf: Optimize final build size 2026-02-06 16:17:49 +01:00
8 changed files with 336 additions and 1308 deletions

1101
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,19 +4,26 @@ version = "0.1.1"
edition = "2024"
[dependencies]
chrono = { version = "0.4.43", features = ["serde"] }
futures = "0.3.31"
once_cell = "1.21.3"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
chrono = { version = "0.4.39", features = ["serde"] }
minreq = { version = "2.13", features = ["json-using-serde", "https-rustls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0.18"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.22"
android_logger = "0.14"
log = "0.4"
uniffi = { version = "0.27", features = ["cli"] }
thiserror = "2.0.11"
uniffi = { version = "0.27" }
[features]
uniffi-cli = ["uniffi/cli"]
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "uniffi-bindgen"
required-features = ["uniffi-cli"]
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = false

16
src/api.rs Normal file
View File

@@ -0,0 +1,16 @@
use crate::models::{ApiResponse, SuplError};
pub fn fetch_api(provider_url: &str) -> Result<ApiResponse, SuplError> {
let trimmed = provider_url.trim_end_matches('/');
let url = format!("{}/versioned/v3", trimmed);
minreq::get(&url)
.send()
.map_err(|e| SuplError::NetworkError {
reason: e.to_string(),
})?
.json()
.map_err(|e| SuplError::ParseError {
reason: e.to_string(),
})
}

97
src/bin/test.rs Normal file
View File

@@ -0,0 +1,97 @@
use jecna_supl_client::{AbsenceEntry, JecnaSuplClient};
fn main() {
let client = JecnaSuplClient::new();
println!("Fetching schedule for E4...");
match client.get_schedule("E4".to_string()) {
Ok(result) => {
println!("Last updated: {}", result.status.last_updated);
if result.schedule.is_empty() {
println!("No substitution schedule found for the upcoming days.");
}
for (date, day) in result.schedule {
println!("\nDate: {}", date);
println!("In work: {}", day.info.in_work);
if !day.takes_place.is_empty() {
println!("Extra info: {}", day.takes_place);
}
println!("Changes:");
for (i, change) in day.changes.iter().enumerate() {
if let Some(c) = change {
println!(" Lesson {}: {}", i + 1, c.text);
}
}
println!("Absences:");
for entry in day.absence {
print_absence(entry);
}
}
}
Err(e) => eprintln!("Error fetching schedule: {:?}", e),
}
println!("\nFetching all teacher absences...");
match client.get_teacher_absence() {
Ok(result) => {
for (date, entries) in result.absences {
println!("\nDate: {}", date);
for entry in entries {
print_absence(entry);
}
}
}
Err(e) => eprintln!("Error fetching teacher absences: {:?}", e),
}
}
fn print_absence(entry: AbsenceEntry) {
match entry {
AbsenceEntry::WholeDay {
teacher,
teacher_code,
} => {
println!(" {} ({}): Whole day", teacher, teacher_code);
}
AbsenceEntry::Single {
teacher,
teacher_code,
hours,
} => {
println!(" {} ({}): Lesson {}", teacher, teacher_code, hours);
}
AbsenceEntry::Range {
teacher,
teacher_code,
hours,
} => {
println!(
" {} ({}): Lessons {}-{}",
teacher, teacher_code, hours.from, hours.to
);
}
AbsenceEntry::Exkurze {
teacher,
teacher_code,
} => {
println!(" {} ({}): Excursion", teacher, teacher_code);
}
AbsenceEntry::Zastoupen {
teacher,
teacher_code,
zastupuje,
} => {
println!(
" {} ({}): Represented by {} ({})",
teacher, teacher_code, zastupuje.teacher, zastupuje.teacher_code
);
}
AbsenceEntry::Invalid { original } => {
println!(" Invalid entry: {}", original);
}
}
}

View File

@@ -1,93 +1,25 @@
use chrono::{Datelike, Local, NaiveDate, Weekday};
use once_cell::sync::Lazy;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::{Once, RwLock};
use tokio::runtime::Runtime;
use tracing::{debug, error, info};
mod api;
mod models;
mod schedule;
mod teacher_absence;
use std::sync::RwLock;
pub use models::*;
uniffi::setup_scaffolding!();
static RUNTIME: Lazy<Runtime> =
Lazy::new(|| Runtime::new().expect("Failed to create Tokio runtime"));
static INIT_TRACING: Once = Once::new();
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum SuplError {
#[error("Network error: {reason}")]
NetworkError { reason: String },
#[error("Parse error: {reason}")]
ParseError { reason: String },
#[error("Invalid date format: {reason}")]
DateFormatError { reason: String },
#[error("Internal runtime error: {reason}")]
RuntimeError { reason: String },
}
#[derive(Deserialize)]
struct ApiResponse {
schedule: Vec<HashMap<String, serde_json::Value>>,
props: Vec<DayProp>,
status: Status,
}
#[derive(Deserialize)]
struct DayProp {
date: String,
#[allow(dead_code)]
priprava: bool,
}
#[derive(Debug, Deserialize, uniffi::Record)]
pub struct Status {
#[serde(rename = "lastUpdated")]
pub last_updated: String,
#[serde(rename = "currentUpdateSchedule")]
pub current_update_schedule: u16,
}
#[derive(uniffi::Record)]
pub struct SuplResult {
pub status: Status,
pub schedule: Vec<DailySchedule>,
}
#[derive(uniffi::Record)]
pub struct DailySchedule {
pub date: String,
pub lessons: Vec<Option<String>>,
}
#[derive(uniffi::Object)]
pub struct JecnaSuplClient {
provider_url: RwLock<String>,
client: reqwest::Client,
}
#[uniffi::export]
impl JecnaSuplClient {
#[uniffi::constructor]
pub fn new() -> Self {
INIT_TRACING.call_once(|| {
#[cfg(target_os = "android")]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("JecnaSuplClient"),
);
#[cfg(not(target_os = "android"))]
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.init();
});
info!("Initializing JecnaSuplClient");
Self {
provider_url: RwLock::new("https://jecnarozvrh.jzitnik.dev".to_string()),
client: reqwest::Client::new(),
}
}
@@ -97,143 +29,12 @@ impl JecnaSuplClient {
}
pub fn get_schedule(&self, class_name: String) -> Result<SuplResult, SuplError> {
let url = {
let provider = self.provider_url.read().unwrap();
let trimmed = provider.trim_end_matches('/');
format!("{}/versioned/v2", trimmed)
};
debug!("Target URL: {}", url);
let client = self.client.clone();
let class_name_clone = class_name.clone();
let (tx, rx) = std::sync::mpsc::channel();
RUNTIME.spawn(async move {
let result = async {
let resp = client.get(&url).send().await.map_err(|e| {
error!("Network request failed: {}", e);
SuplError::NetworkError {
reason: e.to_string(),
}
})?;
let resp_text = resp.text().await.map_err(|e| {
error!("Failed to read response text: {}", e);
SuplError::NetworkError {
reason: e.to_string(),
}
})?;
if resp_text.trim().is_empty() {
error!("Received empty response body from {}", url);
return Err(SuplError::ParseError {
reason: "Empty response body".to_string(),
});
schedule::get_schedule_impl(&provider, class_name)
}
let resp: ApiResponse = serde_json::from_str(&resp_text).map_err(|e| {
error!("JSON parse error: {}. Position: line {}, col {}. Response content: {}", e, e.line(), e.column(), resp_text);
SuplError::ParseError {
reason: e.to_string(),
}
})?;
debug!("Successfully parsed response");
Ok::<ApiResponse, SuplError>(resp)
}
.await;
let _ = tx.send(result);
});
let result = rx.recv().map_err(|e| {
error!("Mpsc receive error: {}", e);
SuplError::RuntimeError {
reason: "Channel closed".to_string(),
}
})??;
let today = Local::now().date_naive();
let current_weekday = today.weekday();
debug!("Current date: {}, weekday: {:?}", today, current_weekday);
let start_date;
let end_date;
match current_weekday {
Weekday::Sat | Weekday::Sun => {
// Next Monday
let days_until_mon = 7 - current_weekday.num_days_from_monday();
let next_mon = today + chrono::Duration::days(days_until_mon as i64);
start_date = next_mon;
end_date = next_mon + chrono::Duration::days(4); // Mon -> Fri is 4 days diff
info!(
"Weekend detected. Showing next week: {} to {}",
start_date, end_date
);
}
_ => {
// Current Monday
let days_from_mon = current_weekday.num_days_from_monday();
let curr_mon = today - chrono::Duration::days(days_from_mon as i64);
start_date = curr_mon;
end_date = curr_mon + chrono::Duration::days(4);
info!(
"Weekday detected. Showing current week: {} to {}",
start_date, end_date
);
}
}
let mut output = Vec::new();
for (i, day_prop) in result.props.iter().enumerate() {
let date = NaiveDate::parse_from_str(&day_prop.date, "%Y-%m-%d").map_err(|e| {
error!("Date parsing error for {}: {}", day_prop.date, e);
SuplError::DateFormatError {
reason: e.to_string(),
}
})?;
if date >= start_date && date <= end_date {
if let Some(day_schedule_map) = result.schedule.get(i) {
if let Some(class_schedule_val) = day_schedule_map.get(&class_name_clone) {
match serde_json::from_value::<Vec<Option<String>>>(
class_schedule_val.clone(),
) {
Ok(lessons) => {
output.push(DailySchedule {
date: day_prop.date.clone(),
lessons,
});
}
Err(e) => {
error!(
"Failed to parse schedule for class {}: {}",
class_name_clone, e
);
return Err(SuplError::ParseError {
reason: format!(
"Invalid schedule format for class {}",
class_name_clone
),
});
}
}
} else {
debug!(
"Class {} not found in schedule for {}",
class_name_clone, date
);
}
}
}
}
Ok(SuplResult {
status: result.status,
schedule: output,
})
pub fn get_teacher_absence(&self) -> Result<TeacherAbsenceResult, SuplError> {
let provider = self.provider_url.read().unwrap();
teacher_absence::get_teacher_absence_impl(&provider)
}
}

127
src/models.rs Normal file
View File

@@ -0,0 +1,127 @@
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum SuplError {
#[error("Network error: {reason}")]
NetworkError { reason: String },
#[error("Parse error: {reason}")]
ParseError { reason: String },
#[error("Invalid date format: {reason}")]
DateFormatError { reason: String },
#[error("Internal runtime error: {reason}")]
RuntimeError { reason: String },
}
#[derive(Deserialize, Debug, uniffi::Enum, Clone)]
#[serde(tag = "type")]
pub enum AbsenceEntry {
#[serde(rename = "wholeDay")]
WholeDay {
teacher: String,
#[serde(rename = "teacherCode")]
teacher_code: String,
},
#[serde(rename = "single")]
Single {
teacher: String,
#[serde(rename = "teacherCode")]
teacher_code: String,
hours: u16,
},
#[serde(rename = "range")]
Range {
teacher: String,
#[serde(rename = "teacherCode")]
teacher_code: String,
hours: AbsenceRange,
},
#[serde(rename = "exkurze")]
Exkurze {
teacher: String,
#[serde(rename = "teacherCode")]
teacher_code: String,
},
#[serde(rename = "zastoupen")]
Zastoupen {
teacher: String,
#[serde(rename = "teacherCode")]
teacher_code: String,
zastupuje: SubstituteInfo,
},
#[serde(rename = "invalid")]
Invalid { original: String },
}
#[derive(Deserialize, Debug, uniffi::Record, Clone)]
pub struct AbsenceRange {
pub from: u16,
pub to: u16,
}
#[derive(Deserialize, Debug, uniffi::Record, Clone)]
pub struct SubstituteInfo {
pub teacher: String,
#[serde(rename = "teacherCode")]
pub teacher_code: String,
}
#[derive(Deserialize, Debug, uniffi::Record, Clone)]
pub struct ChangeEntry {
pub text: String,
#[serde(rename = "backgroundColor")]
pub background_color: Option<String>,
#[serde(rename = "willBeSpecified")]
pub will_be_specified: Option<bool>,
}
#[derive(Deserialize)]
pub struct ApiResponse {
pub status: Status,
pub schedule: HashMap<String, DailyData>,
}
#[derive(Deserialize, Clone)]
pub struct DailyData {
pub info: DayInfo,
pub changes: HashMap<String, Vec<Option<ChangeEntry>>>,
pub absence: Vec<AbsenceEntry>,
#[serde(rename = "takesPlace")]
pub takes_place: String,
#[serde(rename = "reservedRooms")]
pub reserved_rooms: Vec<String>,
}
#[derive(Deserialize, Debug, uniffi::Record, Clone)]
pub struct DayInfo {
#[serde(rename = "inWork")]
pub in_work: bool,
}
#[derive(Debug, Deserialize, uniffi::Record, Clone)]
pub struct Status {
#[serde(rename = "lastUpdated")]
pub last_updated: String,
#[serde(rename = "currentUpdateSchedule")]
pub current_update_schedule: u16,
}
#[derive(uniffi::Record)]
pub struct SuplResult {
pub status: Status,
pub schedule: HashMap<String, DailySchedule>,
}
#[derive(uniffi::Record)]
pub struct DailySchedule {
pub info: DayInfo,
pub changes: Vec<Option<ChangeEntry>>,
pub absence: Vec<AbsenceEntry>,
pub takes_place: String,
}
#[derive(uniffi::Record)]
pub struct TeacherAbsenceResult {
pub absences: HashMap<String, Vec<AbsenceEntry>>,
}

29
src/schedule.rs Normal file
View File

@@ -0,0 +1,29 @@
use std::collections::HashMap;
use crate::api::fetch_api;
use crate::models::{DailySchedule, SuplError, SuplResult};
pub fn get_schedule_impl(provider_url: &str, class_name: String) -> Result<SuplResult, SuplError> {
let resp = fetch_api(provider_url)?;
let mut schedule_output = HashMap::new();
for (date, daily_data) in resp.schedule {
if let Some(class_changes) = daily_data.changes.get(&class_name) {
schedule_output.insert(
date,
DailySchedule {
info: daily_data.info,
changes: class_changes.clone(),
absence: daily_data.absence,
takes_place: daily_data.takes_place,
},
);
}
}
Ok(SuplResult {
status: resp.status,
schedule: schedule_output,
})
}

16
src/teacher_absence.rs Normal file
View File

@@ -0,0 +1,16 @@
use std::collections::HashMap;
use crate::api::fetch_api;
use crate::models::{SuplError, TeacherAbsenceResult};
pub fn get_teacher_absence_impl(provider_url: &str) -> Result<TeacherAbsenceResult, SuplError> {
let resp = fetch_api(provider_url)?;
let mut output = HashMap::new();
for (date, daily_data) in resp.schedule {
output.insert(date, daily_data.absence);
}
Ok(TeacherAbsenceResult { absences: output })
}