From 3dd203f246c07c456cc91b9b8a09cd981d16e101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Mikrut?= <41945903+qarmin@users.noreply.github.com> Date: Wed, 14 Oct 2020 21:10:27 +0200 Subject: [PATCH] Add support for finding similar images to CLI (#66) --- Cargo.lock | 96 ++++++- czkawka_cli/src/commands.rs | 23 +- czkawka_cli/src/main.rs | 34 ++- czkawka_core/Cargo.toml | 8 +- czkawka_core/src/big_file.rs | 2 +- czkawka_core/src/lib.rs | 1 + czkawka_core/src/similar_files.rs | 421 ++++++++++++++++++++++++++++++ czkawka_gui/Cargo.toml | 3 + 8 files changed, 578 insertions(+), 10 deletions(-) create mode 100644 czkawka_core/src/similar_files.rs diff --git a/Cargo.lock b/Cargo.lock index 307e8c1..374e18f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1" +[[package]] +name = "base64" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41b7ea54a0c9d92199de89e20e58d49f02f8e699814ef3fdf266f6f748d15c7" + [[package]] name = "base64" version = "0.12.3" @@ -109,6 +115,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "bk-tree" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5488039ea2c6de8668351415e39a0218a8955bffadcff0cf01d1293a20854584" + [[package]] name = "blake2b_simd" version = "0.5.10" @@ -343,9 +355,12 @@ dependencies = [ name = "czkawka_core" version = "1.1.0" dependencies = [ + "bk-tree", "blake3", "crossbeam-channel", "humansize", + "image", + "img_hash", ] [[package]] @@ -360,6 +375,7 @@ dependencies = [ "glib", "gtk", "humansize", + "open", ] [[package]] @@ -857,6 +873,19 @@ dependencies = [ "tiff", ] +[[package]] +name = "img_hash" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df6c5bc88f37a165c63143e38924f691246fc77f12de0cbd126fe0c8ca3527b" +dependencies = [ + "base64 0.11.0", + "image", + "rustdct", + "serde", + "transpose 0.2.0", +] + [[package]] name = "inflate" version = "0.4.5" @@ -1013,6 +1042,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg 1.0.1", + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.43" @@ -1070,6 +1109,15 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "260e51e7efe62b592207e9e13a68e43692a7a279171d6ba57abd208bf23645ad" +[[package]] +name = "open" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c283bf0114efea9e42f1a60edea9859e8c47528eae09d01df4b29c1e489cc48" +dependencies = [ + "winapi", +] + [[package]] name = "orbclient" version = "0.3.27" @@ -1636,7 +1684,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a58080b7bb83b2ea28c3b7a9a994fd5e310330b7c8ca5258d99b98128ecfe4" dependencies = [ - "base64", + "base64 0.12.3", "bitflags", "serde", ] @@ -1647,7 +1695,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dab61250775933275e84053ac235621dfb739556d5c54a2f2e9313b7cf43a19" dependencies = [ - "base64", + "base64 0.12.3", "blake2b_simd", "constant_time_eq", "crossbeam-utils", @@ -1672,6 +1720,28 @@ dependencies = [ "semver", ] +[[package]] +name = "rustdct" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4d167674b4cf68c2114bdbcd34c95aa9071652b73b0f43b19298f1d2780b7d" +dependencies = [ + "rustfft", +] + +[[package]] +name = "rustfft" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77008ed59a8923c8b4ac2e5eaa6d28fbe893d3b9515098a4a5fc7767d6430fe5" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "strength_reduce", + "transpose 0.1.0", +] + [[package]] name = "rusttype" version = "0.8.3" @@ -1865,6 +1935,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "strength_reduce" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ff2f71c82567c565ba4b3009a9350a96a7269eaa4001ebedae926230bc2254" + [[package]] name = "strsim" version = "0.8.0" @@ -2036,6 +2112,22 @@ dependencies = [ "serde", ] +[[package]] +name = "transpose" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e21580bb0627c7bb09e5cedbb42c8705b19d012de593ed6b0309270b3cd1e" + +[[package]] +name = "transpose" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3311ef71dea6a1fd6bf5bfc10ec5b4bef6174048f6b481dbc6ce915ff48c0a0" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "typed-arena" version = "2.0.1" diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 8edb518..0f69492 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -13,7 +13,7 @@ pub enum Commands { #[structopt(flatten)] excluded_items: ExcludedItems, #[structopt(short, long, parse(try_from_str = parse_minimal_file_size), default_value = "1024", help = "Minimum size in bytes", long_help = "Minimum size of checked files in bytes, assigning bigger value may speed up searching")] - min_size: u64, + minimal_file_size: u64, #[structopt(flatten)] allowed_extensions: AllowedExtensions, #[structopt(short, long, default_value = "HASH", parse(try_from_str = parse_checking_method), help = "Search method (SIZE, HASH, HASHMB)", long_help = "Methods to search files.\nSIZE - The fastest method, checking by the file's size,\nHASHMB - More accurate but slower, checking by the hash of the file's first mibibyte or\nHASH - The slowest method, checking by the hash of the entire file")] @@ -83,6 +83,21 @@ pub enum Commands { #[structopt(flatten)] not_recursive: NotRecursive, }, + #[structopt(name = "ima", about = "Finds similar images", help_message = HELP_MESSAGE, after_help = "EXAMPLE:\n czkawka ima -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt")] + SimilarImages { + #[structopt(flatten)] + directories: Directories, + #[structopt(flatten)] + excluded_directories: ExcludedDirectories, + #[structopt(short, long, parse(try_from_str = parse_minimal_file_size), default_value = "16384", help = "Minimum size in bytes", long_help = "Minimum size of checked files in bytes, assigning bigger value may speed up searching")] + minimal_file_size: u64, + #[structopt(flatten)] + excluded_items: ExcludedItems, + #[structopt(flatten)] + file_to_save: FileToSave, + #[structopt(flatten)] + not_recursive: NotRecursive, + }, } #[derive(Debug, StructOpt)] @@ -158,9 +173,9 @@ fn parse_delete_method(src: &str) -> Result { fn parse_minimal_file_size(src: &str) -> Result { match src.parse::() { - Ok(min_size) => { - if min_size > 0 { - Ok(min_size) + Ok(minimal_file_size) => { + if minimal_file_size > 0 { + Ok(minimal_file_size) } else { Err("Minimum file size must be at least 1 byte".to_string()) } diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index 9ad0597..fd393a2 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -10,6 +10,7 @@ use czkawka_core::{ duplicate::DuplicateFinder, empty_files::{self, EmptyFiles}, empty_folder::EmptyFolder, + similar_files::SimilarImages, temporary::{self, Temporary}, }; use std::{path::PathBuf, process}; @@ -31,7 +32,7 @@ fn main() { directories, excluded_directories, excluded_items, - min_size, + minimal_file_size, allowed_extensions, search_method, delete_method, @@ -43,7 +44,7 @@ fn main() { df.set_included_directory(path_list_to_str(directories.directories)); df.set_excluded_directory(path_list_to_str(excluded_directories.excluded_directories)); df.set_excluded_items(path_list_to_str(excluded_items.excluded_items)); - df.set_minimal_file_size(min_size); + df.set_minimal_file_size(minimal_file_size); df.set_allowed_extensions(allowed_extensions.allowed_extensions.join(",")); df.set_check_method(search_method); df.set_delete_method(delete_method); @@ -178,5 +179,34 @@ fn main() { tf.print_results(); tf.get_text_messages().print_messages(); } + Commands::SimilarImages { + directories, + excluded_directories, + excluded_items, + file_to_save, + minimal_file_size, + not_recursive, + } => { + let mut sf = SimilarImages::new(); + + sf.set_included_directory(path_list_to_str(directories.directories)); + sf.set_excluded_directory(path_list_to_str(excluded_directories.excluded_directories)); + sf.set_excluded_items(path_list_to_str(excluded_items.excluded_items)); + sf.set_minimal_file_size(minimal_file_size); + sf.set_recursive_search(!not_recursive.not_recursive); + + sf.find_similar_images(None); + + if let Some(file_name) = file_to_save.file_name() { + if !sf.save_results_to_file(file_name) { + sf.get_text_messages().print_messages(); + process::exit(1); + } + } + + #[cfg(not(debug_assertions))] // This will show too much probably unnecessary data to debug, comment line only if needed + sf.print_results(); + sf.get_text_messages().print_messages(); + } } } diff --git a/czkawka_core/Cargo.toml b/czkawka_core/Cargo.toml index 2f416da..204b86c 100644 --- a/czkawka_core/Cargo.toml +++ b/czkawka_core/Cargo.toml @@ -13,4 +13,10 @@ repository = "https://github.com/qarmin/czkawka" humansize = "1" blake3 = "0.3" #rayon = "1" -crossbeam-channel = "0.4.4" \ No newline at end of file +crossbeam-channel = "0.4.4" + + +# Needed by similar images +img_hash = "3.1" +bk-tree = "0.3" +image = "0.23" \ No newline at end of file diff --git a/czkawka_core/src/big_file.rs b/czkawka_core/src/big_file.rs index 5059f2e..174ce60 100644 --- a/czkawka_core/src/big_file.rs +++ b/czkawka_core/src/big_file.rs @@ -325,7 +325,7 @@ impl SaveResults for BigFile { } } } else { - write!(file, "Not found any empty folders.").unwrap(); + write!(file, "Not found any files.").unwrap(); } Common::print_time(start_time, SystemTime::now(), "save_results_to_file".to_string()); true diff --git a/czkawka_core/src/lib.rs b/czkawka_core/src/lib.rs index 669e01c..90b1e8f 100644 --- a/czkawka_core/src/lib.rs +++ b/czkawka_core/src/lib.rs @@ -10,5 +10,6 @@ pub mod common_extensions; pub mod common_items; pub mod common_messages; pub mod common_traits; +pub mod similar_files; pub const CZKAWKA_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/czkawka_core/src/similar_files.rs b/czkawka_core/src/similar_files.rs new file mode 100644 index 0000000..6e4557e --- /dev/null +++ b/czkawka_core/src/similar_files.rs @@ -0,0 +1,421 @@ +use crate::common::Common; +use crate::common_directory::Directories; +use crate::common_items::ExcludedItems; +use crate::common_messages::Messages; +use crate::common_traits::{DebugPrint, PrintResults, SaveResults}; +use bk_tree::{metrics, BKTree}; +use crossbeam_channel::Receiver; +use humansize::{file_size_opts as options, FileSize}; +use img_hash::HasherConfig; +use std::collections::HashMap; +use std::fs; +use std::fs::{File, Metadata}; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Eq, PartialEq)] +pub enum Similarity { + None, + Small, + Medium, + High, + VeryHigh, +} + +#[derive(Clone)] +pub struct FileEntry { + pub path: PathBuf, + pub size: u64, + pub modified_date: u64, + pub similarity: Similarity, +} +#[derive(Clone)] +pub struct StructSimilar { + pub base_image: FileEntry, + pub similar_images: Vec, +} + +/// Struct to store most basics info about all folder +pub struct SimilarImages { + information: Info, + text_messages: Messages, + directories: Directories, + excluded_items: ExcludedItems, + bktree: BKTree, + similar_vectors: Vec, + recursive_search: bool, + minimal_file_size: u64, + image_hashes: HashMap>, // Hashmap with image hashes and Vector with names of files + stopped_search: bool, +} + +/// Info struck with helpful information's about results +#[derive(Default)] +pub struct Info { + pub number_of_checked_files: usize, + pub number_of_checked_folders: usize, + pub number_of_ignored_files: usize, + pub number_of_ignored_things: usize, + pub size_of_checked_images: u64, + pub lost_space: u64, + pub number_of_removed_files: usize, + pub number_of_failed_to_remove_files: usize, + pub gained_space: u64, +} +impl Info { + pub fn new() -> Self { + Default::default() + } +} + +/// Method implementation for EmptyFolder +impl SimilarImages { + /// New function providing basics values + pub fn new() -> Self { + Self { + information: Default::default(), + text_messages: Messages::new(), + directories: Directories::new(), + excluded_items: Default::default(), + bktree: BKTree::new(metrics::Levenshtein), + similar_vectors: vec![], + recursive_search: true, + minimal_file_size: 1024 * 16, // 16 KB should be enough to exclude too small images from search + image_hashes: Default::default(), + stopped_search: false, + } + } + + pub fn get_stopped_search(&self) -> bool { + self.stopped_search + } + + pub const fn get_text_messages(&self) -> &Messages { + &self.text_messages + } + + pub const fn get_information(&self) -> &Info { + &self.information + } + + pub fn set_recursive_search(&mut self, recursive_search: bool) { + self.recursive_search = recursive_search; + } + + pub fn set_minimal_file_size(&mut self, minimal_file_size: u64) { + self.minimal_file_size = match minimal_file_size { + 0 => 1, + t => t, + }; + } + + /// Public function used by CLI to search for empty folders + pub fn find_similar_images(&mut self, rx: Option<&Receiver<()>>) { + self.directories.optimize_directories(true, &mut self.text_messages); + if !self.check_for_similar_images(rx) { + self.stopped_search = true; + return; + } + // if self.delete_folders { + // self.delete_empty_folders(); + // } + self.debug_print(); + } + + // pub fn set_delete_folder(&mut self, delete_folder: bool) { + // self.delete_folders = delete_folder; + // } + + /// Function to check if folder are empty. + /// Parameter initial_checking for second check before deleting to be sure that checked folder is still empty + fn check_for_similar_images(&mut self, rx: Option<&Receiver<()>>) -> bool { + let start_time: SystemTime = SystemTime::now(); + let mut folders_to_check: Vec = Vec::with_capacity(1024 * 2); // This should be small enough too not see to big difference and big enough to store most of paths without needing to resize vector + + // Add root folders for finding + for id in &self.directories.included_directories { + folders_to_check.push(id.clone()); + } + self.information.number_of_checked_folders += folders_to_check.len(); + + while !folders_to_check.is_empty() { + if rx.is_some() && rx.unwrap().try_recv().is_ok() { + return false; + } + let current_folder = folders_to_check.pop().unwrap(); + + // Read current dir, if permission are denied just go to next + let read_dir = match fs::read_dir(¤t_folder) { + Ok(t) => t, + Err(_) => { + self.text_messages.warnings.push(format!("Cannot open dir {}", current_folder.display())); + continue; + } // Permissions denied + }; + + // Check every sub folder/file/link etc. + 'dir: for entry in read_dir { + let entry_data = match entry { + Ok(t) => t, + Err(_) => { + self.text_messages.warnings.push(format!("Cannot read entry in dir {}", current_folder.display())); + continue; + } //Permissions denied + }; + let metadata: Metadata = match entry_data.metadata() { + Ok(t) => t, + Err(_) => { + self.text_messages.warnings.push(format!("Cannot read metadata in dir {}", current_folder.display())); + continue; + } //Permissions denied + }; + if metadata.is_dir() { + self.information.number_of_checked_folders += 1; + + if !self.recursive_search { + continue; + } + + let next_folder = current_folder.join(entry_data.file_name()); + if self.directories.is_excluded(&next_folder) { + continue 'dir; + } + + if self.excluded_items.is_excluded(&next_folder) { + continue 'dir; + } + + folders_to_check.push(next_folder); + } else if metadata.is_file() { + // let mut have_valid_extension: bool; + let file_name_lowercase: String = match entry_data.file_name().into_string() { + Ok(t) => t, + Err(_) => continue, + } + .to_lowercase(); + + // Checking allowed image extensions + let allowed_image_extensions = ["jpg", "png", "bmp"]; + if !allowed_image_extensions.iter().any(|e| file_name_lowercase.ends_with(e)) { + self.information.number_of_ignored_files += 1; + continue 'dir; + } + + // Checking files + if metadata.len() >= self.minimal_file_size { + let current_file_name = current_folder.join(entry_data.file_name()); + if self.excluded_items.is_excluded(¤t_file_name) { + continue 'dir; + } + + // Creating new file entry + let fe: FileEntry = FileEntry { + path: current_file_name.clone(), + size: metadata.len(), + modified_date: match metadata.modified() { + Ok(t) => match t.duration_since(UNIX_EPOCH) { + Ok(d) => d.as_secs(), + Err(_) => { + self.text_messages.warnings.push(format!("File {} seems to be modified before Unix Epoch.", current_file_name.display())); + 0 + } + }, + Err(_) => { + self.text_messages.warnings.push(format!("Unable to get modification date from file {}", current_file_name.display())); + continue; + } // Permissions Denied + }, + + similarity: Similarity::None, + }; + let hasher = HasherConfig::new().to_hasher(); + let image = match image::open(current_file_name) { + Ok(t) => t, + Err(_) => continue 'dir, // Something is wrong with image + }; + + let hash = hasher.hash_image(&image); + let string_hash = hash.to_base64(); + + self.bktree.add(string_hash.clone()); + self.image_hashes.entry(string_hash.clone()).or_insert_with(Vec::::new); + self.image_hashes.get_mut(&string_hash).unwrap().push(fe); + + self.information.size_of_checked_images += metadata.len(); + self.information.number_of_checked_files += 1; + } else { + // Probably this is symbolic links so we are free to ignore this + self.information.number_of_ignored_files += 1; + } + } else { + // Probably this is symbolic links so we are free to ignore this + self.information.number_of_ignored_things += 1; + } + } + } + + let hash_map_modification = SystemTime::now(); + + let mut new_vector: Vec = Vec::new(); + for (string_hash, vec_file_entry) in &self.image_hashes { + let vector_with_found_similar_hashes = self.bktree.find(string_hash.as_str(), 3).collect::>(); + if vector_with_found_similar_hashes.len() == 1 && vec_file_entry.len() == 1 { + // Exists only 1 unique picture, so there is no need to use it + continue; + } + + let mut vec_similarity_struct: Vec = Vec::new(); + + for file_entry in vec_file_entry.iter() { + let similar_struct = StructSimilar { + base_image: file_entry.clone(), + similar_images: vec_file_entry + .iter() + .filter(|x| x.path != file_entry.path) + .map(|x| { + let mut y = x.clone(); + y.similarity = Similarity::VeryHigh; + y + }) + .collect::>(), + }; + vec_similarity_struct.push(similar_struct); + } + + for (similarity, hash) in vector_with_found_similar_hashes.iter() { + if *similarity == 0 && string_hash == *hash { + // This was already readed before + continue; + } else if string_hash == *hash { + panic!("I'm not sure if same hash can have distance > 0"); + } + + for file_entry in self.image_hashes.get(*hash).unwrap() { + let mut file_entry = file_entry.clone(); + file_entry.similarity = match similarity { + 0 => Similarity::VeryHigh, + 1 => Similarity::High, + 2 => Similarity::Medium, + 3 => Similarity::Small, + _ => panic!("0-3 similarity levels are allowed, check if not added more."), + }; + for similarity_struct in vec_similarity_struct.iter_mut() { + similarity_struct.similar_images.push(file_entry.clone()); + } + } + } + + new_vector.append(&mut vec_similarity_struct); + } + + self.similar_vectors = new_vector; + + #[allow(clippy::blocks_in_if_conditions)] + Common::print_time(hash_map_modification, SystemTime::now(), "hash_map_modification(internal)".to_string()); + Common::print_time(start_time, SystemTime::now(), "check_for_similar_images".to_string()); + true + } + + /// Set included dir which needs to be relative, exists etc. + pub fn set_included_directory(&mut self, included_directory: String) { + self.directories.set_included_directory(included_directory, &mut self.text_messages); + } + + pub fn set_excluded_directory(&mut self, excluded_directory: String) { + self.directories.set_excluded_directory(excluded_directory, &mut self.text_messages); + } + + pub fn set_excluded_items(&mut self, excluded_items: String) { + self.excluded_items.set_excluded_items(excluded_items, &mut self.text_messages); + } +} +impl Default for SimilarImages { + fn default() -> Self { + Self::new() + } +} + +impl DebugPrint for SimilarImages { + #[allow(dead_code)] + #[allow(unreachable_code)] + fn debug_print(&self) { + #[cfg(not(debug_assertions))] + { + return; + } + + println!("---------------DEBUG PRINT---------------"); + println!("Number of all checked folders - {}", self.information.number_of_checked_folders); + println!("Included directories - {:?}", self.directories.included_directories); + println!("Checked images {} / Different photos {}", self.information.number_of_checked_files, self.image_hashes.len()); + println!( + "Size of checked images {} ({} Bytes)", + self.information.size_of_checked_images.file_size(options::BINARY).unwrap(), + self.information.size_of_checked_images + ); + println!("-----------------------------------------"); + } +} +impl SaveResults for SimilarImages { + fn save_results_to_file(&mut self, file_name: &str) -> bool { + let start_time: SystemTime = SystemTime::now(); + let file_name: String = match file_name { + "" => "results.txt".to_string(), + k => k.to_string(), + }; + + let mut file = match File::create(&file_name) { + Ok(t) => t, + Err(_) => { + self.text_messages.errors.push("Failed to create file ".to_string() + file_name.as_str()); + return false; + } + }; + + if writeln!( + file, + "Results of searching {:?} with excluded directories {:?} and excluded items {:?}", + self.directories.included_directories, self.directories.excluded_directories, self.excluded_items.items + ) + .is_err() + { + self.text_messages.errors.push(format!("Failed to save results to file {}", file_name)); + return false; + } + + if !self.similar_vectors.is_empty() { + write!(file, "{} images which have similar friends\n\n", self.similar_vectors.len()).unwrap(); + + for struct_similar in self.similar_vectors.iter() { + writeln!(file, "Image {:?} have {} similar images", struct_similar.base_image.path, struct_similar.similar_images.len()).unwrap(); + for similar_picture in struct_similar.similar_images.iter() { + writeln!(file, "{:?} - Similarity Level: {}", similar_picture.path, get_string_from_similarity(&similar_picture.similarity)).unwrap(); + } + writeln!(file).unwrap(); + } + } else { + write!(file, "Not found any similar images.").unwrap(); + } + Common::print_time(start_time, SystemTime::now(), "save_results_to_file".to_string()); + true + } +} +impl PrintResults for SimilarImages { + /// Prints basic info about empty folders // TODO print better + fn print_results(&self) { + if !self.similar_vectors.is_empty() { + println!("Found {} images which have similar friends", self.similar_vectors.len()); + } + } +} + +fn get_string_from_similarity(similarity: &Similarity) -> &str { + match similarity { + Similarity::Small => "Small", + Similarity::Medium => "Medium", + Similarity::High => "High", + Similarity::VeryHigh => "Very High", + Similarity::None => panic!(), + } +} diff --git a/czkawka_gui/Cargo.toml b/czkawka_gui/Cargo.toml index ba2bf60..6e0c369 100644 --- a/czkawka_gui/Cargo.toml +++ b/czkawka_gui/Cargo.toml @@ -19,6 +19,9 @@ chrono = "0.4" crossbeam-channel = "0.4.4" +# For opening files +open = "1.4.0" + [dependencies.gtk] version = "0.9.2" default-features = false # just in case