Compare commits

...
This repository has been archived on 2024-05-23. You can view files and clone it, but cannot push or open issues or pull requests.

76 commits

Author SHA1 Message Date
778a4c52d8 print it lines by line, without table 2024-12-12 17:19:10 +01:00
9f4d90fab0 dead code 2024-12-12 17:19:10 +01:00
018d201a09 todo 2024-12-12 17:19:10 +01:00
25f91f70e7 remove warnings 2024-12-12 17:19:10 +01:00
400a927ce1
clippy 2024-12-12 09:42:59 +01:00
c0023d3a83
update dependencies 2024-12-12 08:40:05 +01:00
8e18f7d2e5
keywords 2024-09-14 14:18:38 +02:00
61a85ca1f1
bump 2024-09-14 14:18:05 +02:00
586fb4ba28
update dependencie 2024-09-13 19:30:57 +02:00
1cab8ef246
trim class from courses name (#9) 2024-09-13 19:27:29 +02:00
b621436483
format 2024-09-13 19:23:00 +02:00
090a16433d
todo 2024-09-13 19:19:37 +02:00
8e7fdaf3c8
optional shift for td/tp (#10) 2024-09-13 19:07:18 +02:00
fabcd6dac9
start date chooser (#8) 2024-09-13 19:00:18 +02:00
b6a2fec3cb
update dependencies 2024-08-25 13:52:59 +02:00
c81b07c277
fix for M2 2024-08-25 13:47:42 +02:00
181ebf53f4
some are already defaults 2024-05-29 12:02:51 +02:00
07068a0150
update dependencies and follow more clippy advices 2024-05-28 21:19:19 +02:00
0c26ed24de
valid uri 2024-01-31 00:20:37 +01:00
9269ff12db
0.9.2 release 2024-01-30 20:21:27 +01:00
52648ac61a
mention the name in the correct field, mark the professor as the chairperson and make they accept the rdv 2024-01-30 20:21:21 +01:00
ae14925d9e
0.9.1 release 2024-01-30 15:47:53 +01:00
71a47096b1
fix contact 2024-01-30 15:46:05 +01:00
dfcf1226d0
stick to RFC5545 2024-01-30 15:27:03 +01:00
e444d29a15
represent professors as "attendee" and extra data as comments 2024-01-30 15:21:42 +01:00
4d0f14d045
better usage of the lib API 2024-01-30 15:19:00 +01:00
6c9ccff574
0.9.0 release 2024-01-30 14:19:42 +01:00
28f2c2b192
extract extra data from calendar 2024-01-30 14:09:17 +01:00
4791463150
documentation 2024-01-24 09:36:21 +01:00
aafd734728
0.8.0 release 2024-01-24 09:29:10 +01:00
6bc3a5643c
option to not differentiate TD from TP (#5) 2024-01-24 09:28:43 +01:00
e0afc9414f
doc 2024-01-24 09:28:09 +01:00
d237ff3098
doc 2024-01-24 09:11:44 +01:00
8fdebbe4a1
rename calendar (#4) 2024-01-23 10:07:37 +01:00
51284fb5ae
0.7.1 release 2024-01-22 22:16:39 +01:00
3db016519f
fix Cours/TD showing when it shouldn't 2024-01-22 22:16:11 +01:00
8abec03d0b
0.7.0 release 2024-01-22 21:47:13 +01:00
23892ad245
TD/TP always start one week after lectures, and ends one week later 2024-01-22 21:46:25 +01:00
1eaa257dbf
fix calendar based on the 23/05/2023 edition 2024-01-22 18:27:18 +01:00
3a43782bd0
0.6.0 release 2024-01-01 14:14:31 +01:00
5c922a530e
replace Vec by Arc when possible 2024-01-01 14:14:13 +01:00
64ce6e478b
support cours/td 2024-01-01 13:47:23 +01:00
862c02b3e7
Sort days 2024-01-01 12:58:57 +01:00
9ce116f620
reduce code repetition 2024-01-01 12:09:44 +01:00
9ad0de6222
add course selection 2024-01-01 11:39:31 +01:00
77c2444de1
don't crash when the selected courses don't have multiples TP/TD options 2024-01-01 11:24:27 +01:00
220f46c5de
clippy 2024-01-01 11:21:59 +01:00
83563529be
update dependencies 2024-01-01 11:21:04 +01:00
f97d6c41e8
typo 2023-12-11 09:11:33 +01:00
82ae498a93
0.5.1 release 2023-10-02 14:54:29 +02:00
1d9389e41b
Append the course category to the summary 2023-10-02 14:53:40 +02:00
be6670b02f
Changes
- Rename type to category for courses
- Add category to the exported calendar
- Default to non-selected data when filtering TD/TP
2023-10-02 14:47:31 +02:00
e65534d8f9
0.5.0 release 2023-09-29 00:14:50 +02:00
e679602b76
Merge branch 'main' of git.mylloon.fr:Anri/cal7tor 2023-09-29 00:13:28 +02:00
9e5dd7009d
Merge branch 'rewrite' 2023-09-29 00:12:41 +02:00
54317e2356
filter TP/TD 2023-09-29 00:03:45 +02:00
6e13936980
fill hours is useful for the filter 2023-09-29 00:03:41 +02:00
041b8625f1
WIP: filter 2023-09-28 23:16:39 +02:00
384dd9eaa9
Capitalize + Type in models 2023-09-28 22:09:43 +02:00
75832958d8
add notice 2023-09-28 00:30:33 +02:00
2ec3455d16
refactor 2023-09-28 00:27:16 +02:00
5fc7d9209c
Fix some bugs 2023-09-28 00:23:51 +02:00
dc3a5c6d8e
fmt 2023-09-28 00:01:51 +02:00
be636f552a
Changes
- We are now searching backtoschool day in the same page that the timetable
- It should work now
2023-09-28 00:01:48 +02:00
c671587b7a
Sadly break-weeks are voted every year 2023-09-28 00:00:46 +02:00
bbee1d58be
Should be better now ? 2023-09-26 23:55:54 +02:00
189b77cc4f
Correctly build the timetable from scratch 2023-09-26 23:08:41 +02:00
b3fec12292
I made a discovery that changed my life! 2023-09-26 19:16:15 +02:00
9ba185b247
oui 2023-09-18 20:54:48 +02:00
1dbfe12566
find size of days 2023-09-18 20:52:18 +02:00
e50475f0a7
Actualiser README.md 2023-09-18 20:02:17 +02:00
ff95bf09b5
currently alpha 2023-09-18 20:00:35 +02:00
16a9bba6dd
find the right timetable for the current semester/year combo 2023-09-18 19:50:30 +02:00
ade74b4e4c
fix url 2023-09-18 18:49:08 +02:00
58399ca1e9
typo 2023-09-18 18:48:44 +02:00
3d51a4b743
Update branding 2023-09-18 18:46:50 +02:00
11 changed files with 1615 additions and 1050 deletions

1309
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,25 @@
[package] [package]
name = "cal8tor" name = "cal7tor"
version = "0.4.5" version = "0.10.0"
authors = ["Mylloon"] authors = ["Mylloon"]
edition = "2021" edition = "2021"
description = "Extractor of the calendar of the IT degree of university Paris 8" description = "Timetable extractor for the Paris Cité master's degree in IT"
readme = "README.md" readme = "README.md"
repository = "https://git.kennel.ml/Anri/cal8tor" repository = "https://git.mylloon.fr/Anri/cal7tor"
keywords = ["scrape", "calendar"] keywords = ["scrape", "calendar", "paris diderot", "paris cité"]
publish = false publish = false
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
[dependencies] [dependencies]
reqwest = { version = "0.11" } reqwest = { version = "0.12" }
tokio = { version = "1.32", features = ["full"] } tokio = { version = "1.42", features = ["full"] }
scraper = "0.17" scraper = "0.22"
regex = "1.9" regex = "1.11"
chrono = "0.4.28" chrono = "0.4.39"
ics = "0.5" ics = { version = "0.5", default-features = false }
uuid = { version = "1.4", features = ["v4", "fast-rng"] } uuid = { version = "1.11", features = ["v4", "fast-rng"] }
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
dialoguer = "0.11"
[lints.clippy]
pedantic = "warn"

View file

@ -1,16 +1,13 @@
# cal8tor • *cal*endar P*8* extrac*tor* # cal7tor • *cal*endar P*7* extrac*tor*
Extracteur d'emploi du temps pour la licence d'informatique de Paris 8 > Fork de [cal8tor](https://git.mylloon.fr/Anri/cal8tor)
[![dependency status](https://deps.rs/repo/gitea/git.mylloon.fr/Anri/cal8tor/status.svg)](https://deps.rs/repo/gitea/git.mylloon.fr/Anri/cal8tor) Extracteur d'emploi du temps pour les masters d'informatique de Paris Cité (Diderot)
[![dependency status](https://deps.rs/repo/gitea/git.mylloon.fr/Anri/cal7tor/status.svg)](https://deps.rs/repo/gitea/git.mylloon.fr/Anri/cal7tor)
## Installation ## Installation
### Arch
cal8tor est disponible sur le AUR : [`cal8tor`](https://aur.archlinux.org/packages/cal8tor)
et [`cal8tor-git`](https://aur.archlinux.org/packages/cal8tor-git).
### Manuellement ### Manuellement
Cf. [Compilation et installation](#compilation-et-installation). Cf. [Compilation et installation](#compilation-et-installation).
@ -20,26 +17,28 @@ Cf. [Compilation et installation](#compilation-et-installation).
Pour afficher la page d'aide Pour afficher la page d'aide
``` ```
$ cal8tor --help $ cal7tor --help
``` ```
## Voir le calendrier dans le terminal <!-- ## Voir le calendrier dans le terminal
Pour les L2-X par exemple, lance : > Cette partie est héritée de cal8tor et n'est actuellement pas compatible avec cal7tor.
Pour les M1 par exemple, lance :
```bash ```bash
$ cal8tor l2-X $ cal7tor M1
``` ```
> Le rendu peut parfois être difficile à lire, n'hésites pas à utiliser l'option > Le rendu peut parfois être difficile à lire, n'hésites pas à utiliser l'option
> `-c` (ou `--cl`) pour ajuster la longueur des cellules du planning. > `-c` (ou `--cl`) pour ajuster la longueur des cellules du planning. -->
## Exporter le calendrier au format `.ics` ## Exporter le calendrier au format `.ics`
Pour les L1-A par exemple, lance : Pour les M1 par exemple, lance :
```bash ```bash
$ cal8tor L1A --export calendar.ics $ cal7tor M1 --export calendar.ics
``` ```
> Le fichier comprend le fuseau horaire pour `Europe/Paris` et est > Le fichier comprend le fuseau horaire pour `Europe/Paris` et est
@ -58,7 +57,7 @@ Vous aurez besoin de Rust pour compiler le programme.
1. Clone le dépôt et s'y rendre 1. Clone le dépôt et s'y rendre
```bash ```bash
$ git clone https://git.mylloon.fr/Anri/cal8tor.git && cd cal8tor $ git clone https://git.mylloon.fr/Anri/cal7tor.git && cd cal7tor
``` ```
2. Compiler et installer l'application 2. Compiler et installer l'application
@ -67,7 +66,7 @@ $ git clone https://git.mylloon.fr/Anri/cal8tor.git && cd cal8tor
$ cargo install --path . $ cargo install --path .
``` ```
3. Tu peux maintenant supprimer le dossier `cal8tor` ! 3. Tu peux maintenant supprimer le dossier `cal7tor` !
--- ---

172
src/filter.rs Normal file
View file

@ -0,0 +1,172 @@
use dialoguer::MultiSelect;
use crate::timetable::models::Category;
use crate::timetable::models::Timetable;
use crate::utils::get_count;
use crate::utils::get_entry;
use crate::utils::get_entry_nocat;
use crate::utils::get_selection;
const DISCLAIMER: &str = "(selection avec ESPACE, ENTRER pour valider)";
/// Filter the timetable
pub fn timetable(timetable: Timetable, merge_td_tp: bool) -> Timetable {
let mut my_timetable = timetable;
/* Note on Cours/TD:
* We use the "as long as x interests us, we accept" approach.
*
* Because when a course and its TD are on the same slot,
* it's probably because there's an alternation between course
* and TD and no other choice is possible. */
choice(&mut my_timetable);
courses(&mut my_timetable);
tdtp(&mut my_timetable, merge_td_tp);
my_timetable
}
/// Exclude some courses
fn choice(timetable: &mut Timetable) {
let mut multiselected = vec![];
timetable.1 .1.iter().for_each(|day| {
day.courses.iter().for_each(|course_opt| {
if let Some(course) = course_opt {
if !multiselected.contains(&course.name) {
multiselected.push(course.name.clone());
}
}
});
});
let defaults = vec![true; multiselected.len()];
let selections = MultiSelect::new()
.with_prompt(format!("Choisis tes matières {DISCLAIMER}"))
.items(&multiselected[..])
.defaults(&defaults[..])
.interact()
.unwrap();
for day in &mut timetable.1 .1 {
day.courses.retain(|course_opt| {
if let Some(course) = course_opt {
// Remove courses not followed
for i in &selections {
if course.name == multiselected[*i] {
return true;
}
}
}
false
});
}
}
/// Filter the multiple courses
fn courses(timetable: &mut Timetable) {
let entry_getter = get_entry;
// List of courses and Counter of how much they appears
// to know if multiples slots are available
let (mut courses, counts) = get_count(timetable, &[Category::Cours], entry_getter);
// Keep only elements who have multiples slots
courses.retain(|course| *counts.get(&entry_getter(course.0)).unwrap() > 1);
let mut multiselected: Vec<String> = courses.iter().map(get_selection).collect();
multiselected.sort();
let mut selections = vec![];
if !multiselected.is_empty() {
let defaults = vec![false; multiselected.len()];
selections = MultiSelect::new()
.with_prompt(format!("Choisis tes horaires de Cours {DISCLAIMER}"))
.items(&multiselected[..])
.defaults(&defaults[..])
.interact()
.unwrap();
}
// Keep only wanted courses
for day in &mut timetable.1 .1 {
day.courses.retain(|course_opt| {
if let Some(course) = course_opt {
// Keep if it's a TD/TP
if course.category.contains(&Category::TD)
|| course.category.contains(&Category::TP)
{
return true;
}
// Keep if only one slot is available
if *counts.get(&entry_getter(course)).unwrap() == 1 {
return true;
}
// Keep only chosen courses if multiple was available
for i in &selections {
if get_selection(&(course, day.name.clone())) == multiselected[*i] {
return true;
}
}
}
false
});
}
}
/// Filter the multiples TD/TP
fn tdtp(timetable: &mut Timetable, merge: bool) {
// If we differentiate TD from TP
let entry_getter = if merge { get_entry_nocat } else { get_entry };
// List of TP/TD and Counter of how much they appears
// to know if multiples slots are available
let (mut td_or_tp, counts) = get_count(timetable, &[Category::TD, Category::TP], entry_getter);
// Keep only elements who have multiples TD/TP
td_or_tp.retain(|course| *counts.get(&entry_getter(course.0)).unwrap() > 1);
let mut multiselected: Vec<String> = td_or_tp.iter().map(get_selection).collect();
multiselected.sort();
let mut selections = vec![];
if !multiselected.is_empty() {
let defaults = vec![false; multiselected.len()];
selections = MultiSelect::new()
.with_prompt(format!("Choisis tes horaires de TD/TP {DISCLAIMER}"))
.items(&multiselected[..])
.defaults(&defaults[..])
.interact()
.unwrap();
}
// Keep only wanted courses
for day in &mut timetable.1 .1 {
day.courses.retain(|course_opt| {
if let Some(course) = course_opt {
// Keep if it's a course
if course.category.contains(&Category::Cours) {
return true;
}
// Keep if only one slot is available of the TD/TP
if *counts.get(&entry_getter(course)).unwrap() == 1 {
return true;
}
// Keep only chosen TD/TP if multiple was available
for i in &selections {
if get_selection(&(course, day.name.clone())) == multiselected[*i] {
return true;
}
}
}
false
});
}
}

View file

@ -1,12 +1,16 @@
use std::sync::Arc;
use chrono::TimeZone; use chrono::TimeZone;
use ics::{ use ics::{
parameters, parameters::{Language, PartStat, Role, TzIDParam, CN},
properties::{Class, Description, DtEnd, DtStart, Location, Summary, Transp}, properties::{
Attendee, Categories, Class, Description, DtEnd, DtStart, Location, Summary, Transp,
},
Event, ICalendar, Standard, Event, ICalendar, Standard,
}; };
pub fn export(courses: Vec<crate::timetable::models::Course>, filename: &mut String) { pub fn export(courses: Vec<crate::timetable::models::Course>, filename: &mut String) {
let mut calendar = ICalendar::new("2.0", "cal8tor"); let mut calendar = ICalendar::new("2.0", "cal7tor");
// Add Europe/Paris timezone // Add Europe/Paris timezone
let timezone_name = "Europe/Paris"; let timezone_name = "Europe/Paris";
@ -36,34 +40,57 @@ pub fn export(courses: Vec<crate::timetable::models::Course>, filename: &mut Str
// Professor's name // Professor's name
if course.professor.is_some() { if course.professor.is_some() {
event.push(Description::new(course.professor.unwrap())); let name = course.professor.unwrap();
let mut contact = Attendee::new("mailto:place@holder.com");
contact.add(CN::new(name));
contact.add(PartStat::ACCEPTED);
contact.add(Role::CHAIR);
event.push(contact);
} }
// Start time of the course // Start time of the course
let mut date_start = DtStart::new(dt_ical(course.dtstart.unwrap())); let mut date_start = DtStart::new(dt_ical(course.dtstart.unwrap()));
date_start.append(parameters!("TZID" => timezone_name)); date_start.add(TzIDParam::new(timezone_name));
event.push(date_start); event.push(date_start);
// End time of the course // End time of the course
let mut date_end = DtEnd::new(dt_ical(course.dtend.unwrap())); let mut date_end = DtEnd::new(dt_ical(course.dtend.unwrap()));
date_end.append(parameters!("TZID" => timezone_name)); date_end.add(TzIDParam::new(timezone_name));
event.push(date_end); event.push(date_end);
// Room location // Room location
event.push(Location::new(course.room)); event.push(Location::new(course.room));
let categories = course
.category
.iter()
.map(std::string::ToString::to_string)
.collect::<Arc<[String]>>()
.join("/");
// Course's name // Course's name
let mut course_name = Summary::new(course.name); let mut course_name = Summary::new(format!("{} - {}", categories, course.name));
course_name.append(parameters!("LANGUAGE" => "fr")); course_name.add(Language::new("fr"));
event.push(course_name); event.push(course_name);
// Course's category
event.push(Categories::new(categories));
// Course extra data
if course.data.is_some() {
event.push(Description::new(course.data.unwrap()));
}
// Add the course to the calendar // Add the course to the calendar
calendar.add_event(event); calendar.add_event(event);
} }
// Add the extension if needed // Add the extension if needed
if !filename.ends_with(".ics") { if !std::path::Path::new(filename)
*filename = format!("{}.ics", filename) .extension()
.map_or(false, |ext| ext.eq_ignore_ascii_case("ics"))
{
*filename = format!("{filename}.ics");
}; };
calendar.save_file(filename).unwrap(); calendar.save_file(filename).unwrap();

View file

@ -1,76 +1,100 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Duration, Utc};
use regex::{Captures, Regex}; use regex::{Captures, Regex};
use scraper::{Html, Selector}; use scraper::Selector;
use std::collections::HashMap; use std::{collections::HashMap, sync::Arc};
pub async fn info(user_agent: &str) -> HashMap<usize, Vec<(DateTime<Utc>, i64)>> { use crate::utils::{
let document = get_webpage(user_agent) get_semester, get_webpage, get_year,
models::{Info, InfoList, InfoType},
};
pub async fn get_start_date(
level: i8,
semester_opt: Option<i8>,
year_opt: Option<i32>,
user_agent: &str,
) -> String {
let semester = get_semester(semester_opt);
let year = get_year(year_opt, semester);
// Fetch the timetable of the FIRST semester
let document = get_webpage(level, 1, &year, user_agent)
.await .await
.expect("Can't reach info website."); .expect("Can't reach info website.");
// Selectors // Selectors
let sel_ul = Selector::parse("ul").unwrap(); let sel_b = Selector::parse("b").unwrap();
let sel_li = Selector::parse("li").unwrap(); let sel_font = Selector::parse("font").unwrap();
// Find the raw infos in html page // Find when is the back-to-school date
let mut raw_data = Vec::new(); let raw_data = document
for (i, data) in document.select(&sel_ul).enumerate() { .select(&sel_b)
if [1, 2].contains(&i) { .find(|element| element.select(&sel_font).next().is_some())
raw_data.push(data); .unwrap()
} .inner_html();
}
let mut data = HashMap::new(); let re = Regex::new(r"\d{1,2} (septembre|octobre)").unwrap();
// d => date
// r => repetition
let re = Regex::new(r"(?P<d>\d{1,2} \w+ \d{4}).+(?P<r>\d)").unwrap();
for (i, ul) in raw_data.into_iter().enumerate() {
for element in ul.select(&sel_li) {
match element.inner_html() {
e if e.starts_with("Début") => {
let captures = re.captures(&e).unwrap();
let start_date = get_date(captures.name("d").unwrap().as_str()); re.captures(&raw_data)
.and_then(|caps| caps.get(0))
let rep: i64 = captures.name("r").unwrap().as_str().parse().unwrap(); .map_or("1 septembre".to_owned(), |m| m.as_str().to_owned())
data.insert(i + 1, vec![(start_date, rep)]);
}
e if e.starts_with("Reprise") => {
let captures = re.captures(&e).unwrap();
captures.name("g");
let start_date = get_date(captures.name("d").unwrap().as_str());
let rep: i64 = captures.name("r").unwrap().as_str().parse().unwrap();
let it = i + 1;
let mut vec = data.get(&it).unwrap().to_owned();
vec.push((start_date, rep));
data.insert(it, vec);
}
_ => (),
}
}
}
data
} }
/// Get info webpage pub fn info(semester_opt: Option<i8>, year_opt: Option<i32>, date: &str, skip_week: bool) -> Info {
async fn get_webpage(user_agent: &str) -> Result<Html, Box<dyn std::error::Error>> { let semester = get_semester(semester_opt);
let url = "https://informatique.up8.edu/licence-iv/edt"; let year = get_year(year_opt, semester);
// Use custom User-Agent // 1st semester
let client = reqwest::Client::builder().user_agent(user_agent).build()?; let weeks_s1_1 = 6; // Weeks before break
let html = client.get(url).send().await?.text().await?; let weeks_s1_2 = 7; // Weeks after break
let date_s1_1 = get_date(&format!("{} {}", date, year.split_once('-').unwrap().0)); // Get first week of school
let date_s1_2 = date_s1_1 + Duration::weeks(weeks_s1_1 + 1); // Back-to-school week - add week of holidays
// Panic on error // 2nd semester
crate::utils::check_errors(&html, url); let weeks_s2_1 = 11; // Weeks before break
let weeks_s2_2 = 1; // Weeks after break
let date_s2_1 = date_s1_2 + Duration::weeks(weeks_s1_2 + 4); // Get first week - add week of 'christmas/new year holidays'
let date_s2_2 = date_s2_1 + Duration::weeks(weeks_s2_1 + 2); // Back-to-school week - add week of holidays
Ok(Html::parse_document(&html)) // Group courses values and derive it for TD/TP
let cours_s1 = vec![(date_s1_1, weeks_s1_1), (date_s1_2, weeks_s1_2)];
let cours_s2 = vec![(date_s2_1, weeks_s2_1), (date_s2_2, weeks_s2_2)];
let delta = i64::from(skip_week);
let tdtp_s1 = derive_from_cours(&cours_s1, delta);
let tdtp_s2 = derive_from_cours(&cours_s2, delta);
HashMap::from([
(
1_usize,
InfoType {
course: cours_s1,
td_tp: tdtp_s1,
},
),
(
2_usize,
InfoType {
course: cours_s2,
td_tp: tdtp_s2,
},
),
])
}
/// Find TD/TP dates, based on the ones from courses
fn derive_from_cours(courses: &InfoList, delta: i64) -> Vec<(DateTime<Utc>, i64)> {
// TD/TP start one week after courses
let before_break = courses.first().unwrap();
let after_break = courses.last().unwrap();
vec![
(
before_break.0 + Duration::weeks(delta),
before_break.1 - delta,
),
(after_break.0, after_break.1 + delta),
]
} }
/// Turn a french date to an english one /// Turn a french date to an english one
@ -93,7 +117,7 @@ fn anglophonization(date: &str) -> String {
// New regex of all the french month // New regex of all the french month
let re = Regex::new(&format!( let re = Regex::new(&format!(
"({})", "({})",
dico.keys().cloned().collect::<Vec<_>>().join("|") dico.keys().copied().collect::<Arc<[_]>>().join("|")
)) ))
.unwrap(); .unwrap();
@ -101,19 +125,19 @@ fn anglophonization(date: &str) -> String {
// Use 12:00 and UTC TZ for chrono parser // Use 12:00 and UTC TZ for chrono parser
"{} 12:00 +0000", "{} 12:00 +0000",
// Replace french by english month // Replace french by english month
re.replace_all(date, |cap: &Captures| match &cap[0] { re.replace_all(&date.to_lowercase(), |cap: &Captures| match &cap[0] {
month if dico.contains_key(month) => dico.get(month).unwrap(), month if dico.contains_key(month) => dico.get(month).unwrap(),
month => { month => {
panic!("Unknown month: {}", month) panic!("Unknown month: {month}")
} }
}) })
) )
} }
/// Turn a string to a DateTime /// Turn a string to a `DateTime`
fn get_date(date: &str) -> DateTime<Utc> { fn get_date(date: &str) -> DateTime<Utc> {
// Use and keep UTC time, we have the hour set to 12h and // Use and keep UTC time, we have the hour set to 12h and
// Paris 8 is in France so there is no problems // Paris 7 is in France so there is no problems
DateTime::parse_from_str(&anglophonization(date), "%e %B %Y %H:%M %z") DateTime::parse_from_str(&anglophonization(date), "%e %B %Y %H:%M %z")
.unwrap() .unwrap()
.into() .into()

View file

@ -1,6 +1,8 @@
use clap::Parser; use clap::Parser;
use dialoguer::Input;
use regex::Regex; use regex::Regex;
mod filter;
mod ics; mod ics;
mod info; mod info;
mod timetable; mod timetable;
@ -9,73 +11,81 @@ mod utils;
#[derive(Parser)] #[derive(Parser)]
#[clap(version, about, long_about = None)] #[clap(version, about, long_about = None)]
struct Args { struct Args {
/// The class you want to get the timetable, i.e.: L2-A /// The class you want to get the timetable, i.e.: M1
#[clap(value_parser)] #[clap(value_parser)]
class: String, class: String,
/// The semester you want (useful only in 3rd year, 1-2 use letter in class) /// The semester you want (1 or 2), default to current semester
#[clap(short, long, value_parser, value_name = "SEMESTER NUMBER")] #[clap(short, long, value_parser, value_name = "SEMESTER NUMBER")]
semester: Option<i8>, semester: Option<i8>,
/// The year, default to current year
#[clap(short, long, value_parser, value_name = "YEAR")]
year: Option<i32>,
/// Export to iCalendar format (.ics) /// Export to iCalendar format (.ics)
#[clap(short, long, value_name = "FILE NAME")] #[clap(short, long, value_name = "FILE NAME")]
export: Option<String>, export: Option<String>,
/// Size of cell of the timetable (irrelevant when exporting the timetable) /// Doesn't distinguish TD from TP
#[clap(short, long, value_name = "CELL LENGTH", default_value_t = 35)] #[clap(short, long)]
cl: usize, td_are_tp: bool,
/// First day of your year
#[clap(short, long)]
first_day: Option<String>,
/// If TD/TP start a week after courses
#[clap(short, long)]
week_skip: bool,
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args = Args::parse(); let args = Args::parse();
let matches = Regex::new(r"[Ll](?P<year>\d)[-–•·]?(?P<letter>.)?") let matches = Regex::new(r"(?i)M(?P<level>[1,2])")
.unwrap() .unwrap()
.captures(&args.class) .captures(&args.class)
.unwrap(); .unwrap();
let year = matches let level = matches
.name("year") .name("level")
.unwrap() .unwrap()
.as_str() .as_str()
.parse::<i8>() .parse::<i8>()
.unwrap(); .unwrap();
let letter = matches
.name("letter")
.map(|c| c.as_str().chars().next().expect("Error in letter"));
// Show a separator only if we need one let user_agent = format!("cal7tor/{}", env!("CARGO_PKG_VERSION"));
let seperator = match letter {
Some(_) => "-", println!("Récupération de l'emploi du temps des M{level}...");
None => "", let mut timetable = timetable::timetable(level, args.semester, args.year, &user_agent).await;
timetable = filter::timetable(timetable, args.td_are_tp);
let date = match args.first_day {
None => Input::new()
.with_prompt("Début des cours de l'année")
.default(info::get_start_date(level, args.semester, args.year, &user_agent).await)
.interact_text()
.unwrap(),
Some(day) => day,
}; };
let user_agent = format!("cal8tor/{}", env!("CARGO_PKG_VERSION"));
println!(
"Récupération de l'emploi du temps des L{}{}{}...",
year,
seperator,
letter.unwrap_or_default().to_uppercase()
);
let timetable = timetable::timetable(year, args.semester, letter, &user_agent).await;
println!("Récupération des informations par rapport à l'année..."); println!("Récupération des informations par rapport à l'année...");
let info = info::info(&user_agent).await; let info = info::info(args.semester, args.year, &date, args.week_skip);
if args.export.is_some() { if args.export.is_some() {
// Export the calendar // Export the calendar
let mut filename = args.export.unwrap(); let mut filename = args.export.unwrap();
let builded_timetable = timetable::build(timetable, info); let builded_timetable = timetable::build(&timetable, &info);
ics::export(builded_timetable, &mut filename); ics::export(builded_timetable, &mut filename);
println!("Fichier .ICS construit et exporté => {}", filename); println!("Fichier .ICS construit et exporté => {filename}");
} else { } else {
// Show the calendar // Show the calendar
println!("Affichage..."); println!("Affichage...");
timetable::display(timetable, args.cl); timetable::display(&timetable);
println!("Vous devrez peut-être mettre votre terminal en plein écran si ce n'est pas déjà le cas.");
} }
} }

View file

@ -1,242 +1,152 @@
#![allow(clippy::cast_sign_loss)]
use chrono::{Datelike, Duration, TimeZone, Utc}; use chrono::{Datelike, Duration, TimeZone, Utc};
use regex::Regex; use regex::Regex;
use scraper::{Html, Selector}; use scraper::Selector;
use std::collections::HashMap; use std::{collections::HashMap, sync::Arc};
use crate::utils::{ use crate::utils::{
self, capitalize, format_time_slot, get_hours, get_semester, get_webpage, get_year,
models::{Position, TabChar}, models::{Info, InfoList},
Capitalize,
}; };
use self::models::Day;
pub mod models; pub mod models;
/// Fetch the timetable for a class /// Fetch the timetable for a class
pub async fn timetable( pub async fn timetable(
year: i8, level: i8,
semester_opt: Option<i8>, semester_opt: Option<i8>,
letter: Option<char>, year_opt: Option<i32>,
user_agent: &str, user_agent: &str,
) -> (Vec<String>, (usize, Vec<models::Day>)) { ) -> models::Timetable {
let semester = get_semester(semester_opt, letter); let semester = get_semester(semester_opt);
let document = get_webpage(year, semester, letter, user_agent) let year = get_year(year_opt, semester);
let document = get_webpage(level, semester, &year, user_agent)
.await .await
.expect("Can't reach timetable website."); .expect("Can't reach timetable website.");
// Selectors // Selectors
let sel_table = Selector::parse("table").unwrap(); let sel_table = Selector::parse("table").unwrap();
let sel_tr = Selector::parse("tr").unwrap();
let sel_tbody = Selector::parse("tbody").unwrap(); let sel_tbody = Selector::parse("tbody").unwrap();
let sel_th = Selector::parse("th").unwrap();
let sel_td = Selector::parse("td").unwrap(); let sel_td = Selector::parse("td").unwrap();
let sel_em = Selector::parse("em").unwrap();
let sel_small = Selector::parse("small").unwrap(); let sel_small = Selector::parse("small").unwrap();
let sel_strong = Selector::parse("strong").unwrap(); let sel_b = Selector::parse("b").unwrap();
let sel_span = Selector::parse("span").unwrap();
// Find the timetable // Find the timetable
let raw_timetable = document.select(&sel_table).next().unwrap(); let raw_timetable = document.select(&sel_table).next().unwrap();
// Find the slots available for the timetable let schedules = get_hours();
let raw_schedules = raw_timetable.select(&sel_tr).next().unwrap();
// Find availables schedules let mut timetable: Vec<models::Day> = Vec::new();
let mut schedules = Vec::new();
for time in raw_schedules.select(&sel_th) {
schedules.push(time.inner_html());
}
// Find the timetable values raw_timetable
let raw_timetable_values = raw_timetable.select(&sel_tbody).next().unwrap(); .select(&sel_tbody)
.next()
.unwrap()
.select(&sel_td)
.filter(|element| element.value().attr("title").is_some())
.for_each(|i| {
let extra_data = i.select(&sel_span).next().map(|span|
span.inner_html().replace("<br>", "").trim().to_owned()
);
// For each days /* TODO: Instead of searching *_M2, just find any TD_* and TP_* */
let mut timetable = Vec::new(); let matches =
let span_regex = Regex::new(r"<span.*</span>").unwrap(); Regex::new(
for day in raw_timetable_values.select(&sel_tr) { r"(?P<type>COURS|COURS_TD|TD|TD_M2|TP|TP_M2)? (?P<name>.*) : (?P<day>(lundi|mardi|mercredi|jeudi|vendredi)) (?P<startime>.*) \(durée : (?P<duration>.*)\)")
let mut courses_vec = Vec::new(); .unwrap()
let mut location_tracker = 0; .captures(i.value().attr("title").unwrap())
for course in day.select(&sel_td) { .unwrap();
if course.inner_html() == "" {
courses_vec.push(None); let day = matches
location_tracker += 1; .name("day")
} else { .unwrap()
courses_vec.push(Some(models::Course { .as_str()
name: match course.select(&sel_em).next() { .capitalize();
Some(value) => span_regex.replace(&value.inner_html(), " ").to_string(),
None => span_regex let startime = matches
.replace(course.inner_html().split("<br>").next().unwrap(), " ") .name("startime")
.to_string(), .unwrap()
.as_str();
let binding = i.select(&sel_b).last().unwrap().inner_html();
let course = models::Course{
category: match matches
.name("type")
.map_or("", |m| m.as_str()) {
/* TODO: Instead of searching *_M2, just find any TD_* and TP_* */
"COURS" => [models::Category::Cours].into(),
"TP" | "TP_M2" => [models::Category::TP].into(),
"TD" | "TD_M2" => [models::Category::TD].into(),
"COURS_TD" => [models::Category::Cours, models::Category::TD].into(),
_ => {
println!("Unknown type of course, falling back to 'COURS': {}", i.value().attr("title").unwrap());
[models::Category::Cours].into()
}, },
professor: match course },
.select(&sel_small) name: Regex::new(r"[ -][ML][1-3]$").unwrap().replace(
.next() matches
.name("name")
.unwrap() .unwrap()
.inner_html() .as_str(),
.split("<br>") ""
.next() ).to_string(),
{ professor: if let Some(raw_prof) = i.select(&sel_small).last() {
Some(data) => { match raw_prof.inner_html() {
if data.contains("</strong>") { i if i.starts_with("<span") => None,
// This is the room, so there is no professor assigned i => Some(i),
// to this courses yet
None
} else {
Some(data.to_string())
}
} }
None => None, } else { None },
}, room: Regex::new(r"(<table.*<\/table>|<br>.*?<br>.*?)?<br>(?P<location>.*?)<br>")
room: capitalize(&mut match course.select(&sel_strong).next() { .unwrap()
Some(el) => el.inner_html().replace("<br>", ""), .captures(&binding)
// Error in the site, silently passing... (the room is probably at the professor member) .unwrap().name("location")
None => String::new(), .unwrap()
}), .as_str().to_owned(),
start: location_tracker, start: schedules.iter().position(|r| r.starts_with(startime)).unwrap(),
size: match course.value().attr("colspan") { size: i.value().attr("rowspan").unwrap().parse::<usize>().unwrap(),
Some(i) => i.parse().unwrap(), dtstart: None,
None => 1, dtend: None,
}, data: extra_data,
dtstart: None, };
dtend: None,
}));
match &courses_vec[courses_vec.len() - 1] { // Search for the day in the timetable
Some(course) => location_tracker += course.size, if let Some(existing_day) = timetable.iter_mut().find(|x| x.name == day) {
None => location_tracker += 1, existing_day.courses.push(Some(course));
} } else {
// Day with the name doesn't exist, create a new Day
timetable.push(models::Day {
name: day.clone(),
courses: vec![Some(course)],
});
} }
} });
timetable.push(models::Day { // Sort by days
name: day.select(&sel_th).next().unwrap().inner_html(), let day_positions = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
courses: courses_vec, .iter()
}) .enumerate()
} .map(|(i, &day)| (day.to_owned(), i))
.collect::<HashMap<String, usize>>();
if !check_consistency(&schedules, &timetable) { timetable.sort_by(|a, b| day_positions[&a.name].cmp(&day_positions[&b.name]));
panic!("Error when building the timetable.");
}
(schedules, (semester as usize, timetable)) (schedules, (semester as usize, timetable))
} }
/// Get timetable webpage
async fn get_webpage(
year: i8,
semester: i8,
letter: Option<char>,
user_agent: &str,
) -> Result<Html, Box<dyn std::error::Error>> {
let url = {
let panic_semester_message = "Unknown semester.";
let panic_letter_message = "Unknown letter.";
let base_url = "https://informatique.up8.edu/licence-iv/edt";
let allow_letters_1 = match semester {
1 => ['a', 'b', 'c'],
2 => ['x', 'y', 'z'],
_ => panic!("{}", panic_semester_message),
};
let allow_letters_2_3 = match semester {
1 => ['a', 'b'],
2 => ['x', 'y'],
_ => panic!("{}", panic_semester_message),
};
match year {
1 => {
let c = letter.expect(panic_letter_message).to_ascii_lowercase();
if allow_letters_1.contains(&c) {
format!("{}/l1-{}.html", base_url, c)
} else {
panic!("{}", panic_letter_message)
}
}
2 => {
let c = letter.expect(panic_letter_message).to_ascii_lowercase();
if allow_letters_2_3.contains(&c) {
format!("{}/l2-{}.html", base_url, c)
} else {
panic!("{}", panic_letter_message)
}
}
3 => {
let c = letter.expect(panic_letter_message).to_ascii_lowercase();
if allow_letters_2_3.contains(&c) {
format!("{}/l3-{}.html", base_url, c)
} else {
panic!("{}", panic_letter_message)
}
}
_ => panic!("Unknown year."),
}
};
// Use custom User-Agent
let client = reqwest::Client::builder().user_agent(user_agent).build()?;
let html = client.get(&url).send().await?.text().await?;
// Panic on error
crate::utils::check_errors(&html, &url);
// Parse document
let document = Html::parse_document(&html);
Ok(document)
}
/// Check if the timetable is well built
fn check_consistency(schedules: &[String], timetable: &Vec<models::Day>) -> bool {
let mut checker = true;
for day in timetable {
let mut i = 0;
for course in &day.courses {
match course {
Some(course_it) => {
// Checks the consistency of course start times
if i != course_it.start {
checker = false;
break;
}
// Keep the track of how many courses are in the day
i += course_it.size
}
None => i += 1,
}
}
// The counter should be the same as the amount of possible hours of the day
if i != schedules.len() {
checker = false;
break;
}
}
checker
}
// Data builded in the timetable webpage
type T = (
// Schedules
Vec<String>,
// Timetable per days with the semester as the key
(usize, Vec<models::Day>),
);
// Data builded in the info webpage
type D = HashMap<
// Semester
usize,
// List of start and repetition of course weeks
Vec<(chrono::DateTime<Utc>, i64)>,
>;
/// Build the timetable /// Build the timetable
pub fn build(timetable: T, dates: D) -> Vec<models::Course> { pub fn build(timetable: &models::Timetable, dates: &Info) -> Vec<models::Course> {
let mut schedules = Vec::new(); let mut schedules = Vec::new();
// h1 => heure de début | m1 => minute de début // h1 => heure de début | m1 => minute de début
// h2 => heure de fin | m2 => minute de fin // h2 => heure de fin | m2 => minute de fin
let re = let re = Regex::new(r"(?P<h1>\d{1,2})h(?P<m1>\d{2})-(?P<h2>\d{1,2})h(?P<m2>\d{2})").unwrap();
Regex::new(r"(?P<h1>\d{1,2})(h|:)(?P<m1>\d{1,2})?.(?P<h2>\d{1,2})(h|:)(?P<m2>\d{1,2})?") for hour in timetable.0.iter() {
.unwrap(); let captures = re.captures(hour).unwrap();
for hour in timetable.0 {
let captures = re.captures(&hour).unwrap();
let h1 = match captures.name("h1") { let h1 = match captures.name("h1") {
Some(h) => h.as_str().parse().unwrap(), Some(h) => h.as_str().parse().unwrap(),
@ -262,19 +172,66 @@ pub fn build(timetable: T, dates: D) -> Vec<models::Course> {
// Start date of the back-to-school week // Start date of the back-to-school week
let datetimes = dates.get(&timetable.1 .0).unwrap(); let datetimes = dates.get(&timetable.1 .0).unwrap();
let before_break = datetimes.get(0).unwrap(); add_courses(
&mut semester,
&schedules,
&timetable.1 .1,
&datetimes.course,
Some(&vec![models::Category::Cours]),
None,
);
add_courses(
&mut semester,
&schedules,
&timetable.1 .1,
&datetimes.td_tp,
None,
Some(&vec![models::Category::Cours]),
);
semester
}
type Schedule = [((u32, u32), (u32, u32))];
/// Add a course to the semester list
fn add_courses(
// Accumulator of courses of semester
semester: &mut Vec<models::Course>,
// Hours used
schedules: &Schedule,
// List of days
days: &Vec<Day>,
// Current courses list
info: &InfoList,
// List of category allowed
keep: Option<&Vec<models::Category>>,
// List of category excluded
exclude: Option<&Vec<models::Category>>,
) {
let before_break = info.first().unwrap();
let mut date = before_break.0; let mut date = before_break.0;
let mut rep = before_break.1; let mut rep = before_break.1;
// For each weeks // For each weeks
for _ in 0..2 { for _ in 0..2 {
for _ in 0..rep { for _ in 0..rep {
for day in &timetable.1 .1 { for day in days {
for mut course in day.courses.clone().into_iter().flatten() { for mut course in day.courses.iter().flatten().cloned() {
// Get the hours // Get the hours
let start = schedules.get(course.start).unwrap().0; let start = schedules.get(course.start).unwrap().0;
// -1 because we only add when the size is > 1 // -1 because we only add when the size is > 1
let end = schedules.get(course.start + course.size - 1).unwrap().1; let end = schedules.get(course.start + course.size - 1).unwrap().1;
// Check keep and exclude filters
if keep
.is_some_and(|list| !course.category.iter().any(|item| list.contains(item)))
|| exclude.is_some_and(|list| {
course.category.iter().any(|item| list.contains(item))
})
{
continue;
}
// Add the changed datetimes // Add the changed datetimes
course.dtstart = Some( course.dtstart = Some(
Utc.with_ymd_and_hms( Utc.with_ymd_and_hms(
@ -306,141 +263,35 @@ pub fn build(timetable: T, dates: D) -> Vec<models::Course> {
// From friday to monday // From friday to monday
date += Duration::days(2); date += Duration::days(2);
} }
let after_break = datetimes.get(1).unwrap(); let after_break = info.last().unwrap();
date = after_break.0; date = after_break.0;
rep = after_break.1; rep = after_break.1;
} }
semester
}
/// Get the current semester depending on the letter or the current date
fn get_semester(semester: Option<i8>, letter: Option<char>) -> i8 {
match semester {
// Force the asked semester
Some(n) => n,
// Find the potential semester
None => match letter {
// Based on letter (kinda accurate)
Some(c) => {
if c.to_ascii_uppercase() as i8 > 77 {
// If letter is N or after
2
} else {
// If letter is before N
1
}
}
// Based on the time (kinda less accurate)
None => {
if Utc::now().month() > 6 {
// From july to december
1
} else {
// from january to june
2
}
}
},
}
} }
/// Display the timetable /// Display the timetable
pub fn display(timetable: (Vec<String>, (usize, Vec<models::Day>)), cell_length: usize) { pub fn display(timetable: &(Arc<[String]>, (usize, Vec<Day>))) {
// Cell length for hours for day in &timetable.1 .1 {
let clh = 11; for (index, course_option) in day.courses.iter().enumerate() {
// Cell number if let Some(course) = course_option {
let cn = 6; if index == 0 {
// 3/4 of cell length println!("\n{}:", day.name);
let quarter = (3 * cell_length) / 4; }
let sep = TabChar::Bv.val(); println!(
" {} - {} : {} ({}) // {}",
// Top of the tab format_time_slot(course.start, course.size),
utils::line_table(clh, cell_length, cn, Position::Top, HashMap::new()); course
.category
// First empty case .iter()
print!("{}{:^clh$}{}", sep, "", sep); .map(std::string::ToString::to_string)
.collect::<Vec<String>>()
// Print day's of the week .join(", "),
let mut days = HashMap::new(); course.name,
for (i, data) in timetable.1 .1.iter().enumerate() { course.room,
days.insert(i, &data.name); course.professor.as_deref().unwrap_or("N/A"),
print!("{:^cell_length$}{}", &data.name, sep); );
}
// Store the data of the course for utils::line_table
let mut next_skip = HashMap::new();
// For each hours -- i the hour's number
for (i, hour) in timetable.0.into_iter().enumerate() {
// Draw separator line
utils::line_table(clh, cell_length, cn, Position::Middle, next_skip);
// Reset
next_skip = HashMap::new();
// Print hour
print!("{}{:^clh$}", sep, hour);
// For all the days - `j` the day's number
for (j, day) in timetable.1 .1.iter().enumerate() {
// True if we found something about the slot we are looking for
let mut info_slot = false;
// For all the courses of each days - `k` the possible course.start
for (k, course_opt) in day.courses.iter().enumerate() {
match course_opt {
// If there is a course
Some(course) => {
// Check the course's hour
if i == course.start {
// If the course uses more than one time slot
if course.size > 1 {
// If the data is too long
if course.name.len() > quarter {
let data = utils::split_half(&course.name);
next_skip.insert(j, data.1.trim());
print!("{}{:^cell_length$}", sep, data.0.trim());
} else {
next_skip.insert(j, &course.name);
print!("{}{:^cell_length$}", sep, "");
}
info_slot = true;
break;
} else {
// Else simply print the course
// If the data is too long
if course.name.len() > quarter {
print!("{}{:^cell_length$}", sep, utils::etc_str(&course.name));
} else {
print!("{}{:^cell_length$}", sep, &course.name);
}
info_slot = true;
break;
}
}
}
// If no course was found
None => {
// Verify the "no course" is in the correct day and hour
if *days.get(&j).unwrap() == &day.name.to_string() && k == i {
// If yes print empty row because there is no course
print!("{}{:^cell_length$}", sep, "");
info_slot = true;
break;
}
// Else it was a course of another day/time
}
};
}
if !info_slot {
// We found nothing about the slot because the precedent course
// takes more place than one slot
print!("{}{:^cell_length$}", sep, "");
} }
} }
print!("{}", sep);
} }
// Bottom of the table
utils::line_table(clh, cell_length, cn, Position::Bottom, HashMap::new());
} }

View file

@ -1,5 +1,23 @@
#[derive(Clone)] use std::sync::Arc;
#[derive(Clone, Debug, PartialEq)]
pub enum Category {
Cours,
TP,
TD,
}
impl std::fmt::Display for Category {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Course { pub struct Course {
/// Type du cours
pub category: Arc<[Category]>,
/// Course's name /// Course's name
pub name: String, pub name: String,
@ -25,11 +43,23 @@ pub struct Course {
/// Datetime when the course end /// Datetime when the course end
/// Filled only when building for the ICS /// Filled only when building for the ICS
pub dtend: Option<chrono::DateTime<chrono::Utc>>, pub dtend: Option<chrono::DateTime<chrono::Utc>>,
/// Extra data
pub data: Option<String>,
} }
#[derive(Debug)]
pub struct Day { pub struct Day {
/// Day's name /// Day's name
pub name: String, pub name: String,
/// Ordered list of all the courses of the day /// Ordered list of all the courses of the day
pub courses: Vec<Option<Course>>, pub courses: Vec<Option<Course>>,
} }
// Data builded in the timetable webpage
pub type Timetable = (
// Schedules
Arc<[String]>,
// Timetable per days with the semester as the key
(usize, Vec<Day>),
);

View file

@ -1,122 +1,172 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{Datelike, Utc};
use scraper::Html;
use crate::timetable::models::{Category, Course, Timetable};
pub mod models; pub mod models;
/// Panic if an error happened /// Panic if an error happened
pub fn check_errors(html: &String, loc: &str) { pub fn check_errors(html: &String, loc: &str) {
let no_timetable = "Aucun créneau horaire affecté";
match html { match html {
t if t.contains(&err_code(429)) => panic!( t if t.contains(no_timetable) => panic!("URL: {loc}{no_timetable}"),
"URL: {} • HTTP 429: Slow down - Rate limited (too many access attempts detected)",
loc
),
_ => (), _ => (),
} }
} }
/// Create String error code /// Get timetable webpage
fn err_code(code: i32) -> String { pub async fn get_webpage(
format!("HTTP Code : {}", code) level: i8,
semester: i8,
year: &str,
user_agent: &str,
) -> Result<Html, Box<dyn std::error::Error>> {
let url = format!("https://silice.informatique.univ-paris-diderot.fr/ufr/U{year}/EDT/visualiserEmploiDuTemps.php?quoi=M{level},{semester}");
// Use custom User-Agent
let client = reqwest::Client::builder().user_agent(user_agent).build()?;
let html = client.get(&url).send().await?.text().await?;
// Panic on error
crate::utils::check_errors(&html, &url);
// Parse document
let document = Html::parse_document(&html);
Ok(document)
} }
/// Print a line for the table /// Get the current semester depending on the current date
pub fn line_table( pub fn get_semester(semester: Option<i8>) -> i8 {
cell_length_hours: usize, match semester {
cell_length: usize, // Force the asked semester
number_cell: usize, Some(n) => n,
pos: models::Position, // Find the potential semester
skip_with: std::collections::HashMap<usize, &str>, None => {
) { if Utc::now().month() > 6 {
// Left side // From july to december
let ls = match pos { 1
models::Position::Top => models::TabChar::Jtl.val(), } else {
models::Position::Middle => models::TabChar::Jl.val(), // from january to june
models::Position::Bottom => models::TabChar::Jbl.val(), 2
};
// Middle
let ms = match pos {
models::Position::Top => models::TabChar::Jtb.val(),
models::Position::Middle => models::TabChar::Jm.val(),
models::Position::Bottom => models::TabChar::Jtt.val(),
};
// Right side
let rs = match pos {
models::Position::Top => models::TabChar::Jtr.val(),
models::Position::Middle => models::TabChar::Jr.val(),
models::Position::Bottom => models::TabChar::Jbr.val(),
};
// Right side before big cell
let rs_bbc = models::TabChar::Jr.val();
// Right side big cell before big cell
let rsbc_bbc = models::TabChar::Bv.val();
// Right side big cell
let rsbc = models::TabChar::Jl.val();
let line = models::TabChar::Bh.val().to_string().repeat(cell_length);
let line_h = models::TabChar::Bh
.val()
.to_string()
.repeat(cell_length_hours);
// Hours column
match skip_with.get(&0) {
Some(_) => print!("\n{}{}{}", ls, line_h, rs_bbc),
None => print!("\n{}{}{}", ls, line_h, ms),
};
// Courses columns
let range = number_cell - 1;
let mut last_day = false;
for i in 0..range {
// Check if it's a big cell
if i == range - 1 {
// Friday only
if let Some(text) = skip_with.get(&i) {
println!("{:^cell_length$}{}", text, rsbc_bbc);
last_day = true;
}
} else {
match skip_with.get(&i) {
Some(text) => match skip_with.get(&(i + 1)) {
// Match check if the next cell will be big
Some(_) => print!("{:^cell_length$}{}", text, rsbc_bbc),
None => print!("{:^cell_length$}{}", text, rsbc),
},
None => match skip_with.get(&(i + 1)) {
// Match check if the next cell will be big
Some(_) => print!("{}{}", line, rs_bbc),
None => print!("{}{}", line, ms),
},
} }
} }
} }
if !last_day { }
println!("{}{}", line, rs);
/// Get the current year depending on the current date
pub fn get_year(year: Option<i32>, semester: i8) -> String {
let wanted_year = match year {
// Force the asked semester
Some(n) => n,
// Find the potential semester
None => Utc::now().year(),
};
if semester == 1 {
format!("{}-{}", wanted_year, wanted_year + 1)
} else {
format!("{}-{}", wanted_year - 1, wanted_year)
} }
} }
// Split a string in half with respect of words pub trait Capitalize {
pub fn split_half(text: &str) -> (&str, &str) { /// Capitalize string
let mid = text.len() / 2; fn capitalize(&self) -> String;
for (i, j) in (mid..text.len()).enumerate() { }
if text.as_bytes()[j] == b' ' {
return text.split_at(mid + i); impl Capitalize for str {
fn capitalize(&self) -> String {
let mut string = self.to_owned();
if let Some(r) = string.get_mut(0..1) {
r.make_ascii_uppercase();
}
string
}
}
/// Get all hours used the source, from 08:00 to at least 20:00
pub fn get_hours() -> Arc<[String]> {
let mut hours = vec![];
for hour in 8..=20 {
for minute in &[0, 15, 30, 45] {
let hour_str = format!("{hour}h{minute:02}");
if let Some(last_hour) = hours.pop() {
hours.push(format!("{last_hour}-{hour_str}"));
}
hours.push(hour_str);
} }
} }
for _ in 0..4 {
text.split_at(mid) hours.pop();
}
// Reduce size of string by adding etc. to it, and cutting some info
pub fn etc_str(text: &str) -> String {
format!("{}...", split_half(text).0.trim())
}
// Capitalize string
pub fn capitalize(text: &mut str) -> String {
if let Some(r) = text.get_mut(0..1) {
r.make_ascii_uppercase();
} }
text.to_string() hours.into()
}
/// Names showed to the users
pub fn get_selection(data: &(&Course, String)) -> String {
let hours = get_hours();
format!(
"{} - {} {}-{}",
data.0.name,
data.1,
hours[data.0.start].split_once('-').unwrap().0,
hours[data.0.start + data.0.size - 1]
.split_once('-')
.unwrap()
.1
)
}
/// Entry's name used for finding duplicates
pub fn get_entry(course: &Course) -> String {
format!("{} - {:?}", course.name, course.category)
}
/// Entry's name used for finding duplicates, ignoring categories
pub fn get_entry_nocat(course: &Course) -> String {
course.name.clone()
}
/// Returns a couple of (list of courses) and (a hashmap of how much they appears in the vector)
pub fn get_count<'a>(
timetable: &'a mut Timetable,
allowed_list: &'a [Category],
getter: fn(&Course) -> String,
) -> (Vec<(&'a Course, String)>, HashMap<String, i32>) {
// List of courses who will be courses
let mut courses = vec![];
let mut counts = HashMap::new();
timetable.1 .1.iter().for_each(|day| {
day.courses.iter().for_each(|course_opt| {
if let Some(course) = course_opt {
if course
.category
.iter()
.any(|category| allowed_list.contains(category))
{
courses.push((course, day.name.clone()));
let count = counts.entry(getter(course)).or_insert(0);
*count += 1;
}
}
});
});
(courses, counts)
}
pub fn format_time_slot(start: usize, size: usize) -> String {
let start_hour = 8 + (start * 15) / 60;
let start_minute = (start * 15) % 60;
let end_hour = start_hour + (size * 15) / 60;
let end_minute = (start_minute + (size * 15)) % 60;
format!("{start_hour:02}h{start_minute:02}-{end_hour:02}h{end_minute:02}")
} }

View file

@ -1,51 +1,18 @@
/// Collection of char for the table use std::collections::HashMap;
pub enum TabChar {
/// Vertical bar use chrono::Utc;
Bv,
/// Horizontal bar pub type InfoList = Vec<(chrono::DateTime<Utc>, i64)>;
Bh,
/// Joint left pub struct InfoType {
Jl, pub course: InfoList,
/// Joint right pub td_tp: InfoList,
Jr,
/// Joint bottom left
Jbl,
/// Joint bottom right
Jbr,
/// Joint top left
Jtl,
/// Joint top right
Jtr,
/// Joint to top
Jtt,
/// Joint to bottom
Jtb,
/// Joint of the middle
Jm,
} }
impl TabChar { // Info who old the start and end of courses
/// Value of the element pub type Info = HashMap<
pub fn val(&self) -> char { // Semester
match *self { usize,
Self::Bv => '│', // List of start and repetition of course and TD/TP weeks
Self::Bh => '─', InfoType,
Self::Jl => '├', >;
Self::Jr => '┤',
Self::Jbl => '└',
Self::Jbr => '┘',
Self::Jtl => '┌',
Self::Jtr => '┐',
Self::Jtt => '┴',
Self::Jtb => '┬',
Self::Jm => '┼',
}
}
}
/// Position for lines inside the table
pub enum Position {
Top,
Middle,
Bottom
}