Skip to main content

librus_rs/
lib.rs

1//! # librus-rs
2//!
3//! Rust client for [Librus Synergia](https://synergia.librus.pl/) - the Polish school diary system.
4//!
5//! This crate provides an async API client for accessing student grades, attendance,
6//! messages, and other data from Librus Synergia.
7//!
8//! # Quick Start
9//!
10//! ```rust,no_run
11//! use librus_rs::Client;
12//!
13//! #[tokio::main]
14//! async fn main() -> Result<(), librus_rs::Error> {
15//!     // Create client from environment variables
16//!     let mut client = Client::from_env().await?;
17//!
18//!     // Fetch grades
19//!     let grades = client.grades().await?;
20//!     for grade in grades.grades {
21//!         println!("{}: {}", grade.date, grade.grade);
22//!     }
23//!
24//!     // Fetch unread message count
25//!     let unread = client.unread_counts().await?;
26//!     println!("Unread messages: {}", unread.inbox);
27//!
28//!     Ok(())
29//! }
30//! ```
31//!
32//! # Client Construction
33//!
34//! There are three ways to create a [`Client`]:
35//!
36//! ## From Environment Variables
37//!
38//! Reads `LIBRUS_USERNAME` and `LIBRUS_PASSWORD` from the environment:
39//!
40//! ```rust,no_run
41//! use librus_rs::Client;
42//!
43//! # async fn example() -> Result<(), librus_rs::Error> {
44//! let client = Client::from_env().await?;
45//! # Ok(())
46//! # }
47//! ```
48//!
49//! ## With Explicit Credentials
50//!
51//! ```rust,no_run
52//! use librus_rs::Client;
53//!
54//! # async fn example() -> Result<(), librus_rs::Error> {
55//! let client = Client::new("username", "password").await?;
56//! # Ok(())
57//! # }
58//! ```
59//!
60//! ## Using the Builder Pattern
61//!
62//! ```rust,no_run
63//! use librus_rs::Client;
64//!
65//! # async fn example() -> Result<(), librus_rs::Error> {
66//! let client = Client::builder()
67//!     .username("username")
68//!     .password("password")
69//!     .build()
70//!     .await?;
71//! # Ok(())
72//! # }
73//! ```
74//!
75//! # API Overview
76//!
77//! The client provides access to two APIs:
78//!
79//! ## Synergia API
80//!
81//! Academic data including grades, attendance, lessons, and users.
82//!
83//! | Method | Description |
84//! |--------|-------------|
85//! | [`Client::me()`] | Current user info |
86//! | [`Client::grades()`] | All grades |
87//! | [`Client::grade_category()`] | Grade category by ID |
88//! | [`Client::grade_comment()`] | Grade comment by ID |
89//! | [`Client::lesson()`] | Lesson info by ID |
90//! | [`Client::subject()`] | Subject info by ID |
91//! | [`Client::attendances()`] | All attendances |
92//! | [`Client::attendance_types()`] | Attendance types |
93//! | [`Client::homeworks()`] | All homeworks |
94//! | [`Client::school_notices()`] | School notices (announcements) |
95//! | [`Client::user()`] | User by ID |
96//! | [`Client::current_user()`] | Current user details |
97//!
98//! ## Messages API
99//!
100//! Internal messaging system.
101//!
102//! | Method | Description |
103//! |--------|-------------|
104//! | [`Client::unread_counts()`] | Unread message counts |
105//! | [`Client::inbox_messages()`] | Received messages |
106//! | [`Client::outbox_messages()`] | Sent messages |
107//! | [`Client::message()`] | Full message details |
108//! | [`Client::attachment()`] | Download attachment |
109//!
110//! # Error Handling
111//!
112//! All API methods return `Result<T, Error>`. See [`Error`] for possible error variants.
113//!
114//! ```rust,no_run
115//! use librus_rs::{Client, Error};
116//!
117//! # async fn example() {
118//! let result = Client::from_env().await;
119//! match result {
120//!     Ok(client) => println!("Authenticated successfully"),
121//!     Err(Error::MissingEnvVar(var)) => eprintln!("Missing: {}", var),
122//!     Err(Error::Authentication) => eprintln!("Invalid credentials"),
123//!     Err(e) => eprintln!("Error: {}", e),
124//! }
125//! # }
126//! ```
127
128mod error;
129mod structs;
130
131use reqwest::Client as HttpClient;
132
133pub use crate::error::Error;
134pub use crate::structs::announcements::{ResponseSchoolNotices, SchoolNotice};
135pub use crate::structs::events::{Homework, ResponseHomeworks};
136pub use crate::structs::grades::{
137    Grade, GradeCategory, GradeComment, ResponseGrades, ResponseGradesCategories,
138    ResponseGradesComments,
139};
140pub use crate::structs::lessons::{
141    Attendance, AttendanceType, Lesson, LessonSubject, ResponseAttendances,
142    ResponseAttendancesType, ResponseLesson, ResponseLessonSubject,
143};
144pub use crate::structs::me::{Me, ResponseMe};
145pub use crate::structs::messages::{
146    Attachment, InboxMessage, MessageDetail, OutboxMessage, UnreadCounts,
147};
148pub use crate::structs::users::{ResponseUser, User};
149
150use crate::structs::messages::{
151    ResponseInboxMessages, ResponseMessageDetail, ResponseOutboxMessages, ResponseUnreadCounts,
152};
153
154/// A specialized `Result` type for librus-rs operations.
155pub type Result<T> = std::result::Result<T, Error>;
156
157const SYNERGIA_API_BASE: &str = "https://synergia.librus.pl/gateway/api/2.0/";
158const MESSAGES_API_BASE: &str = "https://wiadomosci.librus.pl/api/";
159const AUTH_URL: &str = "https://api.librus.pl/OAuth/Authorization?client_id=46";
160const AUTH_TEST_URL: &str =
161    "https://api.librus.pl/OAuth/Authorization?client_id=46&response_type=code&scope=mydata";
162const AUTH_GRANT_URL: &str = "https://api.librus.pl/OAuth/Authorization/Grant?client_id=46";
163const TOKEN_INFO_URL: &str = "https://synergia.librus.pl/gateway/api/2.0/Auth/TokenInfo/";
164const MESSAGES_INIT_URL: &str = "https://synergia.librus.pl/wiadomosci3";
165
166/// Builder for creating a [`Client`] instance with custom configuration.
167///
168/// # Example
169///
170/// ```rust,no_run
171/// use librus_rs::ClientBuilder;
172///
173/// # async fn example() -> Result<(), librus_rs::Error> {
174/// let client = ClientBuilder::new()
175///     .username("my_username")
176///     .password("my_password")
177///     .build()
178///     .await?;
179/// # Ok(())
180/// # }
181/// ```
182#[derive(Default)]
183pub struct ClientBuilder {
184    username: Option<String>,
185    password: Option<String>,
186}
187
188impl ClientBuilder {
189    /// Creates a new builder instance with no credentials set.
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Sets the Librus username.
195    ///
196    /// # Example
197    ///
198    /// ```rust
199    /// use librus_rs::ClientBuilder;
200    ///
201    /// let builder = ClientBuilder::new().username("my_username");
202    /// ```
203    pub fn username(mut self, username: impl Into<String>) -> Self {
204        self.username = Some(username.into());
205        self
206    }
207
208    /// Sets the Librus password.
209    ///
210    /// # Example
211    ///
212    /// ```rust
213    /// use librus_rs::ClientBuilder;
214    ///
215    /// let builder = ClientBuilder::new()
216    ///     .username("my_username")
217    ///     .password("my_password");
218    /// ```
219    pub fn password(mut self, password: impl Into<String>) -> Self {
220        self.password = Some(password.into());
221        self
222    }
223
224    /// Builds and authenticates the client.
225    ///
226    /// This method consumes the builder and attempts to authenticate with Librus.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if:
231    /// - Username is missing ([`Error::MissingCredentials`])
232    /// - Password is missing ([`Error::MissingCredentials`])
233    /// - Authentication fails ([`Error::Authentication`])
234    /// - Network error occurs ([`Error::Request`])
235    ///
236    /// # Example
237    ///
238    /// ```rust,no_run
239    /// use librus_rs::ClientBuilder;
240    ///
241    /// # async fn example() -> Result<(), librus_rs::Error> {
242    /// let client = ClientBuilder::new()
243    ///     .username("my_username")
244    ///     .password("my_password")
245    ///     .build()
246    ///     .await?;
247    /// # Ok(())
248    /// # }
249    /// ```
250    pub async fn build(self) -> Result<Client> {
251        let username = self.username.ok_or(Error::MissingCredentials("username"))?;
252        let password = self.password.ok_or(Error::MissingCredentials("password"))?;
253        Client::authenticate(&username, &password).await
254    }
255}
256
257/// An authenticated Librus API client.
258///
259/// This is the main entry point for interacting with Librus Synergia.
260/// Create a client using one of the constructor methods, then call API methods
261/// to fetch data.
262///
263/// # Example
264///
265/// ```rust,no_run
266/// use librus_rs::Client;
267///
268/// #[tokio::main]
269/// async fn main() -> Result<(), librus_rs::Error> {
270///     let mut client = Client::from_env().await?;
271///
272///     // Fetch user info
273///     let me = client.me().await?;
274///     println!("Logged in as: {} {}", me.me.user.first_name, me.me.user.last_name);
275///
276///     // Fetch grades
277///     let grades = client.grades().await?;
278///     println!("Total grades: {}", grades.grades.len());
279///
280///     Ok(())
281/// }
282/// ```
283pub struct Client {
284    http: HttpClient,
285    messages_initialized: bool,
286}
287
288impl Client {
289    /// Creates a new client from environment variables.
290    ///
291    /// Reads `LIBRUS_USERNAME` and `LIBRUS_PASSWORD` from the environment
292    /// and authenticates with Librus.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if:
297    /// - `LIBRUS_USERNAME` is not set ([`Error::MissingEnvVar`])
298    /// - `LIBRUS_PASSWORD` is not set ([`Error::MissingEnvVar`])
299    /// - Authentication fails ([`Error::Authentication`])
300    ///
301    /// # Example
302    ///
303    /// ```rust,no_run
304    /// use librus_rs::Client;
305    ///
306    /// # async fn example() -> Result<(), librus_rs::Error> {
307    /// // Ensure LIBRUS_USERNAME and LIBRUS_PASSWORD are set
308    /// let client = Client::from_env().await?;
309    /// # Ok(())
310    /// # }
311    /// ```
312    pub async fn from_env() -> Result<Self> {
313        let username = std::env::var("LIBRUS_USERNAME")
314            .map_err(|_| Error::MissingEnvVar("LIBRUS_USERNAME"))?;
315        let password = std::env::var("LIBRUS_PASSWORD")
316            .map_err(|_| Error::MissingEnvVar("LIBRUS_PASSWORD"))?;
317        Self::authenticate(&username, &password).await
318    }
319
320    /// Creates a new client with explicit credentials.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if authentication fails ([`Error::Authentication`])
325    /// or a network error occurs ([`Error::Request`]).
326    ///
327    /// # Example
328    ///
329    /// ```rust,no_run
330    /// use librus_rs::Client;
331    ///
332    /// # async fn example() -> Result<(), librus_rs::Error> {
333    /// let client = Client::new("username", "password").await?;
334    /// # Ok(())
335    /// # }
336    /// ```
337    pub async fn new(username: &str, password: &str) -> Result<Self> {
338        Self::authenticate(username, password).await
339    }
340
341    /// Creates a builder for configuring the client.
342    ///
343    /// # Example
344    ///
345    /// ```rust,no_run
346    /// use librus_rs::Client;
347    ///
348    /// # async fn example() -> Result<(), librus_rs::Error> {
349    /// let client = Client::builder()
350    ///     .username("username")
351    ///     .password("password")
352    ///     .build()
353    ///     .await?;
354    /// # Ok(())
355    /// # }
356    /// ```
357    pub fn builder() -> ClientBuilder {
358        ClientBuilder::new()
359    }
360
361    async fn authenticate(username: &str, password: &str) -> Result<Self> {
362        let http = HttpClient::builder()
363            .cookie_store(true)
364            .build()
365            .map_err(Error::HttpClient)?;
366
367        let form_params = [("action", "login"), ("login", username), ("pass", password)];
368
369        http.get(AUTH_TEST_URL)
370            .send()
371            .await
372            .map_err(Error::Request)?;
373
374        http.post(AUTH_URL)
375            .form(&form_params)
376            .send()
377            .await
378            .map_err(Error::Request)?;
379
380        http.get(AUTH_GRANT_URL)
381            .send()
382            .await
383            .map_err(Error::Request)?;
384
385        let token_response = http
386            .get(TOKEN_INFO_URL)
387            .send()
388            .await
389            .map_err(Error::Request)?;
390
391        if token_response.status() != 200 {
392            return Err(Error::Authentication);
393        }
394
395        Ok(Self {
396            http,
397            messages_initialized: false,
398        })
399    }
400
401    async fn get_api(&self, endpoint: &str) -> Result<String> {
402        let url = format!("{}{}", SYNERGIA_API_BASE, endpoint);
403        let response = self
404            .http
405            .get(&url)
406            .header("Content-Type", "application/json")
407            .send()
408            .await
409            .map_err(Error::Request)?;
410
411        let status = response.status();
412        let text = response.text().await.map_err(Error::Request)?;
413
414        if !status.is_success() {
415            return Err(Error::ApiError {
416                status: status.as_u16(),
417                body: text,
418            });
419        }
420
421        Ok(text)
422    }
423
424    async fn get_messages_api(&self, endpoint: &str) -> Result<String> {
425        let url = format!("{}{}", MESSAGES_API_BASE, endpoint);
426        let response = self.http.get(&url).send().await.map_err(Error::Request)?;
427
428        let status = response.status();
429        let text = response.text().await.map_err(Error::Request)?;
430
431        if !status.is_success() {
432            return Err(Error::ApiError {
433                status: status.as_u16(),
434                body: text,
435            });
436        }
437
438        Ok(text)
439    }
440
441    async fn ensure_messages_initialized(&mut self) -> Result<()> {
442        if self.messages_initialized {
443            return Ok(());
444        }
445        self.http
446            .get(MESSAGES_INIT_URL)
447            .send()
448            .await
449            .map_err(Error::Request)?;
450        self.messages_initialized = true;
451        Ok(())
452    }
453
454    /// Gets current user information.
455    ///
456    /// Returns account details, user profile, and class information.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if the request fails or response parsing fails.
461    ///
462    /// # Example
463    ///
464    /// ```rust,no_run
465    /// use librus_rs::Client;
466    ///
467    /// # async fn example() -> Result<(), librus_rs::Error> {
468    /// let client = Client::from_env().await?;
469    /// let me = client.me().await?;
470    /// println!("User: {} {}", me.me.user.first_name, me.me.user.last_name);
471    /// println!("Email: {}", me.me.account.email);
472    /// # Ok(())
473    /// # }
474    /// ```
475    pub async fn me(&self) -> Result<ResponseMe> {
476        let json = self.get_api("Me").await?;
477        serde_json::from_str(&json).map_err(|e| Error::Parse {
478            source: e,
479            body: json,
480        })
481    }
482
483    /// Gets all grades for the student.
484    ///
485    /// Returns a list of all grades across all subjects.
486    ///
487    /// # Errors
488    ///
489    /// Returns an error if the request fails or response parsing fails.
490    ///
491    /// # Example
492    ///
493    /// ```rust,no_run
494    /// use librus_rs::Client;
495    ///
496    /// # async fn example() -> Result<(), librus_rs::Error> {
497    /// let client = Client::from_env().await?;
498    /// let grades = client.grades().await?;
499    /// for grade in grades.grades {
500    ///     println!("{}: {} ({})", grade.date, grade.grade, grade.semester);
501    /// }
502    /// # Ok(())
503    /// # }
504    /// ```
505    pub async fn grades(&self) -> Result<ResponseGrades> {
506        let json = self.get_api("Grades").await?;
507        serde_json::from_str(&json).map_err(|e| Error::Parse {
508            source: e,
509            body: json,
510        })
511    }
512
513    /// Gets a grade category by ID.
514    ///
515    /// Categories describe the type of grade (e.g., test, homework, quiz).
516    ///
517    /// # Arguments
518    ///
519    /// * `id` - The category ID from a [`Grade`]'s `category` field
520    ///
521    /// # Errors
522    ///
523    /// Returns an error if the request fails or the category is not found.
524    ///
525    /// # Example
526    ///
527    /// ```rust,no_run
528    /// use librus_rs::Client;
529    ///
530    /// # async fn example() -> Result<(), librus_rs::Error> {
531    /// let client = Client::from_env().await?;
532    /// let category = client.grade_category(123).await?;
533    /// println!("Category: {}", category.category.name);
534    /// # Ok(())
535    /// # }
536    /// ```
537    pub async fn grade_category(&self, id: i32) -> Result<ResponseGradesCategories> {
538        let json = self.get_api(&format!("Grades/Categories/{}", id)).await?;
539        serde_json::from_str(&json).map_err(|e| Error::Parse {
540            source: e,
541            body: json,
542        })
543    }
544
545    /// Gets a grade comment by ID.
546    ///
547    /// Comments provide additional context for a grade.
548    ///
549    /// # Arguments
550    ///
551    /// * `id` - The comment ID from a [`Grade`]'s `comments` field
552    ///
553    /// # Errors
554    ///
555    /// Returns an error if the request fails or the comment is not found.
556    ///
557    /// # Example
558    ///
559    /// ```rust,no_run
560    /// use librus_rs::Client;
561    ///
562    /// # async fn example() -> Result<(), librus_rs::Error> {
563    /// let client = Client::from_env().await?;
564    /// let comment = client.grade_comment(456).await?;
565    /// if let Some(c) = comment.comment {
566    ///     println!("Comment: {}", c.text);
567    /// }
568    /// # Ok(())
569    /// # }
570    /// ```
571    pub async fn grade_comment(&self, id: i32) -> Result<ResponseGradesComments> {
572        let json = self.get_api(&format!("Grades/Comments/{}", id)).await?;
573        serde_json::from_str(&json).map_err(|e| Error::Parse {
574            source: e,
575            body: json,
576        })
577    }
578
579    /// Gets a lesson by ID.
580    ///
581    /// Lessons contain information about which teacher teaches which subject to which class.
582    ///
583    /// # Arguments
584    ///
585    /// * `id` - The lesson ID
586    ///
587    /// # Errors
588    ///
589    /// Returns an error if the request fails or the lesson is not found.
590    ///
591    /// # Example
592    ///
593    /// ```rust,no_run
594    /// use librus_rs::Client;
595    ///
596    /// # async fn example() -> Result<(), librus_rs::Error> {
597    /// let client = Client::from_env().await?;
598    /// let lesson = client.lesson(789).await?;
599    /// println!("Lesson ID: {}", lesson.lesson.id);
600    /// # Ok(())
601    /// # }
602    /// ```
603    pub async fn lesson(&self, id: i32) -> Result<ResponseLesson> {
604        let json = self.get_api(&format!("Lessons/{}", id)).await?;
605        serde_json::from_str(&json).map_err(|e| Error::Parse {
606            source: e,
607            body: json,
608        })
609    }
610
611    /// Gets a subject by ID.
612    ///
613    /// Subjects contain the name and short code for academic subjects.
614    ///
615    /// # Arguments
616    ///
617    /// * `id` - The subject ID
618    ///
619    /// # Errors
620    ///
621    /// Returns an error if the request fails or the subject is not found.
622    ///
623    /// # Example
624    ///
625    /// ```rust,no_run
626    /// use librus_rs::Client;
627    ///
628    /// # async fn example() -> Result<(), librus_rs::Error> {
629    /// let client = Client::from_env().await?;
630    /// let subject = client.subject(101).await?;
631    /// if let Some(s) = subject.subject {
632    ///     println!("Subject: {} ({})", s.name, s.short);
633    /// }
634    /// # Ok(())
635    /// # }
636    /// ```
637    pub async fn subject(&self, id: i32) -> Result<ResponseLessonSubject> {
638        let json = self.get_api(&format!("Subjects/{}", id)).await?;
639        serde_json::from_str(&json).map_err(|e| Error::Parse {
640            source: e,
641            body: json,
642        })
643    }
644
645    /// Gets all attendances for the student.
646    ///
647    /// Returns attendance records for all lessons.
648    ///
649    /// # Errors
650    ///
651    /// Returns an error if the request fails or response parsing fails.
652    ///
653    /// # Example
654    ///
655    /// ```rust,no_run
656    /// use librus_rs::Client;
657    ///
658    /// # async fn example() -> Result<(), librus_rs::Error> {
659    /// let client = Client::from_env().await?;
660    /// let attendances = client.attendances().await?;
661    /// println!("Total records: {}", attendances.attendances.len());
662    /// # Ok(())
663    /// # }
664    /// ```
665    pub async fn attendances(&self) -> Result<ResponseAttendances> {
666        let json = self.get_api("Attendances/").await?;
667        serde_json::from_str(&json).map_err(|e| Error::Parse {
668            source: e,
669            body: json,
670        })
671    }
672
673    /// Gets all attendance types.
674    ///
675    /// Types describe the kind of attendance (present, absent, late, etc.).
676    ///
677    /// # Errors
678    ///
679    /// Returns an error if the request fails or response parsing fails.
680    ///
681    /// # Example
682    ///
683    /// ```rust,no_run
684    /// use librus_rs::Client;
685    ///
686    /// # async fn example() -> Result<(), librus_rs::Error> {
687    /// let client = Client::from_env().await?;
688    /// let types = client.attendance_types().await?;
689    /// for t in types.types {
690    ///     println!("{}: {} ({})", t.id, t.name, t.short);
691    /// }
692    /// # Ok(())
693    /// # }
694    /// ```
695    pub async fn attendance_types(&self) -> Result<ResponseAttendancesType> {
696        let json = self.get_api("Attendances/Types/").await?;
697        serde_json::from_str(&json).map_err(|e| Error::Parse {
698            source: e,
699            body: json,
700        })
701    }
702
703    /// Gets all homeworks.
704    ///
705    /// Returns a list of all homework assignments.
706    ///
707    /// # Errors
708    ///
709    /// Returns an error if the request fails or response parsing fails.
710    ///
711    /// # Example
712    ///
713    /// ```rust,no_run
714    /// use librus_rs::Client;
715    ///
716    /// # async fn example() -> Result<(), librus_rs::Error> {
717    /// let client = Client::from_env().await?;
718    /// let homeworks = client.homeworks().await?;
719    /// for hw in homeworks.homeworks {
720    ///     println!("{}: {}", hw.date, hw.content);
721    /// }
722    /// # Ok(())
723    /// # }
724    /// ```
725    pub async fn homeworks(&self) -> Result<ResponseHomeworks> {
726        let json = self.get_api("HomeWorks/").await?;
727        serde_json::from_str(&json).map_err(|e| Error::Parse {
728            source: e,
729            body: json,
730        })
731    }
732
733    /// Gets school notices (announcements).
734    ///
735    /// Returns a list of school notices.
736    ///
737    /// # Errors
738    ///
739    /// Returns an error if the request fails or response parsing fails.
740    ///
741    /// # Example
742    ///
743    /// ```rust,no_run
744    /// use librus_rs::Client;
745    ///
746    /// # async fn example() -> Result<(), librus_rs::Error> {
747    /// let client = Client::from_env().await?;
748    /// let notices = client.school_notices().await?;
749    /// for notice in notices.school_notices {
750    ///     println!("{}: {}", notice.creation_date, notice.subject);
751    /// }
752    /// # Ok(())
753    /// # }
754    /// ```
755    pub async fn school_notices(&self) -> Result<ResponseSchoolNotices> {
756        let json = self.get_api("SchoolNotices").await?;
757        serde_json::from_str(&json).map_err(|e| Error::Parse {
758            source: e,
759            body: json,
760        })
761    }
762
763    /// Gets school notices (announcements) with pagination.
764    ///
765    /// # Arguments
766    ///
767    /// * `page` - Page number (1-indexed)
768    /// * `limit` - Number of notices per page
769    ///
770    /// # Errors
771    ///
772    /// Returns an error if the request fails or response parsing fails.
773    pub async fn school_notices_page(
774        &self,
775        page: u32,
776        limit: u32,
777    ) -> Result<ResponseSchoolNotices> {
778        let endpoint = format!("SchoolNotices?page={}&limit={}", page, limit);
779        let json = self.get_api(&endpoint).await?;
780        serde_json::from_str(&json).map_err(|e| Error::Parse {
781            source: e,
782            body: json,
783        })
784    }
785
786    /// Gets the latest school notices (announcements).
787    ///
788    /// This paginates through all notices, sorts them by `creation_date` (descending),
789    /// and returns the newest `limit` items.
790    ///
791    /// # Errors
792    ///
793    /// Returns an error if the request fails or response parsing fails.
794    pub async fn school_notices_latest(&self, limit: usize) -> Result<Vec<SchoolNotice>> {
795        if limit == 0 {
796            return Ok(Vec::new());
797        }
798
799        let page_size: u32 = 50;
800        let mut page = 1;
801        let mut all = Vec::new();
802
803        loop {
804            let resp = self.school_notices_page(page, page_size).await?;
805            if resp.school_notices.is_empty() {
806                break;
807            }
808
809            let count = resp.school_notices.len();
810            all.extend(resp.school_notices);
811
812            if count < page_size as usize {
813                break;
814            }
815
816            page += 1;
817        }
818
819        all.sort_by(|a, b| b.creation_date.cmp(&a.creation_date));
820        all.truncate(limit);
821        Ok(all)
822    }
823
824    /// Gets a user by ID.
825    ///
826    /// Users include teachers, students, and parents.
827    ///
828    /// # Arguments
829    ///
830    /// * `id` - The user ID
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if the request fails or the user is not found.
835    ///
836    /// # Example
837    ///
838    /// ```rust,no_run
839    /// use librus_rs::Client;
840    ///
841    /// # async fn example() -> Result<(), librus_rs::Error> {
842    /// let client = Client::from_env().await?;
843    /// let user = client.user(12345).await?;
844    /// if let Some(u) = user.user {
845    ///     println!("{} {}", u.first_name, u.last_name);
846    /// }
847    /// # Ok(())
848    /// # }
849    /// ```
850    pub async fn user(&self, id: i32) -> Result<ResponseUser> {
851        let json = self.get_api(&format!("Users/{}", id)).await?;
852        serde_json::from_str(&json).map_err(|e| Error::Parse {
853            source: e,
854            body: json,
855        })
856    }
857
858    /// Gets current user details.
859    ///
860    /// Returns detailed information about the authenticated user.
861    ///
862    /// # Errors
863    ///
864    /// Returns an error if the request fails or response parsing fails.
865    pub async fn current_user(&self) -> Result<ResponseUser> {
866        let json = self.get_api("Users").await?;
867        serde_json::from_str(&json).map_err(|e| Error::Parse {
868            source: e,
869            body: json,
870        })
871    }
872
873    /// Gets unread message counts for all folders.
874    ///
875    /// Returns counts for inbox, notes, alerts, and other message categories.
876    ///
877    /// # Errors
878    ///
879    /// Returns an error if the request fails or response parsing fails.
880    ///
881    /// # Example
882    ///
883    /// ```rust,no_run
884    /// use librus_rs::Client;
885    ///
886    /// # async fn example() -> Result<(), librus_rs::Error> {
887    /// let mut client = Client::from_env().await?;
888    /// let counts = client.unread_counts().await?;
889    /// println!("Unread inbox: {}", counts.inbox);
890    /// println!("Unread alerts: {}", counts.alerts);
891    /// # Ok(())
892    /// # }
893    /// ```
894    pub async fn unread_counts(&mut self) -> Result<UnreadCounts> {
895        self.ensure_messages_initialized().await?;
896        let json = self.get_messages_api("inbox/unreadMessagesCount").await?;
897        let resp: ResponseUnreadCounts = serde_json::from_str(&json).map_err(|e| Error::Parse {
898            source: e,
899            body: json,
900        })?;
901        Ok(resp.data)
902    }
903
904    /// Gets inbox messages (received).
905    ///
906    /// # Arguments
907    ///
908    /// * `page` - Page number (1-indexed)
909    /// * `limit` - Number of messages per page
910    ///
911    /// # Errors
912    ///
913    /// Returns an error if the request fails or response parsing fails.
914    ///
915    /// # Example
916    ///
917    /// ```rust,no_run
918    /// use librus_rs::Client;
919    ///
920    /// # async fn example() -> Result<(), librus_rs::Error> {
921    /// let mut client = Client::from_env().await?;
922    /// let messages = client.inbox_messages(1, 10).await?;
923    /// for msg in messages {
924    ///     println!("{}: {}", msg.sender_name, msg.topic);
925    /// }
926    /// # Ok(())
927    /// # }
928    /// ```
929    pub async fn inbox_messages(&mut self, page: u32, limit: u32) -> Result<Vec<InboxMessage>> {
930        self.ensure_messages_initialized().await?;
931        let endpoint = format!("inbox/messages?page={}&limit={}", page, limit);
932        let json = self.get_messages_api(&endpoint).await?;
933        let resp: ResponseInboxMessages =
934            serde_json::from_str(&json).map_err(|e| Error::Parse {
935                source: e,
936                body: json,
937            })?;
938        Ok(resp.data)
939    }
940
941    /// Gets outbox messages (sent).
942    ///
943    /// # Arguments
944    ///
945    /// * `page` - Page number (1-indexed)
946    /// * `limit` - Number of messages per page
947    ///
948    /// # Errors
949    ///
950    /// Returns an error if the request fails or response parsing fails.
951    ///
952    /// # Example
953    ///
954    /// ```rust,no_run
955    /// use librus_rs::Client;
956    ///
957    /// # async fn example() -> Result<(), librus_rs::Error> {
958    /// let mut client = Client::from_env().await?;
959    /// let messages = client.outbox_messages(1, 10).await?;
960    /// for msg in messages {
961    ///     println!("To {}: {}", msg.receiver_name, msg.topic);
962    /// }
963    /// # Ok(())
964    /// # }
965    /// ```
966    pub async fn outbox_messages(&mut self, page: u32, limit: u32) -> Result<Vec<OutboxMessage>> {
967        self.ensure_messages_initialized().await?;
968        let endpoint = format!("outbox/messages?page={}&limit={}", page, limit);
969        let json = self.get_messages_api(&endpoint).await?;
970        let resp: ResponseOutboxMessages =
971            serde_json::from_str(&json).map_err(|e| Error::Parse {
972                source: e,
973                body: json,
974            })?;
975        Ok(resp.data)
976    }
977
978    /// Gets full message details by ID.
979    ///
980    /// Returns the complete message including body content and attachments.
981    ///
982    /// # Arguments
983    ///
984    /// * `message_id` - The message ID from an [`InboxMessage`] or [`OutboxMessage`]
985    ///
986    /// # Errors
987    ///
988    /// Returns an error if the request fails or the message is not found.
989    ///
990    /// # Example
991    ///
992    /// ```rust,no_run
993    /// use librus_rs::Client;
994    ///
995    /// # async fn example() -> Result<(), librus_rs::Error> {
996    /// let mut client = Client::from_env().await?;
997    /// let detail = client.message("12345").await?;
998    /// if let Some(content) = Client::decode_message_content(&detail.message) {
999    ///     println!("Content: {}", content);
1000    /// }
1001    /// # Ok(())
1002    /// # }
1003    /// ```
1004    pub async fn message(&mut self, message_id: &str) -> Result<MessageDetail> {
1005        self.ensure_messages_initialized().await?;
1006        let endpoint = format!("inbox/messages/{}", message_id);
1007        let json = self.get_messages_api(&endpoint).await?;
1008        let resp: ResponseMessageDetail =
1009            serde_json::from_str(&json).map_err(|e| Error::Parse {
1010                source: e,
1011                body: json,
1012            })?;
1013        Ok(resp.data)
1014    }
1015
1016    /// Downloads attachment bytes.
1017    ///
1018    /// # Arguments
1019    ///
1020    /// * `attachment_id` - The attachment ID from a [`MessageDetail`]'s attachments
1021    /// * `message_id` - The message ID containing the attachment
1022    ///
1023    /// # Errors
1024    ///
1025    /// Returns an error if the request fails or the attachment is not found.
1026    ///
1027    /// # Example
1028    ///
1029    /// ```rust,no_run
1030    /// use librus_rs::Client;
1031    /// use std::fs;
1032    ///
1033    /// # async fn example() -> Result<(), librus_rs::Error> {
1034    /// let mut client = Client::from_env().await?;
1035    /// let detail = client.message("12345").await?;
1036    /// for attachment in &detail.attachments {
1037    ///     let bytes = client.attachment(&attachment.id, &detail.message_id).await?;
1038    ///     fs::write(&attachment.name, &bytes).expect("Failed to save file");
1039    /// }
1040    /// # Ok(())
1041    /// # }
1042    /// ```
1043    pub async fn attachment(&mut self, attachment_id: &str, message_id: &str) -> Result<Vec<u8>> {
1044        self.ensure_messages_initialized().await?;
1045        let url = format!(
1046            "https://wiadomosci.librus.pl/api/attachments/{}/messages/{}",
1047            attachment_id, message_id
1048        );
1049        let response = self.http.get(&url).send().await.map_err(Error::Request)?;
1050
1051        let status = response.status();
1052        if !status.is_success() {
1053            let body = response.text().await.unwrap_or_default();
1054            return Err(Error::ApiError {
1055                status: status.as_u16(),
1056                body,
1057            });
1058        }
1059
1060        let bytes = response.bytes().await.map_err(Error::Request)?;
1061        Ok(bytes.to_vec())
1062    }
1063
1064    /// Decodes base64-encoded message content to a string.
1065    ///
1066    /// Message bodies in Librus are base64-encoded. Use this helper to decode them.
1067    ///
1068    /// # Arguments
1069    ///
1070    /// * `content` - The base64-encoded content string
1071    ///
1072    /// # Returns
1073    ///
1074    /// `Some(String)` if decoding succeeds, `None` if the content is invalid.
1075    ///
1076    /// # Example
1077    ///
1078    /// ```rust
1079    /// use librus_rs::Client;
1080    ///
1081    /// let encoded = "SGVsbG8sIFdvcmxkIQ==";
1082    /// let decoded = Client::decode_message_content(encoded);
1083    /// assert_eq!(decoded, Some("Hello, World!".to_string()));
1084    /// ```
1085    pub fn decode_message_content(content: &str) -> Option<String> {
1086        use base64::{engine::general_purpose::STANDARD, Engine};
1087        STANDARD
1088            .decode(content)
1089            .ok()
1090            .and_then(|bytes| String::from_utf8(bytes).ok())
1091    }
1092
1093    /// Formats API-provided HTML content into readable text.
1094    ///
1095    /// School notices (announcements) are often HTML-formatted. This helper removes tags
1096    /// and performs a minimal entity decode to make the content readable.
1097    ///
1098    /// # Example
1099    ///
1100    /// ```rust
1101    /// use librus_rs::Client;
1102    ///
1103    /// let html = "<p>Hello&nbsp;<b>World</b> &amp; friends</p>";
1104    /// let text = Client::notice_content_to_text(html);
1105    /// assert_eq!(text, "Hello World & friends");
1106    /// ```
1107    pub fn notice_content_to_text(content: &str) -> String {
1108        let mut out = String::with_capacity(content.len());
1109        let mut in_tag = false;
1110
1111        for ch in content.chars() {
1112            match ch {
1113                '<' => in_tag = true,
1114                '>' => in_tag = false,
1115                _ if !in_tag => out.push(ch),
1116                _ => {}
1117            }
1118        }
1119
1120        // Minimal entity decoding for common cases.
1121        let out = out
1122            .replace("&nbsp;", " ")
1123            .replace("&amp;", "&")
1124            .replace("&lt;", "<")
1125            .replace("&gt;", ">")
1126            .replace("&quot;", "\"")
1127            .replace("&#39;", "'");
1128
1129        out.trim().to_string()
1130    }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136    use base64::Engine;
1137
1138    #[test]
1139    fn test_decode_message_content() {
1140        let encoded = base64::engine::general_purpose::STANDARD.encode("Hello, World!");
1141        let decoded = Client::decode_message_content(&encoded);
1142        assert_eq!(decoded, Some("Hello, World!".to_string()));
1143    }
1144
1145    #[test]
1146    fn test_decode_invalid_content() {
1147        let decoded = Client::decode_message_content("not valid base64!!!");
1148        assert!(decoded.is_none());
1149    }
1150
1151    #[test]
1152    fn test_notice_content_to_text() {
1153        let html = "<p>Hello&nbsp;<b>World</b> &amp; friends</p>";
1154        let text = Client::notice_content_to_text(html);
1155        assert_eq!(text, "Hello World & friends");
1156    }
1157}