-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
343 additions
and
147 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
use std::{fmt::Display, fs, io::Cursor, path::PathBuf}; | ||
|
||
use crate::Config; | ||
use canvas_cli::{Course, DateTime}; | ||
use fuzzy_matcher::FuzzyMatcher; | ||
use human_bytes::human_bytes; | ||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; | ||
use inquire::MultiSelect; | ||
use serde_derive::Deserialize; | ||
|
||
#[derive(Debug)] | ||
struct File { | ||
id: u32, | ||
filename: String, | ||
url: String, | ||
size: u32, | ||
updated_at: DateTime, | ||
} | ||
|
||
impl Display for File { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
write!(f, "{} ({})", self.filename, human_bytes(self.size)) | ||
} | ||
} | ||
|
||
#[derive(Deserialize, Debug)] | ||
struct FileResponse { | ||
id: u32, | ||
filename: String, | ||
url: String, | ||
size: u32, | ||
updated_at: DateTime, | ||
} | ||
|
||
#[derive(clap::Parser, Debug)] | ||
/// Download files from a course | ||
pub struct DownloadCommand { | ||
/// Canvas course ID | ||
#[clap(long, short)] | ||
course: Option<u32>, | ||
|
||
/// Canvas file IDs | ||
#[clap(value_parser, num_args = 1.., value_delimiter = ' ')] | ||
files: Option<Vec<u32>>, | ||
|
||
/// Output directory | ||
#[clap(long, short)] | ||
directory: Option<PathBuf>, | ||
} | ||
|
||
impl DownloadCommand { | ||
pub async fn action(&self, cfg: &Config) -> Result<(), anyhow::Error> { | ||
if let Some(directory) = &self.directory { | ||
fs::create_dir_all(directory)?; | ||
println!( | ||
"✓ Downloading files into {}", | ||
directory.canonicalize()?.display() | ||
); | ||
} | ||
|
||
let url = cfg.url.to_owned(); | ||
let access_token = cfg.access_token.to_owned(); | ||
|
||
let client = reqwest::Client::builder() | ||
.default_headers( | ||
std::iter::once(( | ||
reqwest::header::AUTHORIZATION, | ||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", access_token)) | ||
.unwrap(), | ||
)) | ||
.collect(), | ||
) | ||
.build() | ||
.unwrap(); | ||
|
||
let course = Course::fetch(self.course, &url, &client).await?; | ||
|
||
log::info!("Selected course {}", course.id); | ||
|
||
let file_request = client | ||
.get(format!( | ||
"{}/api/v1/courses/{}/files?per_page=1000", | ||
url, course.id | ||
)) | ||
.send() | ||
.await?; | ||
|
||
if !file_request.status().is_success() { | ||
println!("No files available"); | ||
return Ok(()); | ||
} | ||
|
||
let mut files: Vec<File> = file_request | ||
.json::<Vec<FileResponse>>() | ||
.await? | ||
.into_iter() | ||
.map(|file| File { | ||
id: file.id, | ||
filename: file.filename, | ||
url: file.url, | ||
size: file.size, | ||
updated_at: file.updated_at, | ||
}) | ||
.collect(); | ||
|
||
if files.len() == 0 { | ||
println!("No files available"); | ||
return Ok(()); | ||
} | ||
|
||
let files = if let Some(file_ids) = &self.files { | ||
println!("✓ Queried all files"); | ||
files.retain(|file| file_ids.contains(&file.id)); | ||
files | ||
} else { | ||
files.sort_by(|a, b| a.updated_at.cmp(&b.updated_at)); | ||
let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); | ||
let files = MultiSelect::new("Files?", files) | ||
.with_filter(&|input, _, string_value, _| { | ||
matcher.fuzzy_match(string_value, input).is_some() | ||
}) | ||
.prompt()?; | ||
files | ||
}; | ||
|
||
if files.len() == 0 { | ||
println!("No files selected"); | ||
return Ok(()); | ||
} | ||
|
||
let multi_progress = MultiProgress::new(); | ||
let future_files = files | ||
.iter() | ||
.map(|file| upload_file(&file, self.directory.as_ref(), &multi_progress)); | ||
futures::future::join_all(future_files).await; | ||
|
||
println!("✓ Successfully downloaded files 🎉"); | ||
|
||
Ok(()) | ||
} | ||
} | ||
|
||
async fn upload_file( | ||
file: &File, | ||
directory: Option<&PathBuf>, | ||
multi_progress: &MultiProgress, | ||
) -> Result<(), anyhow::Error> { | ||
let spinner = multi_progress.add(ProgressBar::new_spinner()); | ||
spinner.set_message(format!("Downloading file {}", file)); | ||
|
||
let spinner_clone = spinner.clone(); | ||
let spinner_task = tokio::spawn(async move { | ||
loop { | ||
tokio::time::sleep(std::time::Duration::from_millis(100)).await; | ||
spinner_clone.inc(1); | ||
} | ||
}); | ||
|
||
let path = if let Some(directory) = directory { | ||
directory.join(&file.filename) | ||
} else { | ||
PathBuf::from(&file.filename) | ||
}; | ||
|
||
let response = reqwest::get(&file.url).await?; | ||
let mut fsfile = std::fs::File::create(path)?; | ||
let mut content = Cursor::new(response.bytes().await?); | ||
std::io::copy(&mut content, &mut fsfile)?; | ||
|
||
spinner_task.abort(); | ||
spinner.set_style(ProgressStyle::with_template("✓ {wide_msg}").unwrap()); | ||
spinner.finish_with_message(format!("Downloaded file {}", file)); | ||
|
||
Ok(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
use colored::Colorize; | ||
use inquire::Select; | ||
use reqwest::Client; | ||
use serde_derive::Deserialize; | ||
use std::{collections::HashMap, fmt::Display}; | ||
|
||
pub type DateTime = chrono::DateTime<chrono::Utc>; | ||
|
||
#[derive(Debug, Hash, Clone, PartialEq, Eq)] | ||
pub struct Course { | ||
pub name: String, | ||
pub id: u32, | ||
is_favorite: bool, | ||
css_color: Option<String>, | ||
created_at: DateTime, | ||
} | ||
|
||
#[derive(Deserialize, Debug)] | ||
struct CourseResponse { | ||
id: u32, | ||
name: String, | ||
is_favorite: bool, | ||
created_at: DateTime, | ||
concluded: bool, | ||
} | ||
|
||
#[derive(Deserialize, Debug)] | ||
struct ColorsResponse { | ||
custom_colors: HashMap<String, String>, | ||
} | ||
|
||
impl Display for Course { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
let css_color = self.css_color.clone().unwrap_or("#000000".to_string()); | ||
let color = csscolorparser::parse(&css_color) | ||
.unwrap() | ||
.to_linear_rgba_u8(); | ||
write!( | ||
f, | ||
"{}{}{}", | ||
"█ ".truecolor(color.0, color.1, color.2), | ||
self.name, | ||
if self.is_favorite { " ★" } else { "" }.yellow() | ||
) | ||
} | ||
} | ||
|
||
impl Course { | ||
pub async fn fetch( | ||
course_id: Option<u32>, | ||
base_url: &str, | ||
client: &Client, | ||
) -> Result<Course, anyhow::Error> { | ||
Ok(if let Some(course_id) = course_id { | ||
let course_response = client | ||
.get(format!( | ||
"{}/api/v1/courses/{}?include[]=favorites&include[]=concluded", | ||
base_url, course_id | ||
)) | ||
.send() | ||
.await? | ||
.json::<CourseResponse>() | ||
.await?; | ||
log::info!("Made REST request to get course information"); | ||
|
||
let course_colors: HashMap<u32, String> = client | ||
.get(format!("{}/api/v1/users/self/colors", base_url)) | ||
.send() | ||
.await? | ||
.json::<ColorsResponse>() | ||
.await? | ||
.custom_colors | ||
.into_iter() | ||
.filter(|(k, _)| k.starts_with("course_")) | ||
.map(|(k, v)| (k.trim_start_matches("course_").parse::<u32>().unwrap(), v)) | ||
.collect(); | ||
log::info!("Made REST request to get course colors"); | ||
|
||
let course = Course { | ||
name: course_response.name, | ||
id: course_response.id, | ||
is_favorite: course_response.is_favorite, | ||
css_color: course_colors.get(&course_response.id).cloned(), | ||
created_at: course_response.created_at, | ||
}; | ||
|
||
println!("✓ Found {course}"); | ||
course | ||
} else { | ||
let courses_response = client | ||
.get(format!( | ||
"{}/api/v1/courses?per_page=1000&include[]=favorites&include[]=concluded", | ||
base_url | ||
)) | ||
.send() | ||
.await? | ||
.json::<Vec<CourseResponse>>() | ||
.await?; | ||
log::info!("Made REST request to get favorite courses"); | ||
|
||
let course_colors: HashMap<u32, String> = client | ||
.get(format!("{}/api/v1/users/self/colors", base_url)) | ||
.send() | ||
.await? | ||
.json::<ColorsResponse>() | ||
.await? | ||
.custom_colors | ||
.into_iter() | ||
.filter(|(k, _)| k.starts_with("course_")) | ||
.map(|(k, v)| (k.trim_start_matches("course_").parse::<u32>().unwrap(), v)) | ||
.collect(); | ||
log::info!("Made REST request to get course colors"); | ||
|
||
println!("✓ Queried course information"); | ||
|
||
let mut courses: Vec<Course> = courses_response | ||
.into_iter() | ||
.filter(|course| !course.concluded) | ||
.map(|course| Course { | ||
name: course.name.clone(), | ||
id: course.id, | ||
is_favorite: course.is_favorite, | ||
css_color: course_colors.get(&course.id).cloned(), | ||
created_at: course.created_at, | ||
}) | ||
.collect(); | ||
|
||
courses.sort_by(|a, b| { | ||
b.is_favorite | ||
.cmp(&a.is_favorite) | ||
.then(a.created_at.cmp(&b.created_at)) | ||
}); | ||
Select::new("Course?", courses).prompt()? | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.