canvas-lms-sync 0.1.0

Synchronizes your course files and modules on Canvas LMS to your local machine.
Documentation
use std::{collections::HashMap, error::Error, vec};

use canvas_lms_sync::{canvas_api::Client, download::Downloader, File};
use futures::StreamExt;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;

#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
    token: String,
    host: String,
    courseid: i64,
    usemodules: bool,
}

impl Config {
    pub fn read_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Box<dyn Error>> {
        let file = std::fs::File::open(path)?;
        let config = serde_yaml::from_reader(file)?;
        Ok(config)
    }
}

pub struct IndentStack<T> {
    stack: Vec<(i64, T)>,
}

impl<T> IndentStack<T> {
    pub fn new() -> Self {
        Self { stack: Vec::new() }
    }
    pub fn add(&mut self, indent: i64, item: T) {
        self.stack.retain(|(i, _)| *i < indent);
        self.stack.push((indent, item));
    }
    pub fn get(&self) -> Vec<&T> {
        self.stack.iter().map(|(_, item)| item).collect()
    }
}

async fn download_modules(config: &Config, client: &Client, downloader: &Downloader) {
    client
        .list_modules(config.courseid)
        .for_each(|module| async {
            let indent = Mutex::new(IndentStack::new());
            match module {
                Ok(module) => {
                    client
                        .list_module_items(config.courseid, module.id)
                        .for_each(|item| async {
                            match item {
                                Ok(item) => {
                                    debug!("Item: {:?}", item);
                                    match item.type_.as_str() {
                                        "SubHeader" => {
                                            indent.lock().await.add(item.indent, item);
                                        }
                                        "File" => {
                                            let file = client
                                                .get_course_file(
                                                    config.courseid,
                                                    item.content_id.expect("No content id"),
                                                )
                                                .await
                                                .unwrap();

                                            let mut file = File::from(file);

                                            file.folder_path =
                                                vec!["Modules".to_string(), module.name.clone()];
                                            file.folder_path.extend(
                                                indent
                                                    .lock()
                                                    .await
                                                    .get()
                                                    .iter()
                                                    .map(|item| item.title.clone()),
                                            );

                                            if file.local_file_matches().unwrap_or(false) {
                                                debug!("File already downloaded: {:?}", file);
                                                return;
                                            }

                                            downloader.submit(file.into());
                                        }
                                        _ => {}
                                    }
                                }
                                Err(e) => error!("Failed getting module items: {:?}", e),
                            }
                        })
                        .await;
                }
                Err(e) => error!("Failed getting modules: {:?}", e),
            }
        })
        .await;
}

async fn download_files(config: &Config, client: &Client, downloader: &Downloader) {
    let folders = Mutex::new(HashMap::new());

    client
        .get_all_folders(config.courseid)
        .for_each(|folder| async {
            match folder {
                Ok(folder) => {
                    folders.lock().await.insert(folder.id, folder.clone());
                }
                Err(e) => error!("Failed getting folders: {:?}", e),
            }
        })
        .await;

    let folders = folders.into_inner();

    client
        .get_all_files(config.courseid)
        .for_each(|file| async {
            match file {
                Ok(file) => {
                    let folder_id = file.folder_id;
                    let mut file = File::from(file);
                    file.set_folder_path(&folders, folder_id);

                    if file.local_file_matches().unwrap_or(false) {
                        debug!("File already exists: {:?}", file);
                        return;
                    }

                    downloader.submit(file.into());
                }
                Err(e) => error!("Failed getting files: {:?}", e),
            }
        })
        .await;
}

#[tokio::main]
async fn main() {
    env_logger::init();

    let config = Config::read_from_path("canvas-sync.yml").expect("Failed to read config file");
    let client = Client::new(config.host.clone(), config.token.clone());

    let mut downloader = Downloader::new(reqwest::Client::new(), 4);

    if config.usemodules {
        download_modules(&config, &client, &downloader).await;
    } else {
        download_files(&config, &client, &downloader).await;
    };

    info!("Waiting for downloads to finish...");
    let mut ticker = tokio::time::interval(std::time::Duration::from_secs(1));

    loop {
        tokio::select! {
            _ = ticker.tick() => {
                for progress in downloader.progress().iter() {
                    let progress = progress.lock().unwrap();
                    if let Some(progress) = progress.as_ref() {
                        info!("Progress: {:?}", progress);
                    }
                }
            }
            _ = downloader.finish() => {
                info!("Downloads finished");
                break;
            }
        }
    }
}