fb2-clean 0.6.0

Simple GUI/CLI utility to remove images from fb2 books
Documentation
mod logger;

use crate::{
    Config, Input, InputFile, InputFileType, Lang, Msg, Output, Result, Tags, display,
    ensure_long_path_prefix, msg,
};
use eframe::egui;
use log::{error, info};
use logger::{GuiLog, GuiLogger};
use std::{
    collections::VecDeque,
    path::Path,
    sync::{Arc, Mutex},
};

#[derive(Debug)]
pub struct App {
    cfg: Config,
    log: GuiLog,
    input_buf: String,
    output_buf: String,
    is_output_set: bool,
    tags_buf: String,
    lang: Lang,
}

impl Default for App {
    fn default() -> App {
        let mut cfg = Config::default();
        cfg.recursive = 16;
        let input_buf = cfg.input.to_string();
        let output_buf = display(&cfg.output.dir).to_string();

        let mut log_buf = VecDeque::with_capacity(logger::MAX_LINES);
        log_buf.push_back(format!("{}:\n'{}'", Msg::GuiSelectedToClean, &input_buf));
        log_buf.push_back(format!(
            "{}:\n'{}'",
            Msg::GuiSelectedSaveDirectory,
            &output_buf
        ));

        App {
            log: Arc::new(Mutex::new(log_buf)),
            input_buf,
            output_buf,
            is_output_set: false,
            tags_buf: cfg.tags.to_string(),
            cfg,
            lang: Default::default(),
        }
    }
}

impl App {
    pub fn init_logger(&self, ctx: &egui::Context) {
        let logger = GuiLogger {
            buf: self.log.clone(),
            ctx: ctx.clone(),
        };
        if log::set_boxed_logger(Box::new(logger)).is_ok() {
            log::set_max_level(log::LevelFilter::Info);
        }
    }
}

