typ2anki 1.0.18

Compile Typst flashcards into Anki decks
use std::{
    collections::HashMap,
    io::{self, Write},
    path::PathBuf,
    sync::{Arc, Mutex, RwLock},
};

use crate::{
    card_wrapper::{TFiles, TypFileStats},
    config,
    output::*,
    utils,
};

use indicatif::{MultiProgress, ProgressBar, ProgressStyle};

pub struct OutputConsole {
    multi: Arc<MultiProgress>,
    bars: Arc<Mutex<HashMap<String, ProgressBar>>>,
    bars_visible: Arc<Mutex<bool>>,
    files: RwLock<Option<TFiles>>,
}

const PROGRESS_BAR_LENGTH: u64 = 40;

impl OutputConsole {
    pub fn new() -> Self {
        Self {
            multi: Arc::new(MultiProgress::new()),
            bars: Arc::new(Mutex::new(HashMap::new())),
            bars_visible: Arc::new(Mutex::new(false)),
            files: RwLock::new(None),
        }
    }

    fn create_progress_bars(&self, files: TFiles) {
        let mut stored_files = self.files.write().unwrap();
        *stored_files = Some(files.clone());

        let files = files.read().unwrap();
        {
            let mut visible = self.bars_visible.lock().unwrap();
            *visible = true;
        }

        let mut bars = self.bars.lock().unwrap();

        let cfg = config::get();

        let files_sorted: Vec<(&PathBuf, &TypFileStats)> = {
            let mut v: Vec<(&PathBuf, &TypFileStats)> = files.iter().collect();
            v.sort_by_key(|(path, _)| path.to_string_lossy().to_string());
            v
        };

        let filenames: Vec<String> = files_sorted
            .iter()
            .map(|(path, _)| {
                path.strip_prefix(&cfg.path)
                    .unwrap_or(path)
                    .to_string_lossy()
                    .into_owned()
            })
            .collect();

        let longest_path = filenames.iter().map(|p| p.len()).max().unwrap_or(20) as u64;
        let longest_count = files
            .values()
            .map(|stats| stats.total_cards)
            .max()
            .unwrap_or(0) as u64;
        let longest_count = longest_count.to_string().len() as u64;

        // Create a total progress bar
        {
            let total_cards: u64 = files.values().map(|s| s.total_cards as u64).sum();
            let longest_pos = total_cards.to_string().len() as u64;

            let pb = self.multi.add(ProgressBar::new(total_cards));
            let format = format!(
                "{{prefix}} [{{wide_bar:.red/green}}] {{pos:>{}}}/{{len:<{}}} {{per_sec:<2}} ETA: {{eta}}",
                longest_pos, longest_pos
            );
            pb.set_style(
                ProgressStyle::with_template(format.as_str())
                    .unwrap()
                    .progress_chars("##-"),
            );
            pb.set_prefix("All:");
            bars.insert("all".to_string(), pb);
        }

        for ((path, stats), filename) in files_sorted.iter().zip(filenames.iter()) {
            let pb = self.create_progress_bar(
                filename,
                longest_path + 1,
                longest_count,
                stats.total_cards as u64,
            );
            bars.insert(path.to_string_lossy().to_string(), pb);
        }
    }

    fn create_progress_bar(
        &self,
        file_name: &str,
        prefix_length: u64,
        longest_pos: u64,
        len: u64,
    ) -> ProgressBar {
        let pb = self.multi.add(ProgressBar::new(len));
        let format = format!(
            "{{prefix:{}}} [{{bar:{}.cyan/blue}}] {{pos:>{}}}/{{len:<{}}} {{msg}}",
            prefix_length, PROGRESS_BAR_LENGTH, longest_pos, longest_pos
        );
        pb.set_style(
            ProgressStyle::with_template(format.as_str())
                .unwrap()
                .progress_chars("=>-"),
        );
        pb.set_prefix(file_name.to_string());
        pb.set_message("");
        pb
    }

    fn progress_on_bar(&self, file_name: &str, inc: u64) {
        let bars = self.bars.lock().unwrap();
        if let Some(pb) = bars.get(file_name) {
            pb.inc(inc);
            if pb.position() >= pb.length().unwrap_or(0) {
                let stored_files = self.files.read().unwrap();
                let stored_files = stored_files.as_ref().unwrap().read().unwrap();
                let stats = stored_files
                    .iter()
                    .find(|(path, _)| path.to_string_lossy() == *file_name)
                    .map(|(_, stats)| stats)
                    .unwrap();
                pb.finish_with_message(stats.stats_colored());
            }
            bars.get("all").unwrap().inc(inc);
        }
    }

    fn println(&self, s: String) {
        let visible = self.bars_visible.lock().unwrap();
        if *visible {
            let _ = self.multi.println(s);
        } else {
            println!("{}", s);
        }
    }

