Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src-tauri/src/exif_processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,50 @@ pub fn extract_metadata(file_bytes: &[u8]) -> Option<HashMap<String, String>> {
Some(map)
}

/// Like `get_creation_date_from_path` but returns `None` when no real
/// EXIF/RAW metadata timestamp is found. Does NOT fall back to filesystem
/// dates or `Utc::now()`. Used for EXIF-based grouping verification.
pub fn try_get_exif_creation_date(path: &std::path::Path) -> Option<DateTime<Utc>> {
if let Some(map) = read_rrexif_sidecar(path)
&& let Some(dt_str) = map.get("DateTimeOriginal").or(map.get("CreateDate"))
&& let Some(dt) = parse_creation_datetime(dt_str)
{
return Some(DateTime::from_naive_utc_and_offset(dt, Utc));
}

if let Ok(file) = std::fs::File::open(path) {
let mut bufreader = BufReader::new(&file);
let exifreader = exif::Reader::new();

if let Ok(exif_obj) = exifreader.read_from_container(&mut bufreader) {
for tag in [exif::Tag::DateTimeOriginal, exif::Tag::DateTime] {
if let Some(field) = exif_obj.get_field(tag, exif::In::PRIMARY)
&& let Some(dt) = parse_creation_field(field)
{
return Some(dt);
}
}
}
}

if is_raw_file(path.to_string_lossy().as_ref()) {
let loader = rawler::RawLoader::new();
if let Ok(raw_source) = rawler::rawsource::RawSource::new(path)
&& let Ok(decoder) = loader.get_decoder(&raw_source)
&& let Ok(metadata) = decoder.raw_metadata(&raw_source, &Default::default())
{
if let Some(dt) = parse_raw_creation_date(metadata.exif.date_time_original.as_deref()) {
return Some(dt);
}
if let Some(dt) = parse_raw_creation_date(metadata.exif.create_date.as_deref()) {
return Some(dt);
}
}
}

None
}

pub fn get_creation_date_from_path(path: &std::path::Path) -> DateTime<Utc> {
if let Some(map) = read_rrexif_sidecar(path)
&& let Some(dt_str) = map.get("DateTimeOriginal").or(map.get("CreateDate"))
Expand Down
135 changes: 124 additions & 11 deletions src-tauri/src/file_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,12 @@ pub struct AppSettings {
pub active_waveform_channel: Option<String>,
#[serde(default)]
pub use_wgpu_renderer: Option<bool>,
#[serde(default)]
pub group_preferred_type: Option<String>,
#[serde(default)]
pub group_edited_files: Option<bool>,
#[serde(default)]
pub require_matching_exif: Option<bool>,
}

fn default_adjustment_visibility() -> HashMap<String, bool> {
Expand Down Expand Up @@ -525,6 +531,9 @@ impl Default for AppSettings {
use_wgpu_renderer: Some(false),
#[cfg(not(any(target_os = "linux", target_os = "android")))]
use_wgpu_renderer: Some(true),
group_preferred_type: Some("raw".to_string()),
group_edited_files: Some(true),
require_matching_exif: Some(false),
}
}
}
Expand All @@ -538,6 +547,76 @@ pub struct ImageFile {
tags: Option<Vec<String>>,
exif: Option<HashMap<String, String>>,
is_virtual_copy: bool,
is_raw: bool,
group_id: Option<String>,
}

/// Grouping key from a source image path: the full path with the
/// extension stripped. Files sharing this key are variants of the
/// same shot. Case-sensitive.
fn make_group_key(source_path: &Path) -> String {
source_path.with_extension("").to_string_lossy().into_owned()
}

/// Tag files that share a stem with `group_id`. Virtual copies are
/// excluded from counting (one file + its virtual copy don't form a
/// group) but still get assigned the group_id of their source.
/// When `group_edited_files` is false, files that have been edited in
/// RapidRAW (have non-rating adjustments in their sidecar) are also
/// excluded from grouping.
/// When `require_matching_exif` is true, files sharing a stem are only
/// grouped if their EXIF creation timestamps match exactly.
fn assign_group_ids(
files: &mut Vec<ImageFile>,
group_edited_files: bool,
require_matching_exif: bool,
) {
let mut stem_sources: HashMap<String, HashSet<PathBuf>> = HashMap::new();

for file in files.iter() {
if file.is_virtual_copy {
continue;
}
if !group_edited_files && file.is_edited {
continue;
}
let (source_path, _) = parse_virtual_path(&file.path);
let key = make_group_key(&source_path);
stem_sources.entry(key).or_default().insert(source_path);
}

// When EXIF verification is enabled, remove stems where files have
// different creation timestamps.
// Files without EXIF metadata are excluded from grouping entirely.
if require_matching_exif {
stem_sources.retain(|_key, paths| {
if paths.len() < 2 {
return true; // nothing to verify
}
let timestamps: Vec<Option<DateTime<Utc>>> = paths
.iter()
.map(|p| exif_processing::try_get_exif_creation_date(p))
.collect();
// All files must have EXIF data to be grouped
if !timestamps.iter().all(|t| t.is_some()) {
return false;
}
// All timestamps must match exactly
let first = timestamps[0].unwrap();
timestamps.iter().all(|t| t.unwrap() == first)
});
}

for file in files.iter_mut() {
if !group_edited_files && file.is_edited {
continue;
}
let (source_path, _) = parse_virtual_path(&file.path);
let key = make_group_key(&source_path);
if stem_sources.get(&key).map_or(false, |s| s.len() >= 2) {
file.group_id = Some(key);
}
}
}