impl eframe::App for App {
    fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);

            ui.horizontal(|ui| {
                if ui
                    .add_sized(
                        [285.0, 32.0],
                        egui::Button::new(egui::RichText::new(msg!(GuiStart)).size(18.0)),
                    )
                    .clicked()
                {
                    let mut cfg = self.cfg.clone();
                    std::thread::spawn(move || {
                        if let Err(e) = start(&mut cfg) {
                            error!("{}", e);
                        }
                    });
                }
            });
            ui.separator();

            let old_lang = self.lang;
            egui::ComboBox::from_label(msg!(GuiLanguage))
                .selected_text(match self.lang {
                    Lang::Eng => "English",
                    Lang::Rus => "Русский",
                })
                .show_ui(ui, |ui| {
                    ui.selectable_value(&mut self.lang, Lang::Eng, "English");
                    ui.selectable_value(&mut self.lang, Lang::Rus, "Русский");
                });
            if self.lang != old_lang {
                let _ = Msg::set_lang(self.lang);
            }

            ui.add_space(10.0);
            ui.label(msg!(GuiSelectToClean));
            ui.horizontal(|ui| {
                if ui
                    .button(msg!(GuiDirectory))
                    .on_hover_text(msg!(GuiSelectDirectoryToClean))
                    .clicked()
                {
                    if let Some(path) = rfd::FileDialog::new().pick_folder() {
                        let path = ensure_long_path_prefix(path);
                        self.set_input(Input::Dir(path.into()));
                    }
                }
                ui.label(msg!(GuiOr));
                if ui
                    .button(msg!(GuiFiles))
                    .on_hover_text(msg!(GuiSelectFilesToClean))
                    .clicked()
                {
                    if let Some(xs) = rfd::FileDialog::new().pick_files() {
                        let mut files: Vec<InputFile> = Vec::with_capacity(xs.len());
                        for x in xs {
                            match InputFileType::get_new(&x) {
                                Some(ty) => files.push(InputFile {
                                    ty,
                                    path: ensure_long_path_prefix(x).into(),
                                }),
                                None => error!("unsupported file extension '{}'", display(&x)),
                            }
                        }

                        if !files.is_empty() {
                            self.set_input(Input::Files(files));
                        }
                    }
                }
            });
            ui.add_space(10.0);

            ui.add_enabled_ui(!self.cfg.overwrite, |ui| {
                if ui.button(msg!(GuiSelectSaveDirectory)).clicked() {
                    if let Some(path) = rfd::FileDialog::new().pick_folder() {
                        self.set_output(path);
                    }
                }
            });
            ui.add_space(10.0);

            ui.label(msg!(GuiRemoveTags)).on_hover_text(msg!(HelpTags));
            if ui.text_edit_singleline(&mut self.tags_buf).lost_focus() {
                let new_tags = Tags::new(&self.tags_buf);
                self.tags_buf = new_tags.to_string();
                if new_tags != self.cfg.tags {
                    self.cfg.tags = new_tags;
                    info!("{}: '{}'", Msg::GuiTagsSet, self.tags_buf);
                }
            }
            ui.add_space(10.0);

            ui.horizontal(|ui| {
                ui.label(msg!(GuiMultithreading));
                ui.add(egui::DragValue::new(&mut self.cfg.jobs).range(1..=Config::default_jobs()));
            });

            ui.add_enabled_ui(matches!(self.cfg.input, Input::Dir(_)), |ui| {
                ui.horizontal(|ui| {
                    ui.label(msg!(GuiRecursiveSearch))
                        .on_hover_text(msg!(HelpRecursive));
                    ui.add(egui::DragValue::new(&mut self.cfg.recursive).range(0..=255));
                });
            });
            ui.add_space(10.0);

            ui.horizontal(|ui| {
                ui.add_enabled_ui(!self.cfg.unzip, |ui| {
                    ui.checkbox(&mut self.cfg.zip, "zip")
                        .on_hover_text(msg!(HelpZip));
                });
                ui.add_enabled_ui(!self.cfg.zip, |ui| {
                    ui.checkbox(&mut self.cfg.unzip, "unzip")
                        .on_hover_text(msg!(HelpUnzip));
                });
            });

            ui.checkbox(&mut self.cfg.overwrite, msg!(GuiOverwrite))
                .on_hover_text(msg!(HelpOverwrite));

            ui.checkbox(&mut self.cfg.exit_on_err, msg!(GuiStopOnError))
                .on_hover_text(msg!(HelpExitOnError));

            ui.separator();
            ui.label(msg!(GuiLog));
            egui::ScrollArea::vertical()
                .stick_to_bottom(true)
                .max_height(150.0)
                .show(ui, |ui| {
                    if let Ok(log) = self.log.try_lock() {
                        for l in log.iter() {
                            ui.label(l);
                        }
                    }
                });
        });
    }
}

impl App {
    fn set_input(&mut self, input: Input) {
        self.input_buf = input.to_string();
        if input != self.cfg.input {
            self.cfg.input = input;
            info!("{}:\n'{}'", Msg::GuiSelectedToClean, self.input_buf);
        }

        if !self.is_output_set {
            if let Ok(output) = Output::try_from_input(&self.cfg.input) {
                self.set_output(output.dir);
                self.is_output_set = false;
            }
        }
    }

    fn set_output(&mut self, dir: impl Into<Box<Path>>) {
        let new = Output {
            dir: dir.into(),
            ..Default::default()
        };
        self.output_buf = display(&new.dir).to_string();
        if new != self.cfg.output {
            self.cfg.output = new;
            self.is_output_set = true;
            info!("{}:\n'{}'", Msg::GuiSelectedSaveDirectory, self.output_buf);
        }
    }
}

fn start(cfg: &mut Config) -> Result<()> {
    cfg.output.create_dirs()?;
    let res = cfg.run();
    cfg.output.remove_created_dirs();
    res
}

impl Input {
    fn to_string(&self) -> String {
        match self {
            Self::Dir(path) => display(&path).to_string(),
            Self::Files(xs) => xs
                .iter()
                .map(|p| display(&p.path).to_string())
                .collect::<Vec<_>>()
                .join("\n"),
        }
    }
}