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 <b>World</b> & 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(" ", " ")
1123 .replace("&", "&")
1124 .replace("<", "<")
1125 .replace(">", ">")
1126 .replace(""", "\"")
1127 .replace("'", "'");
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 <b>World</b> & friends</p>";
1154 let text = Client::notice_content_to_text(html);
1155 assert_eq!(text, "Hello World & friends");
1156 }
1157}