diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..4c9c992 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,24 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install gexiv2 + run: sudo apt update && sudo apt install libgexiv2-2 libgexiv2-dev -y + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/.gitignore b/.gitignore index 67210e1..8733ace 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ Cargo.lock *.png *.jpeg *.jpg -*.heic \ No newline at end of file +*.heic +!/tests/resources/* diff --git a/Cargo.toml b/Cargo.toml index 61c93a3..14952c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,11 @@ anyhow = "1.0" rexiv2 = "0.10" +[dev-dependencies] +regex = "1.10" +tempdir = "0.3" + + [lib] name = "rsexif" path = "src/lib.rs" diff --git a/LICENSE b/LICENSE index 30f778c..4f10a3e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 loic-prn +Copyright (c) 2024 i5-650 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index a676b6f..7c7e05b 100644 --- a/README.md +++ b/README.md @@ -40,20 +40,6 @@ Options: -h, --help Print help ``` -## Examples -### Read exif data from an image and save it to a json file -``` -rusty-exif -f image.jpg -e exif.json -``` -### Read exif data from multiple images and save it to a json file -``` -rusty-exif -F folder -e exif.json -``` -### Read exif data from a file and print everything -``` -rusty-exif -f image.jpg -``` - ## To-do - [x] Read exif data - [x] Write exif data in a json file (for one or multiple files) diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..594556f --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,71 @@ +use crate::utils; +use std::collections::{HashMap, BTreeMap}; +use crate::models::Image; +use std::fs; +use std::path::PathBuf; +use rayon::prelude::*; + +pub fn from_file(path: String) -> HashMap> { + + let metadata = match rexiv2::Metadata::new_from_path(path) { + Ok(m) => m, + Err(e) => { + println!("[*] Error while reading the exif from a file: {}", e); + return HashMap::new(); + } + }; + + let tags = match metadata.get_exif_tags() { + Ok(t) => t, + Err(e) => { + println!("[*] Error while retreving the exif: {}", e); + return HashMap::new(); + } + }; + + let map_data = tags.iter() + .map(|tag| { + let value = match metadata.get_tag_interpreted_string(tag) { + Ok(val) => val, + Err(_) => String::from("Failed to convert to string"), + }; + + // Exifs tags are like: Exif.Categ.TheTag + let parts: Vec<&str> = tag.split('.').collect(); + if parts.len() >= 3 { + let category = parts[1].to_string(); + let tag_name = parts[2..].join("."); + (category, tag_name, value) + } else { + let category = "Unknown".to_string(); + let tag_name = parts[parts.len() -1].to_string(); + (category, tag_name, value) + } + }) + // We want the exifs to be in categories so we make a map of map + .fold(HashMap::new(), |mut acc: HashMap>, (category, tag_name, value)| { + // Use a BTreeMap to keep the elements sorted (better readability) + acc.entry(category) + .or_default() + .insert(tag_name, value); + acc + }); + + utils::add_google_map(map_data) +} + +pub fn from_dir(_path: PathBuf) -> Vec { + let files = fs::read_dir(_path).expect("Couldn't read the directory given"); + + files.par_bridge() + .filter_map(|f| f.ok()) + .filter(|f| !f.path().ends_with(".DS_Store") && !f.path().ends_with("/")) + .map(|f| { + let entry_path = f.path().display().to_string(); + Image{ + name: entry_path.clone(), + exifs: from_file(entry_path) + } + }).collect::>() +} + diff --git a/src/lib.rs b/src/lib.rs index 3156cd6..9b63cf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,109 +1,6 @@ -use std::path::PathBuf; -use std::{fs, collections::HashMap, collections::BTreeMap}; -use scanf::sscanf; -use rayon::prelude::*; - -use models::image::Image; - pub mod models; +pub mod utils; +pub mod modules; +pub mod core; -const GOOGLE_MAP: &str = "googleMap"; - - -pub fn from_file(path: String) -> HashMap> { - - let metadata = match rexiv2::Metadata::new_from_path(path) { - Ok(m) => m, - Err(e) => { - println!("[*] Error while reading the exif from a file: {}", e); - return HashMap::new(); - } - }; - - let tags = match metadata.get_exif_tags() { - Ok(t) => t, - Err(e) => { - println!("[*] Error while retreving the exif: {}", e); - return HashMap::new(); - } - }; - - let map_data = tags.iter() - .map(|tag| { - let value = match metadata.get_tag_interpreted_string(tag) { - Ok(val) => val, - Err(_) => String::from("Failed to convert to string"), - }; - - // Exifs tags are like: Exif.Categ.TheTag - let parts: Vec<&str> = tag.split('.').collect(); - if parts.len() >= 3 { - let category = parts[1].to_string(); - let tag_name = parts[2..].join("."); - (category, tag_name, value) - } else { - let category = "Unknown".to_string(); - let tag_name = parts[parts.len() -1].to_string(); - (category, tag_name, value) - } - }) - // We want the exifs to be in categories so we make a map of map - .fold(HashMap::new(), |mut acc: HashMap>, (category, tag_name, value)| { - // Use a BTreeMap to keep the elements sorted (better readability) - acc.entry(category) - .or_default() - .insert(tag_name, value); - acc - }); - - add_google_map(map_data) -} - - - -fn add_google_map(mut map_data :HashMap>) -> HashMap> { - - if !map_data.contains_key("GPSInfo") { - return map_data; - } - - let gps_info = map_data.get_mut("GPSInfo").expect("Impossible missing GPSInfo"); - - if let (Some(longitude), Some(latitude)) = (gps_info.get("GPSLatitude"), gps_info.get("GPSLongitude")) { - gps_info.insert( - GOOGLE_MAP.to_string(), - format!("https://www.google.com/maps/search/?api=1&query={},{}", - to_decimal(latitude), - to_decimal(longitude) - ) - ); - } - map_data -} - -fn to_decimal(dms: &str) -> f64 { - let mut degrees: f64 = 0.0; - let mut minutes: f64 = 0.0; - let mut seconds: f64 = 0.0; - - if sscanf!(dms, "{f64} deg {f64}' {f64}\"", degrees, minutes, seconds).is_err() { - return 0.0; - } - - degrees + minutes / 60.0 + seconds / 3600.0 -} - -pub fn from_folder(_path: PathBuf) -> Vec { - let files = fs::read_dir(_path).expect("Couldn't read the directory given"); - - files.par_bridge() - .filter_map(|f| f.ok()) - .filter(|f| !f.path().ends_with(".DS_Store") && !f.path().ends_with("/")) - .map(|f| { - let entry_path = f.path().display().to_string(); - Image{ - name: entry_path.clone(), - exifs: from_file(entry_path) - } - }).collect::>() -} +pub use core::*; diff --git a/src/main.rs b/src/main.rs index 4f772c7..6517d8d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,9 @@ use clap::{Parser, Subcommand}; -use rayon::{ - prelude::ParallelSliceMut, - iter::{ - IntoParallelRefMutIterator, - ParallelIterator - } -}; - -extern crate rsexif; - -use std::fs::File; -use std::io::{self, Write}; -use std::path::PathBuf; use std::println; use anyhow::{Result, anyhow, Error}; +extern crate rsexif; +use rsexif::modules; #[derive(Parser)] #[command( @@ -43,8 +32,8 @@ enum Commands { #[command(arg_required_else_help = true, long_flag = "dir", short_flag = 'd', about = "Extract exif from every files in a directory")] Dir { - #[arg(value_name = "folder", required = true, help = "directory containing images to extract exifs from")] - folder: String, + #[arg(value_name = "dir", required = true, help = "directory containing images to extract exifs from")] + dir: String, #[arg(value_name = "split", required = false, conflicts_with = "export_folder", short = 's', long = "split", help = "Wether you decide to store all exifs into one file or multiples")] split: bool, @@ -63,11 +52,11 @@ fn main() -> Result<(), Error> { let m_file = file.as_str(); let export = export.to_owned(); let json = json.to_owned(); - file_module(m_file.to_string(), export, json) + modules::file_module(m_file.to_string(), export, json) }, - Commands::Dir { folder, split, export_folder } => { - folder_module(folder, *split, export_folder) + Commands::Dir { dir, split, export_folder } => { + modules::dir_module(dir, *split, export_folder) } }; @@ -79,74 +68,3 @@ fn main() -> Result<(), Error> { } } - -fn file_module(filename: String, export_file: Option, json: bool) -> Result<(), Error> { - let exifs = rsexif::from_file(filename); - - if export_file.is_some() || json { - let serialized = serde_json::to_string_pretty(&exifs).expect("Map must be "); - - if json { - println!("{}", serialized); - } else { - let mut export = export_file.unwrap(); - let mut json_file = create_json_file(&mut export)?; - json_file.write_all(serialized.as_bytes())? - } - - } else { - exifs.iter().for_each(|(categ, sub_exifs)| { - println!("{}", categ); - sub_exifs.iter().for_each(|(key, val)| { - println!("\t{}: {}", key, val); - }) - }); - } - Ok(()) -} - -#[inline(always)] -fn create_json_file(filename: &mut String) -> Result { - if !filename.ends_with(".json") { - filename.push_str(".json"); - } - File::create(filename) -} - - -fn folder_module(folder_name: &String, split: bool, export_file: &Option) -> Result<(), Error> { - let folder = PathBuf::from(folder_name); - let mut exifs = rsexif::from_folder(folder); - - if split { - - let list_err = exifs.as_parallel_slice_mut() - .par_iter_mut() - .map(|img: &mut rsexif::models::Image| { - let serialized = serde_json::to_string_pretty(&img).expect("Must be a Map"); - img.name.push_str(".json"); - exif_to_json(serialized, &img.name) - }) - .collect::>>(); - - if !list_err.is_empty() { - println!("[/!\\] Error encountered while creating/writing to files."); - } - - } else if let Some(mut export) = export_file.to_owned() { - let mut export = create_json_file(&mut export)?; - let serialized = serde_json::to_string_pretty(&exifs).expect("Map must be "); - export.write_all(serialized.as_bytes())? - } - - Ok(()) -} - -fn exif_to_json(content: String, path: &String) -> Result<()> { - let mut json_file = File::create(path)?; - match json_file.write_all(content.as_bytes()) { - Ok(_) => Ok(()), - Err(e) => Err(anyhow!(e)) - } -} - diff --git a/src/modules/dir.rs b/src/modules/dir.rs new file mode 100644 index 0000000..79741fb --- /dev/null +++ b/src/modules/dir.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; +use rayon::prelude::*; +use anyhow::{Result, Error}; + +use crate::models::Image; +use crate::core; +use crate::utils; + +pub fn dir_module(folder_name: &String, split: bool, export_file: &Option) -> Result<(), Error> { + let folder = PathBuf::from(folder_name); + let mut exifs = core::from_dir(folder); + + if split { + + let list_err = exifs.as_parallel_slice_mut() + .par_iter_mut() + .map(|img: &mut Image| { + let serialized = serde_json::to_string_pretty(&img).expect("Must be a Map"); + utils::write_json_to_file(serialized, &mut img.name) + }) + .collect::>>(); + + if !list_err.is_empty() { + println!("[/!\\] Error encountered while creating/writing to files."); + } + + } else if let Some(mut export) = export_file.to_owned() { + let serialized = serde_json::to_string_pretty(&exifs).expect("Map must be "); + utils::write_json_to_file(serialized, &mut export)?; + } + + Ok(()) +} + diff --git a/src/modules/file.rs b/src/modules/file.rs new file mode 100644 index 0000000..81cc945 --- /dev/null +++ b/src/modules/file.rs @@ -0,0 +1,29 @@ +use anyhow::{Error, Result}; + +use crate::core; +use crate::utils; + +pub fn file_module(filename: String, export_file: Option, json: bool) -> Result<(), Error> { + let exifs = core::from_file(filename); + + if export_file.is_some() || json { + let serialized = serde_json::to_string_pretty(&exifs).expect("Map must be "); + + if json { + println!("{}", serialized); + } else { + let mut export = export_file.unwrap(); + utils::write_json_to_file(serialized, &mut export)? + } + + } else { + exifs.iter().for_each(|(categ, sub_exifs)| { + println!("{}", categ); + sub_exifs.iter().for_each(|(key, val)| { + println!(" {}: {}", key, val); + }) + }); + } + Ok(()) +} + diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..65f8824 --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1,6 @@ +pub mod file; +pub mod dir; + + +pub use file::*; +pub use dir::*; diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..0f9109b --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,56 @@ +use scanf::sscanf; +use std::collections::{HashMap, BTreeMap}; +use anyhow::{anyhow, Result}; +use std::fs::File; +use std::io::Write; + +pub const GOOGLE_MAP: &str = "googleMap"; + +pub fn to_decimal(dms: &str) -> f64 { + let mut degrees: f64 = 0.0; + let mut minutes: f64 = 0.0; + let mut seconds: f64 = 0.0; + + if sscanf!(dms, "{f64} deg {f64}' {f64}\"", degrees, minutes, seconds).is_err() { + return 0.0; + } + + degrees + minutes / 60.0 + seconds / 3600.0 +} + +pub fn add_google_map(mut map_data :HashMap>) -> HashMap> { + + if !map_data.contains_key("GPSInfo") { + return map_data; + } + + let gps_info = map_data.get_mut("GPSInfo").expect("Impossible missing GPSInfo"); + + if let (Some(longitude), Some(latitude)) = (gps_info.get("GPSLatitude"), gps_info.get("GPSLongitude")) { + gps_info.insert( + GOOGLE_MAP.to_string(), + format!("https://www.google.com/maps/search/?api=1&query={},{}", + to_decimal(latitude), + to_decimal(longitude) + ) + ); + } + map_data +} + +#[inline(always)] +pub fn create_json_file(filename: &mut String) -> Result { + if !filename.ends_with(".json") { + filename.push_str(".json"); + } + File::create(filename) +} + +pub fn write_json_to_file(content: String, path: &mut String) -> Result<()> { + let mut json_file = create_json_file(path)?; + match json_file.write_all(content.as_bytes()) { + Ok(_) => Ok(()), + Err(e) => Err(anyhow!(e)) + } +} + diff --git a/tests/core/mod.rs b/tests/core/mod.rs new file mode 100644 index 0000000..ff09535 --- /dev/null +++ b/tests/core/mod.rs @@ -0,0 +1,66 @@ + +extern crate rsexif; + +use rsexif::core; +use std::path::PathBuf; + +#[test] +fn from_file_test() { + // Act + let exifs = core::from_file("./tests/resources/Canon_40D.jpg".to_string()); + + + // Assert + assert!(!exifs.is_empty()); + assert_eq!(5, exifs.len()); + assert!(exifs.contains_key("Thumbnail")); + assert!(exifs.contains_key("GPSInfo")); + assert!(exifs.contains_key("Photo")); + assert!(exifs.contains_key("Image")); + assert!(exifs.contains_key("Iop")); +} + + +#[test] +fn from_dir_test() { + // Given + let folder_pathbuf = PathBuf::from("./tests/resources/"); + + // Act + let exifs = core::from_dir(folder_pathbuf); + + + // Assert + assert!(!exifs.is_empty()); + assert_eq!(3, exifs.len()); + + let mut checked = 0; + exifs.iter().for_each(|e| { + let name = e.name.as_str(); + if name == "./tests/resources/Canon_40D.jpg" { + checked+=1; + assert!(e.exifs.contains_key("Thumbnail")); + assert!(e.exifs.contains_key("GPSInfo")); + assert!(e.exifs.contains_key("Photo")); + assert!(e.exifs.contains_key("Image")); + assert!(e.exifs.contains_key("Iop")); + } + + if name == "./tests/resources/Canon_40D_photoshop_import.jpg" { + checked+=1; + assert!(e.exifs.contains_key("Photo")); + assert!(e.exifs.contains_key("Image")); + assert!(e.exifs.contains_key("Thumbnail")); + } + + + if name == "./tests/resources/Nikon_D70.jpg" { + checked+=1; + assert!(e.exifs.contains_key("Photo")); + assert!(e.exifs.contains_key("Image")); + assert!(e.exifs.contains_key("Thumbnail")); + } + }); + + assert_eq!(3, checked); +} diff --git a/tests/lib.rs b/tests/lib.rs new file mode 100644 index 0000000..209a9a8 --- /dev/null +++ b/tests/lib.rs @@ -0,0 +1,6 @@ +#[cfg(test)] +pub mod core; +#[cfg(test)] +pub mod modules; +#[cfg(test)] +pub mod utils; diff --git a/tests/modules/dir.rs b/tests/modules/dir.rs new file mode 100644 index 0000000..4ccf762 --- /dev/null +++ b/tests/modules/dir.rs @@ -0,0 +1,51 @@ +use tempdir::TempDir; +use anyhow::{Error, Result}; +use std::fs; + +extern crate rsexif; + +use rsexif::modules; + + +#[test] +fn dir_module_no_split() -> Result<(), Error>{ + // Given + let tempdir = TempDir::new("dir_module_split")?; + + let export_file = tempdir.path().join("export_test_file.json"); + + + // Act + let res = modules::dir_module(&"./tests/resources/".to_string(), false, &Some(export_file.display().to_string())); + + // Assert + assert!(res.is_ok()); + assert!(export_file.as_path().exists()); + assert_ne!(0, std::fs::metadata(export_file)?.len()); + + Ok(()) +} + +#[test] +fn dir_module_split() -> Result<(), Error>{ + // Act + let res = modules::dir_module(&"./tests/resources/".to_string(), true, &None); + + // Assert + assert!(res.is_ok()); + + let paths = fs::read_dir("./tests/resources/")?; + + let mut count = 0; + for path in paths { + let path = path?.path(); + if path.extension().unwrap() == "json" { + assert_ne!(0, std::fs::metadata(path.clone())?.len()); + count+=1; + std::fs::remove_file(path)?; + } + } + assert_eq!(3, count); + + Ok(()) +} diff --git a/tests/modules/file.rs b/tests/modules/file.rs new file mode 100644 index 0000000..9e66f0b --- /dev/null +++ b/tests/modules/file.rs @@ -0,0 +1,42 @@ +use anyhow::{Result, Error}; +use tempdir::TempDir; + +extern crate rsexif; + +use rsexif::modules; + +#[test] +fn file_module_export() -> Result<(), Error>{ + // Given + let tempdir = TempDir::new("file_module_export")?; + + let export_file = tempdir.path().join("export_test_file.json"); + + + // Act + let res = modules::file_module("./tests/resources/Canon_40D.jpg".to_string(), Some(export_file.display().to_string()), false); + + // Assert + assert!(res.is_ok()); + assert!(export_file.as_path().exists()); + + Ok(()) +} + +#[test] +fn file_module_print_json() { + // Act + let res = modules::file_module("./tests/resources/Canon_40D.jpg".to_string(), None, true); + + // Assert + assert!(res.is_ok()); +} + +#[test] +fn file_module_print() { + // Act + let res = modules::file_module("./tests/resources/Canon_40D.jpg".to_string(), None, false); + + // Assert + assert!(res.is_ok()); +} diff --git a/tests/modules/mod.rs b/tests/modules/mod.rs new file mode 100644 index 0000000..25e8dd1 --- /dev/null +++ b/tests/modules/mod.rs @@ -0,0 +1,2 @@ +pub mod dir; +pub mod file; diff --git a/tests/resources/Canon_40D.jpg b/tests/resources/Canon_40D.jpg new file mode 100644 index 0000000..6eb33f1 Binary files /dev/null and b/tests/resources/Canon_40D.jpg differ diff --git a/tests/resources/Canon_40D_photoshop_import.jpg b/tests/resources/Canon_40D_photoshop_import.jpg new file mode 100644 index 0000000..10ee97e Binary files /dev/null and b/tests/resources/Canon_40D_photoshop_import.jpg differ diff --git a/tests/resources/Nikon_D70.jpg b/tests/resources/Nikon_D70.jpg new file mode 100644 index 0000000..8ea0bcc Binary files /dev/null and b/tests/resources/Nikon_D70.jpg differ diff --git a/tests/utils/mod.rs b/tests/utils/mod.rs new file mode 100644 index 0000000..6f41568 --- /dev/null +++ b/tests/utils/mod.rs @@ -0,0 +1,119 @@ +use std::collections::{HashMap, BTreeMap}; +use tempdir::TempDir; +use regex::Regex; + +extern crate rsexif; + +use rsexif::utils; + + +#[test] +fn to_decimal_test_ok() { + // Given + let dms = "50 deg 28' 36.63\""; + + // Act + let decimal = utils::to_decimal(dms); + + // Assert + assert_ne!(0.0, decimal); +} + + +#[test] +fn to_decimal_test_ko() { + // Given + let dms = "someRandomValue"; + + // Act + let decimal = utils::to_decimal(dms); + + // Assert + assert_eq!(0.0, decimal); +} + + +#[test] +fn add_google_map_existing() { + // Given + let mut map = HashMap::new(); + + let mut sub_map = BTreeMap::new(); + sub_map.insert("GPSLatitude".to_string(), "48 deg 51' 52.9776\"".to_string()); + sub_map.insert("GPSLongitude".to_string(), "2 deg 20' 56.4504\"".to_string()); + map.insert("GPSInfo".to_string(), sub_map); + + + // Act + let result_map = utils::add_google_map(map); + + // Assert + assert!(!result_map.is_empty()); + assert!(result_map.contains_key("GPSInfo")); + assert!(result_map.get("GPSInfo").unwrap().contains_key(utils::GOOGLE_MAP)); + + // Assert + let re = Regex::new(r"^https://www\.google\.com/maps/search/\?api=1&query=(-?\d+\.\d+),(-?\d+\.\d+)$").unwrap(); + let google_map = result_map.get("GPSInfo").unwrap().get(utils::GOOGLE_MAP).unwrap(); + assert!(re.is_match(google_map)); +} + + +#[test] +fn add_google_map_nothing() { + // Given + let map: HashMap> = HashMap::new(); + + // Act + let result_map = utils::add_google_map(map); + + // Assert + assert!(result_map.is_empty()); +} + +#[test] +fn add_google_map_not_enough() { + // Given + let mut map = HashMap::new(); + map.insert("GPSInfo".to_string(), BTreeMap::new()); + + // Act + let result_map = utils::add_google_map(map); + + + // Assert + assert!(!result_map.is_empty()); + assert!(result_map.contains_key("GPSInfo")); + assert!(!result_map.get("GPSInfo").unwrap().contains_key(utils::GOOGLE_MAP)); +} + + +#[test] +fn create_json_file_test() -> Result<(), anyhow::Error> { + // Given + let tmp_dir = TempDir::new("create_json_file_test")?; + let tmp_file_path = tmp_dir.path().join("tmp_json_file"); + + // Act + let res = utils::create_json_file(&mut tmp_file_path.display().to_string()); + + // Assert + assert!(res.is_ok()); + tmp_dir.close()?; + Ok(()) +} + + +#[test] +fn write_json_to_file_test() -> Result<(), anyhow::Error> { + // Given + let tmp_dir = TempDir::new("write_json_to_file_test")?; + let tmp_file_path = tmp_dir.path().join("tmp_json_file"); + + // Act + let res = utils::write_json_to_file("{\"example\":\"test\"}".to_string(), &mut tmp_file_path.display().to_string()); + + // Assert + assert!(res.is_ok()); + Ok(()) +}