    fn print_separator(&self) {
        let width = std::env::var("COLUMNS")
            .ok()
            .and_then(|v| v.parse::<usize>().ok())
            .filter(|&w| w > 0)
            .unwrap_or(80);
        self.println("=".repeat(width));
    }

    fn finish_all_bars(&self, files: TFiles) {
        let bars = self.bars.lock().unwrap();
        let files = files.read().unwrap();
        for (file, pb) in bars.iter() {
            if !pb.is_finished() {
                if file == "all" {
                    pb.finish();
                    continue;
                }
                let stats = files
                    .iter()
                    .find(|(path, _)| path.to_string_lossy() == *file)
                    .map(|(_, stats)| stats)
                    .unwrap();
                pb.finish_with_message(stats.stats_colored());
            }
        }
        {
            let mut visible = self.bars_visible.lock().unwrap();
            *visible = false;
        }
    }
}

impl OutputManager for OutputConsole {
    fn ask_yes_no(&self, _question: &str, _: bool) -> bool {
        loop {
            print!("{} [Y/n]: ", _question);
            let _ = io::stdout().flush();
            let mut input = String::new();
            if io::stdin().read_line(&mut input).is_err() {
                return false;
            }
            match input.trim().to_lowercase().as_str() {
                "y" | "yes" | "" => return true,
                "n" | "no" => return false,
                _ => println!("Please answer 'y' or 'n'."),
            }
        }
    }

    fn fail(&self) {
        self.send(OutputMessage::Fail(None));
    }

    fn fail_with_reason(&self, reason: String) {
        self.send(OutputMessage::Fail(Some(reason)));
    }

    fn send(&self, msg: OutputMessage) {
        match msg {
            OutputMessage::ListTypstFiles(files) => {
                self.print_separator();
                self.create_progress_bars(files);
            }
            OutputMessage::DbgShowConfig(cfg) => {
                println!("Current Configuration: {:#?}", cfg);
            }
            OutputMessage::DbgConfigChangeDetection {
                total_cards,
                config_changes,
            } => {
                println!(
                    "Configuration Change Detection: {} cards checked, {} configuration changes detected.",
                    total_cards, config_changes
                );
            }
            OutputMessage::DbgCreateDeck(deck_name) => {
                println!("Creating deck: {}", deck_name);
            }
            OutputMessage::DbgSavedCache => {
                println!("Cards cache saved successfully.");
            }
            OutputMessage::ParsingError(err) => {
                eprintln!("Parsing Error: {}", err);
            }
            OutputMessage::NoAnkiConnection => {
                utils::print_header(
                    &[
                        "Anki couldn't be detected.",
                        "Please make sure Anki is running and the AnkiConnect add-on is installed.",
                        "For more information about installing AnkiConnect, please see typ2anki's README",
                    ],
                    0,
                    '=',
                );
            }
            OutputMessage::ErrorSavingCache(e) => {
                eprintln!("Error saving cards cache: {}", e);
            }
            OutputMessage::SkipCompileCard(OutputCompiledCardInfo {
                file: relative_file,
                ..
            })
            | OutputMessage::PushedCard(OutputCompiledCardInfo {
                file: relative_file,
                ..
            }) => {
                self.progress_on_bar(&relative_file, 1);
            }
            OutputMessage::CompiledCard(OutputCompiledCardInfo { .. }) => {}
            OutputMessage::CompileError(OutputCompiledCardInfo {
                card_id,
                file: relative_file,
                card_status,
                error_message,
            }) => {
                self.println(format!(
                    "Error compiling card ID {} from file {} with status {:?}: {}",
                    card_id,
                    relative_file,
                    card_status,
                    error_message.unwrap_or("Unknown error".to_string())
                ));
                self.progress_on_bar(&relative_file, 1);
            }
            OutputMessage::PushError(OutputCompiledCardInfo {
                card_id,
                file: relative_file,
                card_status,
                error_message,
            }) => {
                self.println(format!(
                    "Error pushing card to anki: ID {} from file {} with status {:?}: {}",
                    card_id,
                    relative_file,
                    card_status,
                    error_message.unwrap_or("Unknown error".to_string())
                ));
                self.progress_on_bar(&relative_file, 1);
            }
            OutputMessage::DbgCompilationDone { files } => {
                self.finish_all_bars(files);
                self.println("".to_string());
                self.print_separator();
            }
            OutputMessage::TypstDownloadingPackage(pkg) => {
                self.println(format!("Downloading Typst package: {}", pkg));
            }
            OutputMessage::DbgDone => {}
            OutputMessage::Fail(reason) => {
                let cfg = config::get();
                if let Some(r) = reason {
                    println!("Fail reason: {}", r);
                }
                if cfg.keep_terminal_open {
                    println!("Press Enter to exit...");
                    let mut input = String::new();
                    let _ = std::io::stdin().read_line(&mut input);
                }
                std::process::exit(1);
            }
        }
    }
}