#[derive(Serialize, Deserialize, Debug, Clone)]
Expand Down Expand Up @@ -912,7 +991,7 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result<Vec<Ima
})
.collect();

let result_list: Vec<ImageFile> = tasks
let mut result_list: Vec<ImageFile> = tasks
.into_par_iter()
.flat_map(|(path_str, file_name, path_buf, sidecars)| {
let modified = fs::metadata(&path_buf)
Expand Down Expand Up @@ -967,6 +1046,8 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result<Vec<Ima
tags,
exif: None,
is_virtual_copy,
is_raw: is_raw_file(&path_str),
group_id: None,
rating,
});
}
Expand All @@ -975,6 +1056,9 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result<Vec<Ima
})
.collect();

let group_edited = settings.group_edited_files.unwrap_or(true);
let require_exif = settings.require_matching_exif.unwrap_or(false);
assign_group_ids(&mut result_list, group_edited, require_exif);
Ok(result_list)
}

Expand Down Expand Up @@ -1038,7 +1122,7 @@ pub fn list_images_recursive(
})
.collect();

let result_list: Vec<ImageFile> = tasks
let mut result_list: Vec<ImageFile> = tasks
.into_par_iter()
.flat_map(|(path_str, file_name, path_buf, sidecars)| {
let modified = fs::metadata(&path_buf)
Expand Down Expand Up @@ -1093,6 +1177,8 @@ pub fn list_images_recursive(
tags,
exif: None,
is_virtual_copy,
is_raw: is_raw_file(&path_str),
group_id: None,
rating,
});
}
Expand All @@ -1101,6 +1187,9 @@ pub fn list_images_recursive(
})
.collect();

let group_edited = settings.group_edited_files.unwrap_or(true);
let require_exif = settings.require_matching_exif.unwrap_or(false);
assign_group_ids(&mut result_list, group_edited, require_exif);
Ok(result_list)
}

Expand Down Expand Up @@ -3312,9 +3401,7 @@ pub fn delete_files_with_associated(paths: Vec<String>) -> Result<(), String> {

for path_str in &paths {
let (source_path, _) = parse_virtual_path(path_str);
if let Some(file_name) = source_path.file_name().and_then(|s| s.to_str())
&& let Some(stem) = file_name.split('.').next()
{
if let Some(stem) = source_path.file_stem().and_then(|s| s.to_str()) {
stems_to_delete.insert(stem.to_string());
}
if let Some(parent) = source_path.parent() {
Expand All @@ -3339,13 +3426,39 @@ pub fn delete_files_with_associated(paths: Vec<String>) -> Result<(), String> {
let entry_filename = entry.file_name();
let entry_filename_str = entry_filename.to_string_lossy();

if let Some(base_stem) = entry_filename_str.split('.').next()
&& stems_to_delete.contains(base_stem)
&& (is_supported_image_file(entry_filename_str.as_ref())
|| entry_filename_str.ends_with(".rrdata")
|| entry_filename_str.ends_with(".rrexif"))
if entry_filename_str.ends_with(".rrdata") {
// Sidecars: {filename}.rrdata or {filename}.{vc_id}.rrdata
// VC ids are first 6 chars of a UUID v4, see create_virtual_copy()
let without_rrdata = entry_filename_str.trim_end_matches(".rrdata");
let image_filename = if let Some(dot_pos) = without_rrdata.rfind('.') {
let suffix = &without_rrdata[dot_pos + 1..];
if suffix.len() == 6 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
&without_rrdata[..dot_pos]
} else {
without_rrdata
}
} else {
without_rrdata
};
let sidecar_stem = Path::new(image_filename)
.file_stem()
.and_then(|s| s.to_str());
if let Some(stem) = sidecar_stem {
if stems_to_delete.contains(stem) {
files_to_trash.insert(entry_path);
}
}
} else if is_supported_image_file(entry_filename_str.as_ref())
|| entry_filename_str.ends_with(".rrexif")
{
files_to_trash.insert(entry_path);
let entry_stem = Path::new(entry_filename_str.as_ref())
.file_stem()
.and_then(|s| s.to_str());
if let Some(stem) = entry_stem {
if stems_to_delete.contains(stem) {
files_to_trash.insert(entry_path);
}
}
}
}
}
Expand Down
Loading
Loading