#![doc = include_str!("../README.md")]
#![allow(clippy::unnecessary_debug_formatting)]
use crate::analysis::exif2date::ExifDateType;
use crate::analysis::name_formatters::{
BracketInfo, BracketingFormattingPriority, FileType, NameFormatterInvocationInfo,
};
use action::ActionMode;
use anyhow::{anyhow, Result};
use chrono::NaiveDateTime;
use log::{debug, error, info, trace, warn};
use regex::Regex;
use std::cmp::Ordering;
use std::ffi::OsStr;
use std::fs;
use std::fs::{DirEntry, File};
use std::io::{Read, Seek};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::LazyLock;
pub mod action;
pub mod analysis;
pub mod name;
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum AnalysisType {
OnlyEmbedded,
OnlyName,
EmbeddedThenName,
NameThenEmbedded,
}
impl FromStr for AnalysisType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"only_exif" | "exif" | "embedded" | "only_embedded" | "metadata" | "e" | "m" => {
Ok(AnalysisType::OnlyEmbedded)
}
"only_name" | "name" | "n" => Ok(AnalysisType::OnlyName),
"exif_then_name" | "exif_name" | "embedded_then_name" | "metadata_then_name" | "mn"
| "en" => Ok(AnalysisType::EmbeddedThenName),
"name_then_exif" | "name_exif" | "name_then_embedded" | "name_then_metadata" | "nm"
| "ne" => Ok(AnalysisType::NameThenEmbedded),
_ => Err(anyhow::anyhow!("Invalid analysis type")),
}
}
}
#[derive(Debug, Clone)]
pub struct AnalyzerSettings {
pub analysis_type: AnalysisType,
pub exif_date_type: ExifDateType,
pub source_dirs: Vec<PathBuf>,
pub target_dir: PathBuf,
pub recursive_source: bool,
pub file_format: String,
pub nodate_file_format: String,
pub unknown_file_format: Option<String>,
pub bracketed_file_format: Option<String>,
pub date_format: String,
pub bracketing_formatting: BracketingFormattingPriority,
pub extensions: Vec<String>,
#[cfg(feature = "video")]
pub video_extensions: Vec<String>,
pub action_type: ActionMode,
pub mkdir: bool,
pub excluded_files: Vec<Regex>,
pub included_files: Vec<Regex>,
}
static RE_DETECT_NAME_FORMAT_COMMAND: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r"\{([^}]*)}", )
.expect("Failed to compile regex")
});
static RE_COMMAND_SPLIT: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r"^(([^:]*):)?(.*)$", )
.expect("Failed to compile regex")
});
static LOCK_MOVE_OPERATION: LazyLock<std::sync::Mutex<()>> =
LazyLock::new(|| std::sync::Mutex::new(()));
pub struct Analyzer {
name_transformers:
Vec<Box<dyn analysis::filename2date::FileNameToDateTransformer + Send + Sync>>,
name_formatters: Vec<Box<dyn analysis::name_formatters::NameFormatter + Send + Sync>>,
pub settings: AnalyzerSettings,
}
impl Analyzer {
pub fn new(settings: AnalyzerSettings) -> Result<Analyzer> {
let analyzer = Analyzer {
name_transformers: Vec::default(),
name_formatters: Vec::default(),
settings,
};
if !analyzer.settings.target_dir.exists() {
return Err(anyhow!("Target directory does not exist"));
}
for source in &analyzer.settings.source_dirs {
if !source.exists() {
return Err(anyhow!("Source directory {source:?} does not exist"));
}
}
Ok(analyzer)
}
pub fn add_transformer<
T: 'static + analysis::filename2date::FileNameToDateTransformer + Send + Sync,
>(
&mut self,
transformer: T,
) {
self.name_transformers.push(Box::new(transformer));
}
pub fn add_formatter<T: 'static + analysis::name_formatters::NameFormatter + Send + Sync>(
&mut self,
formatter: T,
) {
self.name_formatters.push(Box::new(formatter));
}
fn analyze_name(&self, name: &str) -> Result<(Option<NaiveDateTime>, String)> {
let result = analysis::get_name_time(name, &self.name_transformers)?;
match result {
Some((time, name)) => Ok((Some(time), name)),
None => Ok((None, name.to_string())),
}
}
fn analyze_photo_exif<S: Read + Seek>(
file: S,
date_type: ExifDateType,
) -> Result<Option<NaiveDateTime>> {
let exif_time = analysis::exif2date::get_exif_time(file, date_type)?;
Ok(exif_time)
}
#[cfg(feature = "video")]
fn analyze_video_metadata<P: AsRef<Path>>(path: P) -> Result<Option<NaiveDateTime>> {
let video_time = analysis::video2date::get_video_time(path)?;
Ok(video_time)
}
fn analyze_embedded_metadata<A: AsRef<Path>>(&self, path: A) -> Result<Option<NaiveDateTime>> {
let path = path.as_ref();
#[cfg(feature = "video")]
let video = self.is_valid_video_extension(path.extension())?;
let photo = self.is_valid_photo_extension(path.extension())?;
#[cfg(feature = "video")]
{
if video && photo {
return Err(anyhow::anyhow!("File has both photo and video extensions. Do not include the same extension in both settings"));
}
}
if photo {
let file = File::open(path)?;
return Analyzer::analyze_photo_exif(&file, self.settings.exif_date_type);
}
#[cfg(feature = "video")]
if video {
return Analyzer::analyze_video_metadata(path);
}
Err(anyhow::anyhow!("File extension is not valid"))
}
pub fn analyze<A: AsRef<Path>>(&self, path: A) -> Result<(Option<NaiveDateTime>, String)> {
let path = path.as_ref();
let name = path
.file_name()
.ok_or(anyhow::anyhow!("No file name"))?
.to_str()
.ok_or(anyhow::anyhow!("Invalid file name"))?;
let valid_extension = self
.is_valid_extension(path.extension())
.unwrap_or_else(|err| {
warn!("Error checking file extension: {err}");
false
});
if !valid_extension {
warn!("Skipping file with invalid extension: {}", path.display());
return Err(anyhow::anyhow!("Invalid file extension"));
}
Ok(match self.settings.analysis_type {
AnalysisType::OnlyEmbedded => {
let exif_result = self
.analyze_embedded_metadata(path)
.map_err(|e| anyhow!("Error analyzing embedded data: {e}"))?;
let name_result = self.analyze_name(name);
match name_result {
Ok((_, name)) => (exif_result, name),
Err(_err) => (exif_result, name.to_string()),
}
}
AnalysisType::OnlyName => self.analyze_name(name)?,
AnalysisType::EmbeddedThenName => {
let metadata_result = self.analyze_embedded_metadata(path);
let exif_result = match metadata_result {
Err(e) => {
warn!(
"Error analyzing embedded data: {} for {}",
e,
path.display()
);
info!("Falling back to name analysis");
None
}
Ok(date) => date,
};
let name_result = self.analyze_name(name);
match exif_result {
Some(date) => match name_result {
Ok((_, name)) => (Some(date), name),
Err(_err) => (Some(date), name.to_string()),
},
None => name_result?,
}
}
AnalysisType::NameThenEmbedded => {
let name_result = self.analyze_name(name)?;
if name_result.0.is_none() {
(self.analyze_embedded_metadata(path)?, name_result.1)
} else {
name_result
}
}
})
}
fn replace_filepath_parts<'a, 'b>(
&self,
format_string: &'b str,
info: &'a NameFormatterInvocationInfo,
) -> Result<String> {
#[derive(Debug)]
enum FormatString<'a> {
Literal(String),
Command(&'a str, String),
}
impl FormatString<'_> {
fn formatted_string(self) -> String {
match self {
FormatString::Literal(str) | FormatString::Command(_, str) => str,
}
}
}
let detect_commands = RE_DETECT_NAME_FORMAT_COMMAND.captures_iter(format_string);
let mut final_string: Vec<FormatString<'b>> = Vec::new();
let mut current_string_index = 0;
for capture in detect_commands {
let match_all = capture.get(0).expect("Capture group 0 should always exist");
let start = match_all.start();
let end = match_all.end();
if start > current_string_index {
final_string.push(FormatString::Literal(
format_string[current_string_index..start].to_string(),
));
}
let inner_command_string = capture
.get(1)
.expect("Capture group 2 should always exist")
.as_str();
let inner_command_capture = RE_COMMAND_SPLIT
.captures(inner_command_string)
.expect("Should always match");
let command_modifier = inner_command_capture.get(2).map_or("", |x| x.as_str());
let actual_command = inner_command_capture.get(3).map_or("", |x| x.as_str());
let mut found_command = false;
for formatter in &self.name_formatters {
if let Some(matched) = formatter.argument_template().captures(actual_command) {
let mut command_substitution = match formatter.replacement_text(matched, info) {
Ok(replaced_text) => replaced_text,
Err(err) => {
return Err(anyhow!("Failed to format the file name with the given format string: {actual_command:?}. Got error: {{{err}}}"));
}
};
if !command_substitution.is_empty() && !command_modifier.is_empty() {
command_substitution = format!("{command_modifier}{command_substitution}");
}
found_command = true;
final_string.push(FormatString::Command(
inner_command_string,
command_substitution,
));
break;
}
}
if !found_command {
return Err(anyhow!("Failed to format file name with the given format string. There exists no formatter for the format command: {{{actual_command}}}"));
}
current_string_index = end;
}
if format_string.len() > current_string_index {
final_string.push(FormatString::Literal(
format_string[current_string_index..].to_string(),
));
}
trace!("Parsed format string {format_string:?} to");
for part in &final_string {
match part {
FormatString::Literal(str) => trace!(" - Literal: {str:?}"),
FormatString::Command(cmd, str) => trace!(" - Command: {cmd:?}\t{str:?}"),
}
}
Ok(final_string
.into_iter()
.map(FormatString::formatted_string)
.collect::<String>())
}
#[allow(clippy::too_many_lines)]
pub fn run_file<P: AsRef<Path>>(
&self,
path: P,
bracket_info: &Option<BracketInfo>,
) -> Result<()> {
let path = path.as_ref();
let mut data = match self.analyze_context(path) {
Ok(None) => return Ok(()), Ok(Some(mut result)) => {
result.bracket_info = bracket_info.as_ref();
result
}
Err(err) => {
error!("Error analyzing file {}: {err}", path.display());
return Err(anyhow!("Error analyzing file: {err}"));
}
};
let new_file_path = |file_name_info: &NameFormatterInvocationInfo| -> Result<PathBuf> {
let format_string = if data.file_type == FileType::None {
self.settings
.unknown_file_format
.as_ref()
.ok_or(anyhow!("No unknown format string specified"))?
.as_str()
} else if let (Some(bracket_info), Some(_)) =
(&self.settings.bracketed_file_format, &bracket_info)
{
bracket_info.as_str()
} else if data.date.is_some() {
self.settings.file_format.as_str()
} else {
self.settings.nodate_file_format.as_str()
};
let path_split: Vec<_> = format_string.split('/').collect();
let len = path_split.len();
let path_split: Vec<_> = path_split
.into_iter()
.enumerate()
.map(|(index, component)| {
let is_leaf = index == len - 1;
let bracketing_info = file_name_info.bracket_info;
if let Some(bracketing_info) = bracketing_info {
if is_leaf {
self.replace_filepath_parts(component, file_name_info)
} else {
match self.settings.bracketing_formatting {
BracketingFormattingPriority::First => {
if let Some(bracketed_format) = &bracketing_info.analysis_first
{
let mut bracketed_format =
bracketed_format.as_ref().clone();
bracketed_format.bracket_info = Some(bracketing_info);
self.replace_filepath_parts(component, &bracketed_format)
} else {
self.replace_filepath_parts(component, file_name_info)
}
}
BracketingFormattingPriority::Last => {
if let Some(bracketed_format) = &bracketing_info.analysis_last {
let mut bracketed_format =
bracketed_format.as_ref().clone();
bracketed_format.bracket_info = Some(bracketing_info);
self.replace_filepath_parts(component, &bracketed_format)
} else {
self.replace_filepath_parts(component, file_name_info)
}
}
BracketingFormattingPriority::Current => {
self.replace_filepath_parts(component, file_name_info)
}
}
}
} else {
self.replace_filepath_parts(component, file_name_info)
}
})
.collect();
for entry in &path_split {
if let Err(err) = entry {
return Err(anyhow!("Failed to format filename: {err}"));
}
}
let path_split = path_split.into_iter().map(Result::unwrap);
let mut target_path = self.settings.target_dir.clone();
for path_component in path_split {
let component = path_component.replace(['/', '\\'], "");
if component != ".." {
target_path.push(component);
}
}
Ok(target_path)
};
let mut new_path = new_file_path(&data)?;
let mut dup_counter = 0;
let lock_rename = match LOCK_MOVE_OPERATION.lock() {
Ok(guard) => Some(guard),
Err(err) => {
warn!("Failed to acquire lock for move operation: {err}");
None
}
};
while new_path.exists() {
debug!("Target file already exists: {}", new_path.display());
dup_counter += 1;
data.duplicate_counter = Some(dup_counter);
new_path = new_file_path(&data)?;
}
if dup_counter > 0 {
info!("De-duplicated target file: {}", new_path.display());
}
action::file_action(
path,
&new_path,
&self.settings.action_type,
self.settings.mkdir,
lock_rename,
)?;
Ok(())
}
pub fn analyze_context<P: AsRef<Path>>(
&self,
path: P,
) -> Result<Option<NameFormatterInvocationInfo<'static>>> {
let path = path.as_ref();
let valid_ext = self.is_valid_extension(path.extension());
let is_unknown_file = match valid_ext {
Ok(false) => {
if self.settings.unknown_file_format.is_none() {
info!(
"Skipping file because extension is not in the list and no unknown format specified: {}",
path.display()
);
return Ok(None);
}
debug!("Processing unknown file: {}", path.display());
true
}
Ok(true) => {
debug!("Processing file: {}", path.display());
false
}
Err(err) => {
warn!("Error checking file extension: {err}");
return Ok(None);
}
};
let (date, cleaned_name) = if is_unknown_file {
(
None,
path.with_extension("")
.file_name()
.ok_or(anyhow::anyhow!("No file name"))?
.to_str()
.ok_or(anyhow::anyhow!("Invalid file name"))?
.to_string(),
)
} else {
let (date, cleaned_name) = self.analyze(path).map_err(|err| {
error!("Error extracting date: {err}");
err
})?;
let cleaned_name = name::clean_image_name(cleaned_name.as_str());
debug!("Analysis results: Date: {date:?}, Cleaned name: {cleaned_name:?}",);
if date.is_none() {
warn!("No date was derived for file {}.", path.display());
}
(date, cleaned_name)
};
let date_string = match date {
None => "NODATE".to_string(),
Some(date) => date.format(&self.settings.date_format).to_string(),
};
let mut ftype = FileType::None;
if self.is_valid_photo_extension(path.extension())? {
ftype = FileType::Image;
}
#[cfg(feature = "video")]
if self.is_valid_video_extension(path.extension())? {
ftype = FileType::Video;
}
let file_name_info = NameFormatterInvocationInfo {
date,
date_string,
date_default_format: self.settings.date_format.clone(),
bracketing_formatting: self.settings.bracketing_formatting,
file_type: ftype,
cleaned_name,
duplicate_counter: None,
extension: path
.extension()
.map_or(String::new(), |ext| ext.to_string_lossy().to_string()),
bracket_info: None,
original_name: path
.with_extension("")
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
original_filename: path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
};
Ok(Some(file_name_info))
}
pub fn is_valid_photo_extension(&self, ext: Option<&OsStr>) -> Result<bool> {
match ext {
None => Ok(false),
Some(ext) => {
let ext = ext
.to_str()
.ok_or(anyhow::anyhow!("Invalid file extension"))?
.to_lowercase();
Ok(self
.settings
.extensions
.iter()
.any(|valid_ext| ext == valid_ext.as_str()))
}
}
}
#[cfg(feature = "video")]
pub fn is_valid_video_extension(&self, ext: Option<&OsStr>) -> Result<bool> {
match ext {
None => Ok(false),
Some(ext) => {
let ext = ext
.to_str()
.ok_or(anyhow::anyhow!("Invalid file extension"))?
.to_lowercase();
Ok(self
.settings
.video_extensions
.iter()
.any(|valid_ext| ext == valid_ext.as_str()))
}
}
}
pub fn is_valid_extension(&self, ext: Option<&OsStr>) -> Result<bool> {
let valid_photo = self.is_valid_photo_extension(ext)?;
#[cfg(feature = "video")]
let valid_video = self.is_valid_video_extension(ext)?;
#[cfg(not(feature = "video"))]
let valid_video = false;
Ok(valid_photo || valid_video)
}
pub fn find_files_in_source<P: AsRef<Path>>(
&self,
directory: P,
recursive: bool,
result: &mut Vec<PathBuf>,
) -> Result<()> {
let mut entries =
fs::read_dir(directory.as_ref())?.collect::<Vec<std::io::Result<DirEntry>>>();
entries.sort_by(|a, b| match (a, b) {
(Ok(a), Ok(b)) => a.path().cmp(&b.path()),
(Err(_), Ok(_)) => Ordering::Less,
(Ok(_), Err(_)) => Ordering::Greater,
(Err(_), Err(_)) => Ordering::Equal,
});
let directory = directory.as_ref().canonicalize()?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if recursive {
debug!("Processing subfolder: {}", path.display());
self.find_files_in_source(path, recursive, result)?;
}
} else {
let path = path.canonicalize()?;
let path_no_prefix = format!(
"{}{}",
std::path::MAIN_SEPARATOR_STR,
path.strip_prefix(&directory).unwrap_or(&path).display()
);
trace!("Found file: {path_no_prefix}");
let mut include_it = self.settings.included_files.is_empty();
for include_pattern in &self.settings.included_files {
let matching = include_pattern.is_match(path_no_prefix.as_str());
trace!(
" - Include pattern: {} {}",
include_pattern.as_str(),
if matching { "[INCLUDE]" } else { "" }
);
include_it = include_it || matching;
}
let mut exclude_it = false;
for exclude_pattern in &self.settings.excluded_files {
let matching = exclude_pattern.is_match(path_no_prefix.as_str());
trace!(
" - Exclude pattern: {} {}",
exclude_pattern.as_str(),
if matching { "[EXCLUDE]" } else { "" }
);
exclude_it = exclude_it || matching;
}
if include_it && !exclude_it {
trace!(" -> Included file: {path:?}");
result.push(path);
}
}
}
Ok(())
}
}
mod exifutils;
pub struct BracketEXIFInformation {
pub index: u32,
}