Skip to content

Commit

Permalink
add download functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
mbund committed Jan 15, 2024
1 parent d7cb74b commit da2d5f8
Show file tree
Hide file tree
Showing 6 changed files with 343 additions and 147 deletions.
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ csscolorparser = "0.6.2"
env_logger = "0.10.1"
futures = "0.3.30"
fuzzy-matcher = "0.3.7"
human_bytes = "0.4.3"
indicatif = "0.17.7"
inquire = "0.6.2"
log = "0.4.20"
Expand Down
175 changes: 175 additions & 0 deletions src/download.rs
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(())
}
136 changes: 136 additions & 0 deletions src/lib.rs
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()?
})
}
}
3 changes: 3 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use clap::{CommandFactory, Parser, Subcommand};
use serde_derive::{Deserialize, Serialize};

pub mod auth;
pub mod download;
pub mod submit;

#[derive(Serialize, Deserialize, Debug)]
Expand Down Expand Up @@ -31,6 +32,7 @@ struct Args {
enum Action {
Auth(auth::AuthCommand),
Submit(submit::SubmitCommand),
Download(download::DownloadCommand),

/// Generate shell completions
Completions {
Expand All @@ -50,6 +52,7 @@ async fn main() -> Result<(), anyhow::Error> {
match args.action {
Action::Auth(command) => command.action(&mut cfg).await,
Action::Submit(command) => command.action(&cfg).await,
Action::Download(command) => command.action(&cfg).await,

Action::Completions { shell } => Ok({
shell.generate(&mut Args::command(), &mut std::io::stdout());
Expand Down
Loading

0 comments on commit da2d5f8

Please sign in to comment.