From 46100f72c6fe4ae325f33d89a077ee6b1214cf7f Mon Sep 17 00:00:00 2001 From: Dimitry Fayerman Date: Thu, 6 Aug 2026 09:23:07 -0400 Subject: [PATCH 1/2] feat: add capture date editing --- src-tauri/src/exif_processing.rs | 2 +- src-tauri/src/file_management.rs | 780 ++++++++++++++++++++- src-tauri/src/image_processing.rs | 10 + src-tauri/src/lib.rs | 2 + src/components/modals/AppModals.tsx | 35 + src/components/modals/CaptureDateModal.tsx | 367 ++++++++++ src/components/ui/AppProperties.tsx | 2 + src/components/ui/GlobalTooltip.tsx | 8 +- src/hooks/useAppContextMenus.ts | 11 + src/hooks/useAppInitialization.ts | 2 + src/hooks/useLibraryActions.ts | 90 +++ src/i18n/locales/ca.json | 49 ++ src/i18n/locales/de.json | 42 ++ src/i18n/locales/en.json | 42 ++ src/i18n/locales/es.json | 49 ++ src/i18n/locales/fr.json | 49 ++ src/i18n/locales/it.json | 49 ++ src/i18n/locales/ja.json | 42 ++ src/i18n/locales/ko.json | 42 ++ src/i18n/locales/pl.json | 56 ++ src/i18n/locales/pt.json | 49 ++ src/i18n/locales/ru.json | 56 ++ src/i18n/locales/zh-CN.json | 42 ++ src/i18n/locales/zh-TW.json | 42 ++ src/store/useUIStore.ts | 4 + 25 files changed, 1914 insertions(+), 8 deletions(-) create mode 100644 src/components/modals/CaptureDateModal.tsx diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 327ecb4a6d..9f2fdbbd4d 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -100,7 +100,7 @@ fn normalize_creation_datetime(s: &str) -> Option { Some(format!("{} {}", date.replace(':', "-"), time)) } -fn parse_creation_datetime(s: &str) -> Option { +pub(crate) fn parse_creation_datetime(s: &str) -> Option { let clean = clean_creation_datetime_str(s); if clean.is_empty() { return None; diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 8b57182d35..cc3cb82fc7 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs; use std::hash::{Hash, Hasher}; -use std::io::Cursor; +use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; @@ -13,15 +13,19 @@ use std::sync::atomic::Ordering; use std::thread; use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, NaiveDateTime, Utc}; use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, GenericImageView, ImageBuffer, Luma}; +use little_exif::exif_tag::ExifTag; +use little_exif::filetype::FileExtension; +use little_exif::metadata::Metadata; use rayon::prelude::*; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; use sysinfo::Disks; use tauri::{AppHandle, Emitter, Manager}; +use tempfile::NamedTempFile; use uuid::Uuid; use walkdir::WalkDir; @@ -36,9 +40,9 @@ use crate::gpu_processing; use crate::image_loader; use crate::image_processing::GpuContext; use crate::image_processing::{ - Crop, ImageMetadata, apply_coarse_rotation, apply_cpu_default_raw_processing, apply_crop, - apply_flip, apply_geometry_warp, apply_rotation, auto_results_to_json, - get_all_adjustments_from_json, perform_auto_analysis, + CaptureDateBackup, Crop, ImageMetadata, apply_coarse_rotation, + apply_cpu_default_raw_processing, apply_crop, apply_flip, apply_geometry_warp, apply_rotation, + auto_results_to_json, get_all_adjustments_from_json, perform_auto_analysis, }; use crate::mask_generation::MaskDefinition; use crate::preset_converter; @@ -509,6 +513,772 @@ pub async fn update_exif_fields( .map_err(|e| format!("Task failed: {}", e))? } +#[derive(Deserialize)] +#[serde( + tag = "mode", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum CaptureDateOperation { + Adjust { + reference_path: String, + new_date: String, + }, + Shift { + seconds: i64, + }, + Revert, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureDateUpdate { + path: String, + new_date: Option, + wrote_original: bool, + source_error: Option, +} + +#[derive(Serialize)] +pub struct CaptureDateFailure { + path: String, + error: String, +} + +#[derive(Serialize)] +pub struct CaptureDateBatchResult { + updates: Vec, + failures: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureDateRevertAvailability { + can_revert: bool, + eligible_count: usize, + total_count: usize, +} + +enum ResolvedCaptureDateOperation { + Add(Duration), + Set(NaiveDateTime), + Revert, +} + +fn read_source_exif(path: &Path) -> HashMap { + let path_str = path.to_string_lossy(); + if let Ok(mmap) = read_file_mapped(path) { + exif_processing::read_exif_data_from_bytes(&path_str, &mmap) + } else if let Ok(bytes) = fs::read(path) { + exif_processing::read_exif_data_from_bytes(&path_str, &bytes) + } else { + HashMap::new() + } +} + +fn current_exif_for_path(path: &Path, metadata: &ImageMetadata) -> HashMap { + metadata + .exif + .clone() + .or_else(|| exif_processing::read_rrexif_sidecar(path)) + .unwrap_or_else(|| read_source_exif(path)) +} + +fn normalized_capture_date(value: &str) -> Result { + exif_processing::parse_creation_datetime(value) + .ok_or_else(|| format!("Invalid capture date: {value}")) +} + +fn capture_date_physical_paths(paths: &[String]) -> Vec { + let mut seen = HashSet::new(); + paths + .iter() + .map(|path| parse_virtual_path(path).0) + .filter(|path| seen.insert(path.clone())) + .collect() +} + +fn capture_date_revert_availability_for_paths( + physical_paths: &[PathBuf], +) -> CaptureDateRevertAvailability { + let total_count = physical_paths.len(); + let eligible_count = physical_paths + .iter() + .filter(|path| { + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(path)) + .capture_date_backup + .is_some() + }) + .count(); + CaptureDateRevertAvailability { + can_revert: total_count > 0 && eligible_count == total_count, + eligible_count, + total_count, + } +} + +fn save_capture_date_metadata(path: &Path, metadata: &ImageMetadata) -> Result<(), String> { + let sidecar_path = exif_processing::get_primary_sidecar_path(path); + let json = serde_json::to_string_pretty(metadata).map_err(|e| e.to_string())?; + fs::write(&sidecar_path, json) + .map_err(|e| format!("Failed to write {}: {e}", sidecar_path.display())) +} + +fn is_jpeg(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("jpg" | "jpeg") + ) +} + +fn write_jpeg_capture_date(path: &Path, capture_date: Option<&str>) -> Result<(), String> { + if !is_jpeg(path) { + return Err( + "Writing Capture Date to the original is currently supported only for JPEG files" + .to_string(), + ); + } + + let resolved_path = path + .canonicalize() + .map_err(|e| format!("Failed to resolve {}: {e}", path.display()))?; + let path = resolved_path.as_path(); + + let file_metadata = fs::metadata(path) + .map_err(|e| format!("Failed to read file attributes for {}: {e}", path.display()))?; + let accessed = filetime::FileTime::from_last_access_time(&file_metadata); + let modified = filetime::FileTime::from_last_modification_time(&file_metadata); + let permissions = file_metadata.permissions(); + + let mut image_bytes = + fs::read(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + let mut metadata = match Metadata::new_from_vec(&image_bytes, FileExtension::JPEG) { + Ok(metadata) => metadata, + Err(little_exif_error) => { + let mut cursor = Cursor::new(&image_bytes); + match exif::Reader::new().read_from_container(&mut cursor) { + Err(exif::Error::NotFound(_)) => Metadata::new(), + _ => { + return Err(format!( + "Existing EXIF metadata in {} could not be preserved: {little_exif_error}", + path.display() + )); + } + } + } + }; + + metadata.remove_tag(ExifTag::DateTimeOriginal(String::new())); + if let Some(value) = capture_date { + let parsed = normalized_capture_date(value)?; + metadata.set_tag(ExifTag::DateTimeOriginal( + parsed.format("%Y:%m:%d %H:%M:%S").to_string(), + )); + } + metadata + .write_to_vec(&mut image_bytes, FileExtension::JPEG) + .map_err(|e| format!("Failed to update JPEG metadata for {}: {e}", path.display()))?; + + let parent = path + .parent() + .ok_or_else(|| format!("File has no parent directory: {}", path.display()))?; + let mut temp_file = NamedTempFile::new_in(parent).map_err(|e| { + format!( + "Failed to create a temporary file beside {}: {e}", + path.display() + ) + })?; + temp_file + .as_file() + .set_permissions(permissions) + .map_err(|e| format!("Failed to preserve permissions for {}: {e}", path.display()))?; + temp_file + .write_all(&image_bytes) + .and_then(|_| temp_file.flush()) + .and_then(|_| temp_file.as_file().sync_all()) + .map_err(|e| format!("Failed to stage updated JPEG {}: {e}", path.display()))?; + temp_file + .persist(path) + .map_err(|e| format!("Failed to replace {}: {}", path.display(), e.error))?; + if let Err(error) = filetime::set_file_times(path, accessed, modified) { + log::warn!( + "Capture Date was written to {}, but file times could not be restored: {}", + path.display(), + error + ); + } + + Ok(()) +} + +fn resolve_capture_date_operation( + paths: &[PathBuf], + operation: &CaptureDateOperation, +) -> Result { + match operation { + CaptureDateOperation::Shift { seconds } => { + if *seconds == 0 { + return Err("Capture Date is unchanged".to_string()); + } + Duration::try_seconds(*seconds) + .map(ResolvedCaptureDateOperation::Add) + .ok_or_else(|| "The requested Capture Date shift is too large".to_string()) + } + CaptureDateOperation::Revert => Ok(ResolvedCaptureDateOperation::Revert), + CaptureDateOperation::Adjust { + reference_path, + new_date, + } => { + let desired = normalized_capture_date(new_date)?; + let (reference_path, _) = parse_virtual_path(reference_path); + let reference_metadata = exif_processing::load_sidecar( + &exif_processing::get_primary_sidecar_path(&reference_path), + ); + let reference_exif = current_exif_for_path(&reference_path, &reference_metadata); + + if let Some(current) = reference_exif + .get("DateTimeOriginal") + .and_then(|value| exif_processing::parse_creation_datetime(value)) + { + let delta = desired - current; + if delta == Duration::zero() { + Err("Capture Date is unchanged".to_string()) + } else { + Ok(ResolvedCaptureDateOperation::Add(delta)) + } + } else if paths.len() == 1 { + Ok(ResolvedCaptureDateOperation::Set(desired)) + } else { + Err( + "The reference photo has no Capture Date. Set undated photos one at a time" + .to_string(), + ) + } + } + } +} + +#[tauri::command] +pub async fn get_capture_date_revert_availability( + paths: Vec, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let physical_paths = capture_date_physical_paths(&paths); + capture_date_revert_availability_for_paths(&physical_paths) + }) + .await + .map_err(|e| format!("Task failed: {e}")) +} + +#[tauri::command] +pub async fn update_capture_dates( + paths: Vec, + operation: CaptureDateOperation, + write_to_original: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let physical_paths = capture_date_physical_paths(&paths); + let resolved = resolve_capture_date_operation(&physical_paths, &operation)?; + if matches!(&resolved, ResolvedCaptureDateOperation::Revert) { + let availability = capture_date_revert_availability_for_paths(&physical_paths); + if !availability.can_revert { + return Err(format!( + "Revert requires a Capture Date backup for every selected photo ({}/{} available)", + availability.eligible_count, availability.total_count + )); + } + } + let mut updates = Vec::new(); + let mut failures = Vec::new(); + + for path in physical_paths { + let path_string = path.to_string_lossy().to_string(); + let mut metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&path)); + let mut exif = current_exif_for_path(&path, &metadata); + + let new_date = match &resolved { + ResolvedCaptureDateOperation::Revert => { + let Some(backup) = metadata.capture_date_backup.as_ref() else { + failures.push(CaptureDateFailure { + path: path_string, + error: "No original Capture Date is stored".to_string(), + }); + continue; + }; + backup.date_time_original.clone() + } + ResolvedCaptureDateOperation::Set(value) => { + Some(value.format("%Y-%m-%d %H:%M:%S").to_string()) + } + ResolvedCaptureDateOperation::Add(delta) => { + let Some(current) = exif + .get("DateTimeOriginal") + .and_then(|value| exif_processing::parse_creation_datetime(value)) + else { + failures.push(CaptureDateFailure { + path: path_string, + error: "This photo has no Capture Date to shift".to_string(), + }); + continue; + }; + let Some(updated) = current.checked_add_signed(*delta) else { + failures.push(CaptureDateFailure { + path: path_string, + error: "The adjusted Capture Date is outside the supported range" + .to_string(), + }); + continue; + }; + Some(updated.format("%Y-%m-%d %H:%M:%S").to_string()) + } + }; + + let is_revert = matches!(&resolved, ResolvedCaptureDateOperation::Revert); + if metadata.capture_date_backup.is_none() && !is_revert { + let source_exif = read_source_exif(&path); + metadata.capture_date_backup = Some(CaptureDateBackup { + date_time_original: source_exif.get("DateTimeOriginal").map(|value| { + exif_processing::parse_creation_datetime(value) + .map(|date| date.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| value.clone()) + }), + source_written: false, + }); + } + + if let Some(value) = &new_date { + exif.insert("DateTimeOriginal".to_string(), value.clone()); + } else { + exif.remove("DateTimeOriginal"); + } + + metadata.exif = Some(exif); + + if is_revert { + let should_write_source = metadata + .capture_date_backup + .as_ref() + .is_some_and(|backup| backup.source_written); + if should_write_source + && let Err(error) = write_jpeg_capture_date(&path, new_date.as_deref()) + { + failures.push(CaptureDateFailure { + path: path_string, + error, + }); + continue; + } + + metadata.capture_date_backup = None; + if let Err(error) = save_capture_date_metadata(&path, &metadata) { + failures.push(CaptureDateFailure { + path: path_string, + error, + }); + continue; + } + + updates.push(CaptureDateUpdate { + path: path_string, + new_date, + wrote_original: should_write_source, + source_error: None, + }); + continue; + } + + let should_write_source = write_to_original; + if should_write_source && let Some(backup) = metadata.capture_date_backup.as_mut() { + // Mark conservatively before touching the source so an interrupted or failed + // write can still be safely restored by Revert. + backup.source_written = true; + } + + if let Err(error) = save_capture_date_metadata(&path, &metadata) { + failures.push(CaptureDateFailure { + path: path_string, + error, + }); + continue; + } + + let source_result = if should_write_source { + Some(write_jpeg_capture_date(&path, new_date.as_deref())) + } else { + None + }; + let wrote_original = matches!(source_result, Some(Ok(()))); + let source_error = source_result.and_then(|result| result.err()); + + updates.push(CaptureDateUpdate { + path: path_string, + new_date, + wrote_original, + source_error, + }); + } + + Ok(CaptureDateBatchResult { updates, failures }) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + +#[cfg(test)] +mod capture_date_tests { + use super::*; + + fn create_test_jpeg(path: &Path, capture_date: &str) { + let mut bytes = Vec::new(); + DynamicImage::new_rgb8(2, 2) + .write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Jpeg) + .unwrap(); + let mut metadata = Metadata::new(); + metadata.set_tag(ExifTag::Make("RapidRAW test camera".to_string())); + metadata.set_tag(ExifTag::DateTimeOriginal(capture_date.to_string())); + metadata + .write_to_vec(&mut bytes, FileExtension::JPEG) + .unwrap(); + fs::write(path, bytes).unwrap(); + } + + #[test] + fn parses_and_shifts_capture_dates_as_wall_clock_time() { + let start = normalized_capture_date("2024:02:28 23:30:00").unwrap(); + let shifted = start.checked_add_signed(Duration::hours(25)).unwrap(); + assert_eq!( + shifted.format("%Y-%m-%d %H:%M:%S").to_string(), + "2024-03-01 00:30:00" + ); + } + + #[test] + fn capture_date_operations_deserialize_frontend_payloads() { + let adjust: CaptureDateOperation = serde_json::from_value(serde_json::json!({ + "mode": "adjust", + "referencePath": "/photos/reference.jpg", + "newDate": "2026-08-06 08:11:25" + })) + .unwrap(); + assert!(matches!( + adjust, + CaptureDateOperation::Adjust { + reference_path, + new_date + } if reference_path == "/photos/reference.jpg" && new_date == "2026-08-06 08:11:25" + )); + + let shift: CaptureDateOperation = serde_json::from_value(serde_json::json!({ + "mode": "shift", + "seconds": 3_600 + })) + .unwrap(); + assert!(matches!( + shift, + CaptureDateOperation::Shift { seconds: 3_600 } + )); + + let revert: CaptureDateOperation = + serde_json::from_value(serde_json::json!({ "mode": "revert" })).unwrap(); + assert!(matches!(revert, CaptureDateOperation::Revert)); + } + + #[test] + fn unchanged_capture_date_does_not_create_backup() { + let directory = tempfile::tempdir().unwrap(); + let image_path = directory.path().join("unchanged.jpg"); + create_test_jpeg(&image_path, "2020:01:02 03:04:05"); + let path = image_path.to_string_lossy().to_string(); + + let result = tauri::async_runtime::block_on(update_capture_dates( + vec![path.clone()], + CaptureDateOperation::Adjust { + reference_path: path, + new_date: "2020-01-02 03:04:05".to_string(), + }, + false, + )); + let error = match result { + Err(error) => error, + Ok(_) => panic!("unchanged operation unexpectedly succeeded"), + }; + + assert_eq!(error, "Capture Date is unchanged"); + let metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); + assert!(metadata.capture_date_backup.is_none()); + } + + #[test] + fn jpeg_source_write_preserves_other_exif_and_can_remove_capture_date() { + let directory = tempfile::tempdir().unwrap(); + let image_path = directory.path().join("capture-date.jpg"); + create_test_jpeg(&image_path, "2020:01:02 03:04:05"); + let original_modified = filetime::FileTime::from_unix_time(1_600_000_000, 0); + filetime::set_file_mtime(&image_path, original_modified).unwrap(); + + write_jpeg_capture_date(&image_path, Some("2023-04-05 06:07:08")).unwrap(); + let updated_bytes = fs::read(&image_path).unwrap(); + image::load_from_memory_with_format(&updated_bytes, image::ImageFormat::Jpeg).unwrap(); + let updated_exif = exif_processing::read_exif_data_from_bytes( + image_path.to_str().unwrap(), + &updated_bytes, + ); + assert_eq!( + updated_exif.get("DateTimeOriginal").map(String::as_str), + Some("2023:04:05 06:07:08") + ); + assert_eq!( + updated_exif.get("Make").map(String::as_str), + Some("RapidRAW test camera") + ); + assert_eq!( + filetime::FileTime::from_last_modification_time(&fs::metadata(&image_path).unwrap()), + original_modified + ); + + write_jpeg_capture_date(&image_path, None).unwrap(); + let reverted_bytes = fs::read(&image_path).unwrap(); + image::load_from_memory_with_format(&reverted_bytes, image::ImageFormat::Jpeg).unwrap(); + let reverted_exif = exif_processing::read_exif_data_from_bytes( + image_path.to_str().unwrap(), + &reverted_bytes, + ); + assert!(!reverted_exif.contains_key("DateTimeOriginal")); + assert_eq!( + reverted_exif.get("Make").map(String::as_str), + Some("RapidRAW test camera") + ); + } + + #[test] + fn jpeg_source_write_adds_capture_date_to_scan_without_exif() { + let directory = tempfile::tempdir().unwrap(); + let image_path = directory.path().join("scan.jpg"); + let mut bytes = Vec::new(); + DynamicImage::new_rgb8(2, 2) + .write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Jpeg) + .unwrap(); + fs::write(&image_path, bytes).unwrap(); + + write_jpeg_capture_date(&image_path, Some("1985-07-04 12:30:00")).unwrap(); + let updated_bytes = fs::read(&image_path).unwrap(); + image::load_from_memory_with_format(&updated_bytes, image::ImageFormat::Jpeg).unwrap(); + let updated_exif = exif_processing::read_exif_data_from_bytes( + image_path.to_str().unwrap(), + &updated_bytes, + ); + assert_eq!( + updated_exif.get("DateTimeOriginal").map(String::as_str), + Some("1985:07:04 12:30:00") + ); + } + + #[test] + fn revert_removes_added_source_date_and_consumes_empty_backup() { + let directory = tempfile::tempdir().unwrap(); + let image_path = directory.path().join("scan.jpg"); + let mut bytes = Vec::new(); + DynamicImage::new_rgb8(2, 2) + .write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Jpeg) + .unwrap(); + fs::write(&image_path, bytes).unwrap(); + let path = image_path.to_string_lossy().to_string(); + + let adjusted = tauri::async_runtime::block_on(update_capture_dates( + vec![path.clone()], + CaptureDateOperation::Adjust { + reference_path: path.clone(), + new_date: "1985-07-04 12:30:00".to_string(), + }, + true, + )) + .unwrap(); + assert_eq!(adjusted.updates.len(), 1); + assert!(adjusted.updates[0].source_error.is_none()); + + let adjusted_metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); + let backup = adjusted_metadata.capture_date_backup.as_ref().unwrap(); + assert!(backup.date_time_original.is_none()); + assert!(backup.source_written); + + let reverted = tauri::async_runtime::block_on(update_capture_dates( + vec![path], + CaptureDateOperation::Revert, + false, + )) + .unwrap(); + assert_eq!(reverted.updates.len(), 1); + assert!(reverted.failures.is_empty()); + + let reverted_metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); + assert!(reverted_metadata.capture_date_backup.is_none()); + assert!( + !read_source_exif(&image_path).contains_key("DateTimeOriginal"), + "Revert should remove a Capture Date that did not exist originally" + ); + } + + #[test] + fn batch_adjust_uses_reference_delta_and_revert_restores_each_original() { + let directory = tempfile::tempdir().unwrap(); + let reference_path = directory.path().join("reference.jpg"); + let second_path = directory.path().join("second.jpg"); + create_test_jpeg(&reference_path, "2020:01:02 03:04:05"); + create_test_jpeg(&second_path, "2020:01:02 05:04:05"); + let paths = vec![ + reference_path.to_string_lossy().to_string(), + second_path.to_string_lossy().to_string(), + ]; + + let result = tauri::async_runtime::block_on(update_capture_dates( + paths.clone(), + CaptureDateOperation::Adjust { + reference_path: paths[0].clone(), + new_date: "2020-01-02 04:04:05".to_string(), + }, + false, + )) + .unwrap(); + assert_eq!(result.updates.len(), 2); + assert!(result.failures.is_empty()); + + let availability = + tauri::async_runtime::block_on(get_capture_date_revert_availability(paths.clone())) + .unwrap(); + assert!(availability.can_revert); + assert_eq!(availability.eligible_count, 2); + assert_eq!(availability.total_count, 2); + + let unedited_path = directory.path().join("unedited.jpg"); + create_test_jpeg(&unedited_path, "2020:01:02 07:04:05"); + let mixed_paths = vec![ + paths[0].clone(), + unedited_path.to_string_lossy().to_string(), + ]; + let mixed_availability = tauri::async_runtime::block_on( + get_capture_date_revert_availability(mixed_paths.clone()), + ) + .unwrap(); + assert!(!mixed_availability.can_revert); + assert_eq!(mixed_availability.eligible_count, 1); + assert_eq!(mixed_availability.total_count, 2); + + let mixed_revert = tauri::async_runtime::block_on(update_capture_dates( + mixed_paths, + CaptureDateOperation::Revert, + false, + )); + assert!(mixed_revert.is_err()); + let still_adjusted = exif_processing::load_sidecar( + &exif_processing::get_primary_sidecar_path(&reference_path), + ); + assert!(still_adjusted.capture_date_backup.is_some()); + assert_eq!( + still_adjusted + .exif + .as_ref() + .and_then(|exif| exif.get("DateTimeOriginal")) + .map(String::as_str), + Some("2020-01-02 04:04:05") + ); + + let reference_metadata = exif_processing::load_sidecar( + &exif_processing::get_primary_sidecar_path(&reference_path), + ); + let second_metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&second_path)); + assert_eq!( + reference_metadata + .exif + .as_ref() + .and_then(|exif| exif.get("DateTimeOriginal")) + .map(String::as_str), + Some("2020-01-02 04:04:05") + ); + assert_eq!( + second_metadata + .exif + .as_ref() + .and_then(|exif| exif.get("DateTimeOriginal")) + .map(String::as_str), + Some("2020-01-02 06:04:05") + ); + + let reverted = tauri::async_runtime::block_on(update_capture_dates( + paths.clone(), + CaptureDateOperation::Revert, + false, + )) + .unwrap(); + assert_eq!(reverted.updates.len(), 2); + let reverted_second = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&second_path)); + assert_eq!( + reverted_second + .exif + .as_ref() + .and_then(|exif| exif.get("DateTimeOriginal")) + .map(String::as_str), + Some("2020-01-02 05:04:05") + ); + assert!(reverted_second.capture_date_backup.is_none()); + + let availability = + tauri::async_runtime::block_on(get_capture_date_revert_availability(paths)).unwrap(); + assert!(!availability.can_revert); + assert_eq!(availability.eligible_count, 0); + assert_eq!(availability.total_count, 2); + } + + #[test] + fn failed_source_revert_retains_backup_for_retry() { + let directory = tempfile::tempdir().unwrap(); + let image_path = directory.path().join("not-a-jpeg.png"); + create_test_jpeg(&image_path, "2020:01:02 03:04:05"); + let path = image_path.to_string_lossy().to_string(); + + let adjusted = tauri::async_runtime::block_on(update_capture_dates( + vec![path.clone()], + CaptureDateOperation::Adjust { + reference_path: path.clone(), + new_date: "2021-02-03 04:05:06".to_string(), + }, + true, + )) + .unwrap(); + assert_eq!(adjusted.updates.len(), 1); + assert!(adjusted.updates[0].source_error.is_some()); + + let reverted = tauri::async_runtime::block_on(update_capture_dates( + vec![path], + CaptureDateOperation::Revert, + false, + )) + .unwrap(); + assert!(reverted.updates.is_empty()); + assert_eq!(reverted.failures.len(), 1); + + let metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); + assert!(metadata.capture_date_backup.is_some()); + assert_eq!( + metadata + .exif + .as_ref() + .and_then(|exif| exif.get("DateTimeOriginal")) + .map(String::as_str), + Some("2021-02-03 04:05:06") + ); + } +} + fn match_disk_kind(disks: &Disks, canonical: &Path) -> Option { let mut best_match: Option<(&Path, bool)> = None; diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 59c9f040d3..a88db1edef 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -57,6 +57,15 @@ pub struct ImageMetadata { pub tags: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub exif: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capture_date_backup: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CaptureDateBackup { + pub date_time_original: Option, + #[serde(default)] + pub source_written: bool, } impl Default for ImageMetadata { @@ -67,6 +76,7 @@ impl Default for ImageMetadata { adjustments: Value::Null, tags: None, exif: None, + capture_date_backup: None, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 034d88067b..b0e9fe09e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2331,6 +2331,8 @@ pub fn run() { export_processing::estimate_export_sizes, image_processing::calculate_auto_adjustments, mask_generation::generate_mask_overlay, + file_management::get_capture_date_revert_availability, + file_management::update_capture_dates, file_management::update_exif_fields, file_management::get_supported_file_types, file_management::read_exif_for_paths, diff --git a/src/components/modals/AppModals.tsx b/src/components/modals/AppModals.tsx index 137594dc9b..fbf13a2b37 100644 --- a/src/components/modals/AppModals.tsx +++ b/src/components/modals/AppModals.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { invoke } from '@tauri-apps/api/core'; import { toast } from 'react-toastify'; @@ -19,8 +20,10 @@ import ConfirmModal from './ConfirmModal'; import ImportSettingsModal from './ImportSettingsModal'; import CullingModal from './CullingModal'; import CollageModal from './CollageModal'; +import CaptureDateModal from './CaptureDateModal'; import { AppSettings, Invokes, AlbumItem, Album, AlbumGroup } from '../ui/AppProperties'; import { CopyPasteSettings } from '../../utils/adjustments'; +import { useLibraryActions } from '../../hooks/useLibraryActions'; export interface AppModalsProps { handleImageSelect: (path: string) => void; @@ -46,6 +49,7 @@ export interface AppModalsProps { export default function AppModals(props: AppModalsProps) { const { t } = useTranslation(); + const { getCaptureDateRevertAvailability, handleUpdateCaptureDates } = useLibraryActions(); const { appSettings, handleSettingsChange } = useSettingsStore( useShallow((state) => ({ appSettings: state.appSettings, @@ -57,10 +61,12 @@ export default function AppModals(props: AppModalsProps) { isCreateFolderModalOpen, isRenameFolderModalOpen, isRenameFileModalOpen, + isCaptureDateModalOpen, isImportModalOpen, isCopyPasteSettingsModalOpen, folderActionTarget, renameTargetPaths, + captureDateTargetPaths, importSourcePaths, isCreateAlbumModalOpen, isCreateAlbumGroupModalOpen, @@ -79,10 +85,12 @@ export default function AppModals(props: AppModalsProps) { isCreateFolderModalOpen: state.isCreateFolderModalOpen, isRenameFolderModalOpen: state.isRenameFolderModalOpen, isRenameFileModalOpen: state.isRenameFileModalOpen, + isCaptureDateModalOpen: state.isCaptureDateModalOpen, isImportModalOpen: state.isImportModalOpen, isCopyPasteSettingsModalOpen: state.isCopyPasteSettingsModalOpen, folderActionTarget: state.folderActionTarget, renameTargetPaths: state.renameTargetPaths, + captureDateTargetPaths: state.captureDateTargetPaths, importSourcePaths: state.importSourcePaths, isCreateAlbumModalOpen: state.isCreateAlbumModalOpen, isCreateAlbumGroupModalOpen: state.isCreateAlbumGroupModalOpen, @@ -113,6 +121,21 @@ export default function AppModals(props: AppModalsProps) { })), ); + const imageList = useLibraryStore((state) => state.imageList); + const captureDateReferencePath = captureDateTargetPaths[0] || ''; + const captureDateReferenceImage = + imageList.find((image) => image.path === captureDateReferencePath) || + (selectedImage?.path === captureDateReferencePath ? selectedImage : null); + const physicalCaptureDateTargetPaths = Array.from( + new Set(captureDateTargetPaths.map((path) => path.split('?vc=')[0])), + ); + const canWriteCaptureDateToOriginal = + physicalCaptureDateTargetPaths.length > 0 && physicalCaptureDateTargetPaths.every((path) => /\.jpe?g$/i.test(path)); + const checkCaptureDateRevertAvailability = useCallback( + () => getCaptureDateRevertAvailability(captureDateTargetPaths), + [captureDateTargetPaths, getCaptureDateRevertAvailability], + ); + const closeConfirmModal = () => { setUI((state) => ({ confirmModalState: { ...state.confirmModalState, isOpen: false } })); }; @@ -281,6 +304,18 @@ export default function AppModals(props: AppModalsProps) { onClose={() => setUI({ isRenameFileModalOpen: false })} onSave={props.handleSaveRename} /> + + handleUpdateCaptureDates(captureDateTargetPaths, operation, writeToOriginal) + } + onCheckRevertAvailability={checkCaptureDateRevertAvailability} + onClose={() => setUI({ captureDateTargetPaths: [], isCaptureDateModalOpen: false })} + referencePath={captureDateReferencePath} + targetCount={physicalCaptureDateTargetPaths.length} + /> ; + onCheckRevertAvailability(): Promise; + onClose(): void; + referencePath: string; + targetCount: number; +} + +type EditMode = 'adjust' | 'shift'; + +function captureDateParts(value?: string) { + const match = value?.match(/^(\d{4})[:-](\d{2})[:-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})/); + return match ? { date: `${match[1]}-${match[2]}-${match[3]}`, time: `${match[4]}:${match[5]}:${match[6]}` } : null; +} + +function shiftCaptureDate(value: string | undefined, seconds: number) { + const parts = captureDateParts(value); + if (!parts) return null; + const [year, month, day] = parts.date.split('-').map(Number); + const [hour, minute, second] = parts.time.split(':').map(Number); + const shifted = new Date(Date.UTC(year, month - 1, day, hour, minute, second) + seconds * 1000); + const pad = (part: number) => String(part).padStart(2, '0'); + return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`; +} + +export default function CaptureDateModal({ + canWriteOriginal, + currentDate, + isOpen, + onApply, + onCheckRevertAvailability, + onClose, + referencePath, + targetCount, +}: CaptureDateModalProps) { + const { t } = useTranslation(); + const [mode, setMode] = useState('adjust'); + const [date, setDate] = useState(''); + const [time, setTime] = useState('00:00:00'); + const [direction, setDirection] = useState<1 | -1>(1); + const [days, setDays] = useState(0); + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const [seconds, setSeconds] = useState(0); + const [writeToOriginal, setWriteToOriginal] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [canRevert, setCanRevert] = useState(false); + const [isCheckingRevert, setIsCheckingRevert] = useState(false); + + useEffect(() => { + if (!isOpen) return; + const parts = captureDateParts(currentDate); + setMode('adjust'); + setDate(parts?.date || ''); + setTime(parts?.time || '00:00:00'); + setDirection(1); + setDays(0); + setHours(0); + setMinutes(0); + setSeconds(0); + setWriteToOriginal(false); + }, [currentDate, isOpen]); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + setCanRevert(false); + setIsCheckingRevert(true); + onCheckRevertAvailability() + .then((availability) => { + if (!cancelled) setCanRevert(availability.canRevert); + }) + .catch((error) => { + if (!cancelled) console.error('Failed to check Capture Date revert availability', error); + }) + .finally(() => { + if (!cancelled) setIsCheckingRevert(false); + }); + return () => { + cancelled = true; + }; + }, [isOpen, onCheckRevertAvailability]); + + const shiftSeconds = direction * (days * 86400 + hours * 3600 + minutes * 60 + seconds); + const preview = useMemo( + () => (mode === 'adjust' ? (date ? `${date} ${time}` : null) : shiftCaptureDate(currentDate, shiftSeconds)), + [currentDate, date, mode, shiftSeconds, time], + ); + const normalizedCurrentDate = useMemo(() => { + const parts = captureDateParts(currentDate); + return parts ? `${parts.date} ${parts.time}` : null; + }, [currentDate]); + const canApply = mode === 'adjust' ? Boolean(date && time && preview !== normalizedCurrentDate) : shiftSeconds !== 0; + + const reportResult = (result: CaptureDateBatchResult) => { + const sourceFailures = result.updates.filter((update) => update.sourceError); + if (result.updates.length === 0) { + result.failures.forEach((failure) => + console.error(`Failed to update Capture Date for ${failure.path}: ${failure.error}`), + ); + toast.error(t('editor.metadata.captureDate.failed')); + return false; + } + if (result.failures.length > 0) { + toast.warn( + t('editor.metadata.captureDate.partialSuccess', { + count: result.updates.length, + failed: result.failures.length, + }), + ); + } else { + toast.success(t('editor.metadata.captureDate.success', { count: result.updates.length })); + } + if (sourceFailures.length > 0) { + toast.warn( + t('editor.metadata.captureDate.sourceWriteFailed', { + count: sourceFailures.length, + }), + ); + sourceFailures.forEach((update) => + console.error(`Failed to write Capture Date to ${update.path}: ${update.sourceError}`), + ); + } + return true; + }; + + const applyOperation = async (operation: CaptureDateOperation, writeOriginal: boolean) => { + setIsApplying(true); + try { + const result = await onApply(operation, writeOriginal); + if (reportResult(result)) onClose(); + } catch (error) { + console.error('Failed to update Capture Date', error); + toast.error(t('editor.metadata.captureDate.failedWithError', { error: String(error) })); + } finally { + setIsApplying(false); + } + }; + + const handleApply = () => { + if (mode === 'adjust') { + applyOperation( + { + mode: 'adjust', + referencePath, + newDate: `${date} ${time}`, + }, + writeToOriginal, + ); + } else { + applyOperation({ mode: 'shift', seconds: shiftSeconds }, writeToOriginal); + } + }; + + if (!isOpen) return null; + + const numberInput = (label: string, value: number, setValue: (value: number) => void, max: number) => ( + + ); + + return ( +
event.key === 'Escape' && onClose()} + role="dialog" + > +
event.stopPropagation()} + > +
+ + {t('editor.metadata.captureDate.title')} +
+ + {t('editor.metadata.captureDate.selectionCount', { count: targetCount })} + + +
+ {(['adjust', 'shift'] as EditMode[]).map((item) => ( + + ))} +
+ + + {t( + mode === 'adjust' + ? targetCount > 1 + ? 'editor.metadata.captureDate.adjustMultipleHint' + : 'editor.metadata.captureDate.adjustSingleHint' + : 'editor.metadata.captureDate.shiftHint', + )} + + + {mode === 'adjust' ? ( +
+ + +
+ ) : ( +
+
+ + +
+
+ {numberInput(t('editor.metadata.captureDate.days'), days, setDays, 99999)} + {numberInput(t('editor.metadata.captureDate.hours'), hours, setHours, 23)} + {numberInput(t('editor.metadata.captureDate.minutes'), minutes, setMinutes, 59)} + {numberInput(t('editor.metadata.captureDate.seconds'), seconds, setSeconds, 59)} +
+
+ )} + +
+
+ + {t('editor.metadata.captureDate.current')} + + {currentDate || '—'} +
+
+ + {t('editor.metadata.captureDate.preview')} + + {preview || '—'} +
+
+ + {canWriteOriginal ? ( + + ) : ( + + {t('editor.metadata.captureDate.sidecarOnlyHint')} + + )} + +
+
+ +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 90bd14078b..6864c1adde 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -100,6 +100,8 @@ export enum Invokes { MergeHdr = 'merge_hdr', TestAIConnectorConnection = 'test_ai_connector_connection', UpdateWgpuTransform = 'update_wgpu_transform', + GetCaptureDateRevertAvailability = 'get_capture_date_revert_availability', + UpdateCaptureDates = 'update_capture_dates', UpdateExifFields = 'update_exif_fields', FetchCommunityPresets = 'fetch_community_presets', GenerateAllCommunityPreviews = 'generate_all_community_previews', diff --git a/src/components/ui/GlobalTooltip.tsx b/src/components/ui/GlobalTooltip.tsx index 323211b3ba..c0637b4b2d 100644 --- a/src/components/ui/GlobalTooltip.tsx +++ b/src/components/ui/GlobalTooltip.tsx @@ -179,12 +179,16 @@ export default function GlobalTooltip() { animate={{ opacity: 1, scale: 1, y: tooltip.isAbove ? -10 : 0, x: '-50%' }} exit={{ opacity: 0, scale: 0.9, x: '-50%' }} transition={{ duration: 0.15, ease: 'easeOut' }} - style={{ top: tooltip.y, left }} + style={{ + top: tooltip.y, + left, + maxWidth: 'min(24rem, calc(100vw - 1.5rem))', + }} className={clsx( 'fixed z-100 pointer-events-none', 'bg-surface/80 backdrop-blur-xs', 'border border-text-secondary/10 shadow-xl rounded-md', - 'px-2.5 py-1.5 whitespace-nowrap', + 'px-2.5 py-1.5 whitespace-normal text-center break-words', tooltip.isAbove && '-translate-y-full', )} > diff --git a/src/hooks/useAppContextMenus.ts b/src/hooks/useAppContextMenus.ts index 38d3d70b4c..4e2e705126 100644 --- a/src/hooks/useAppContextMenus.ts +++ b/src/hooks/useAppContextMenus.ts @@ -41,6 +41,7 @@ import { Briefcase, User, Album as AlbumIcon, + CalendarClock, } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; @@ -418,6 +419,7 @@ export function useAppContextMenus(props: UseAppContextMenusProps) { const copyLabel = t('contextMenus.thumbnail.copyImage', { count: selectionCount }); const autoAdjustLabel = t('contextMenus.thumbnail.autoAdjust', { count: selectionCount }); const renameLabel = t('contextMenus.thumbnail.renameImage', { count: selectionCount }); + const captureDateLabel = t('contextMenus.thumbnail.editCaptureDate', { count: selectionCount }); const cullLabel = t('contextMenus.thumbnail.cullImage', { count: selectionCount }); const collageLabel = t('contextMenus.thumbnail.collage', { count: selectionCount }); const stitchLabel = t('contextMenus.editor.stitchPanorama'); @@ -681,6 +683,15 @@ export function useAppContextMenus(props: UseAppContextMenusProps) { ], }, { icon: FileEdit, label: renameLabel, onClick: () => props.handleRenameFiles(finalSelection) }, + { + icon: CalendarClock, + label: captureDateLabel, + onClick: () => + setUI({ + captureDateTargetPaths: finalSelection, + isCaptureDateModalOpen: true, + }), + }, { type: OPTION_SEPARATOR }, { icon: Star, diff --git a/src/hooks/useAppInitialization.ts b/src/hooks/useAppInitialization.ts index 87b88ff4ba..c7a3673785 100644 --- a/src/hooks/useAppInitialization.ts +++ b/src/hooks/useAppInitialization.ts @@ -468,6 +468,8 @@ export const useAppInitialization = ({ THEMES.find((t: ThemeProps) => t.id === DEFAULT_THEME_ID); if (!baseTheme) return; + root.style.colorScheme = currentThemeId === Theme.Light ? 'light' : 'dark'; + let finalCssVariables: any = { ...baseTheme.cssVariables }; Object.entries(finalCssVariables).forEach(([key, value]) => { diff --git a/src/hooks/useLibraryActions.ts b/src/hooks/useLibraryActions.ts index cda575aedf..8cc6090500 100644 --- a/src/hooks/useLibraryActions.ts +++ b/src/hooks/useLibraryActions.ts @@ -9,6 +9,27 @@ import { globalImageCache } from '../utils/ImageLRUCache'; import { useSettingsStore } from '../store/useSettingsStore'; import { computeSortedLibrary } from './useSortedLibrary'; +export type CaptureDateOperation = + { mode: 'adjust'; referencePath: string; newDate: string } | { mode: 'shift'; seconds: number } | { mode: 'revert' }; + +export interface CaptureDateUpdate { + path: string; + newDate: string | null; + wroteOriginal: boolean; + sourceError: string | null; +} + +export interface CaptureDateBatchResult { + updates: CaptureDateUpdate[]; + failures: { path: string; error: string }[]; +} + +export interface CaptureDateRevertAvailability { + canRevert: boolean; + eligibleCount: number; + totalCount: number; +} + export function useLibraryActions(handleImageSelect?: (path: string, openInEditor?: boolean) => void) { const handleRate = useCallback((newRating: number, paths?: string[]) => { const { multiSelectedPaths, imageRatings, setLibrary } = useLibraryStore.getState(); @@ -131,6 +152,73 @@ export function useLibraryActions(handleImageSelect?: (path: string, openInEdito } }, []); + const handleUpdateCaptureDates = useCallback( + async ( + paths: string[], + operation: CaptureDateOperation, + writeToOriginal: boolean, + ): Promise => { + const physicalPaths = Array.from(new Set(paths.map((path) => path.split('?vc=')[0]))); + const result = await invoke(Invokes.UpdateCaptureDates, { + paths: physicalPaths, + operation, + writeToOriginal, + }); + const updatesByPath = new Map(result.updates.map((update) => [update.path, update])); + + const updateExif = ( + path: string, + exif: Record | null | undefined, + ): Record | null => { + const update = updatesByPath.get(path.split('?vc=')[0]); + if (!update) return exif || null; + const nextExif = { ...(exif || {}) }; + if (update.newDate) { + nextExif.DateTimeOriginal = update.newDate; + } else { + delete nextExif.DateTimeOriginal; + } + return nextExif; + }; + + useEditorStore.getState().setEditor((state) => { + if (!state.selectedImage) return state; + const nextExif = updateExif(state.selectedImage.path, state.selectedImage.exif); + if (nextExif === state.selectedImage.exif) return state; + return { selectedImage: { ...state.selectedImage, exif: nextExif } }; + }); + + useLibraryStore.getState().setLibrary((state) => ({ + imageList: state.imageList.map((image) => { + const nextExif = updateExif(image.path, image.exif); + return nextExif === image.exif ? image : { ...image, exif: nextExif }; + }), + })); + + paths.forEach((path) => { + const cached = globalImageCache.get(path); + if (!cached?.selectedImage) return; + const nextExif = updateExif(path, cached.selectedImage.exif); + if (nextExif !== cached.selectedImage.exif) { + globalImageCache.set(path, { + ...cached, + selectedImage: { ...cached.selectedImage, exif: nextExif }, + }); + } + }); + + return result; + }, + [], + ); + + const getCaptureDateRevertAvailability = useCallback(async (paths: string[]) => { + const physicalPaths = Array.from(new Set(paths.map((path) => path.split('?vc=')[0]))); + return invoke(Invokes.GetCaptureDateRevertAvailability, { + paths: physicalPaths, + }); + }, []); + const handleClearSelection = useCallback(() => { const activeView = useUIStore.getState().activeView; const { selectedImage } = useEditorStore.getState(); @@ -425,9 +513,11 @@ export function useLibraryActions(handleImageSelect?: (path: string, openInEdito }, []); return { + getCaptureDateRevertAvailability, handleRate, handleSetColorLabel, handleTagsChanged, + handleUpdateCaptureDates, handleUpdateExif, handleClearSelection, handleLibraryImageSingleClick, diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json index 636f4f921e..002013ddaf 100644 --- a/src/i18n/locales/ca.json +++ b/src/i18n/locales/ca.json @@ -253,6 +253,9 @@ "denoise_one": "Reduir soroll de la imatge", "denoise_other": "Reduir soroll de les imatges", "duplicateImage": "Duplicar imatge", + "editCaptureDate_one": "Edita la data de captura", + "editCaptureDate_other": "Edita les dates de captura", + "editCaptureDate_many": "Edita les dates de captura", "exportImage_one": "Exportar imatge", "exportImage_other": "Exportar {{count}} imatges", "noAlbums": "Cap àlbum disponible", @@ -610,6 +613,52 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_many": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "selectionCount_many": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_many": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "success_many": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginal_many": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_many": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Detalls del creador", "title": "Autor i Drets d'autor" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 1ae7ad7c8b..ce371da4c8 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -253,6 +253,8 @@ "denoise_one": "Bild entrauschen", "denoise_other": "Bilder entrauschen", "duplicateImage": "Bild duplizieren", + "editCaptureDate_one": "Aufnahmedatum bearbeiten", + "editCaptureDate_other": "Aufnahmedaten bearbeiten", "exportImage_one": "Bild exportieren", "exportImage_other": "{{count}} Bilder exportieren", "noAlbums": "Keine Alben verfügbar", @@ -610,6 +612,46 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Details zum Ersteller", "title": "Autor & Copyright" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 28b89a19b5..a34f86063c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -253,6 +253,8 @@ "denoise_one": "Denoise Image", "denoise_other": "Denoise Images", "duplicateImage": "Duplicate Image", + "editCaptureDate_one": "Edit Capture Date", + "editCaptureDate_other": "Edit Capture Dates", "exportImage_one": "Export Image", "exportImage_other": "Export {{count}} Images", "noAlbums": "No Albums Available", @@ -614,6 +616,46 @@ "creatorDetails": "Creator Details", "title": "Author & Copyright" }, + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "camera": { "aperture": "Aperture", "focalLength": "Focal Length", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5458974362..0bcca0872c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -264,6 +264,9 @@ "denoise_one": "Reducir ruido de imagen", "denoise_other": "Reducir ruido de imágenes", "duplicateImage": "Duplicar imagen", + "editCaptureDate_one": "Editar fecha de captura", + "editCaptureDate_other": "Editar fechas de captura", + "editCaptureDate_many": "Editar fechas de captura", "exportImage_many": "Exportar {{count}} imágenes", "exportImage_one": "Exportar imagen", "exportImage_other": "Exportar {{count}} imágenes", @@ -631,6 +634,52 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_many": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "selectionCount_many": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_many": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "success_many": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginal_many": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_many": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Detalles del creador", "title": "Autor y Copyright" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f45d0a1354..a5200a62a0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -264,6 +264,9 @@ "denoise_one": "Débruiter l'image", "denoise_other": "Débruiter les images", "duplicateImage": "Dupliquer l'image", + "editCaptureDate_one": "Modifier la date de capture", + "editCaptureDate_other": "Modifier les dates de capture", + "editCaptureDate_many": "Modifier les dates de capture", "exportImage_many": "Exporter {{count}} images", "exportImage_one": "Exporter l'image", "exportImage_other": "Exporter {{count}} images", @@ -631,6 +634,52 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_many": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "selectionCount_many": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_many": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "success_many": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginal_many": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_many": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Détails du créateur", "title": "Auteur & Copyright" diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index ee93cacb1e..31f44edb76 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -264,6 +264,9 @@ "denoise_one": "Riduci Rumore Immagine", "denoise_other": "Riduci Rumore Immagini", "duplicateImage": "Duplica Immagine", + "editCaptureDate_one": "Modifica data di acquisizione", + "editCaptureDate_other": "Modifica date di acquisizione", + "editCaptureDate_many": "Modifica date di acquisizione", "exportImage_many": "Esporta {{count}} Immagini", "exportImage_one": "Esporta Immagine", "exportImage_other": "Esporta {{count}} Immagini", @@ -631,6 +634,52 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_many": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "selectionCount_many": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_many": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "success_many": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginal_many": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_many": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Dettagli Creatore", "title": "Autore e Copyright" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9b3c453ddb..7b2e537cf8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -253,6 +253,8 @@ "denoise_one": "ノイズ低減を実行", "denoise_other": "ノイズ低減を実行", "duplicateImage": "画像を複製", + "editCaptureDate_one": "撮影日時を編集", + "editCaptureDate_other": "撮影日時を編集", "exportImage_one": "画像を書き出し", "exportImage_other": "{{count}} 枚の画像を書き出し", "noAlbums": "利用可能なアルバムがありません", @@ -608,6 +610,46 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "制作者の詳細", "title": "作成者と著作権" diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 85277c3292..a31337517d 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -253,6 +253,8 @@ "denoise_one": "이미지 노이즈 제거", "denoise_other": "이미지 노이즈 제거", "duplicateImage": "이미지 복제", + "editCaptureDate_one": "촬영 날짜 편집", + "editCaptureDate_other": "촬영 날짜 편집", "exportImage_one": "이미지 내보내기", "exportImage_other": "이미지 {{count}}개 내보내기", "noAlbums": "사용 가능한 앨범 없음", @@ -608,6 +610,46 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "제작자 세부 정보", "title": "작성자 및 저작권" diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 2902f86675..e5eec86ea2 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -275,6 +275,10 @@ "denoise_one": "Odszum obraz", "denoise_other": "Odszum obrazy", "duplicateImage": "Duplikuj obraz", + "editCaptureDate_one": "Edytuj datę wykonania", + "editCaptureDate_other": "Edytuj daty wykonania", + "editCaptureDate_few": "Edytuj daty wykonania", + "editCaptureDate_many": "Edytuj daty wykonania", "exportImage_few": "Eksportuj {{count}} obrazy", "exportImage_many": "Eksportuj {{count}} obrazów", "exportImage_one": "Eksportuj obraz", @@ -652,6 +656,58 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_few": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_many": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_few": "{{count}} photos selected", + "selectionCount_many": "{{count}} photos selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_few": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_many": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_few": "Updated Capture Date for {{count}} photos.", + "success_many": "Updated Capture Date for {{count}} photos.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_few": "Also write to original JPEG files", + "writeOriginal_many": "Also write to original JPEG files", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_few": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_many": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Szczegóły twórcy", "title": "Autor i prawa autorskie" diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 2cd4d611f0..1f2c4b6db1 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -264,6 +264,9 @@ "denoise_one": "Reduzir Ruído da Imagem", "denoise_other": "Reduzir Ruído das Imagens", "duplicateImage": "Duplicar Imagem", + "editCaptureDate_one": "Editar data de captura", + "editCaptureDate_other": "Editar datas de captura", + "editCaptureDate_many": "Editar datas de captura", "exportImage_many": "Exportar {{count}} Imagens", "exportImage_one": "Exportar Imagem", "exportImage_other": "Exportar {{count}} Imagens", @@ -631,6 +634,52 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_many": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "selectionCount_many": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_many": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "success_many": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginal_many": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_many": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Detalhes do Criador", "title": "Autor e Direitos Autorais" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b7185c162a..fa972eb2f5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -275,6 +275,10 @@ "denoise_one": "Шумоподавление", "denoise_other": "Шумоподавление для изображений", "duplicateImage": "Дублировать изображение", + "editCaptureDate_one": "Изменить дату съёмки", + "editCaptureDate_other": "Изменить даты съёмки", + "editCaptureDate_few": "Изменить даты съёмки", + "editCaptureDate_many": "Изменить даты съёмки", "exportImage_few": "Экспортировать {{count}} изображений", "exportImage_many": "Экспортировать {{count}} изображений", "exportImage_one": "Экспортировать изображение", @@ -652,6 +656,58 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_few": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_many": "Updated {{count}} photos; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_few": "{{count}} photos selected", + "selectionCount_many": "{{count}} photos selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_few": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_many": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_few": "Updated Capture Date for {{count}} photos.", + "success_many": "Updated Capture Date for {{count}} photos.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_few": "Also write to original JPEG files", + "writeOriginal_many": "Also write to original JPEG files", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_few": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_many": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "Информация об авторе", "title": "Автор и авторские права" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 40ad32b846..fa2f471d0f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -253,6 +253,8 @@ "denoise_one": "降噪图像", "denoise_other": "降噪图像", "duplicateImage": "复制图像", + "editCaptureDate_one": "编辑拍摄日期", + "editCaptureDate_other": "编辑拍摄日期", "exportImage_one": "导出图像", "exportImage_other": "导出 {{count}} 张图像", "noAlbums": "无可用相册", @@ -608,6 +610,46 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "创作者详情", "title": "作者与版权" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 9b5352094d..727896e690 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -253,6 +253,8 @@ "denoise_one": "降噪影像", "denoise_other": "降噪影像", "duplicateImage": "複製影像", + "editCaptureDate_one": "編輯拍攝日期", + "editCaptureDate_other": "編輯拍攝日期", "exportImage_one": "匯出影像", "exportImage_other": "匯出 {{count}} 張影像", "noAlbums": "無可用相簿", @@ -608,6 +610,46 @@ } }, "metadata": { + "captureDate": { + "adjust": "Adjust", + "adjustMultipleHint": "Set the selected photo's corrected time. The same difference will be applied to every selected photo, preserving the time between them.", + "adjustSingleHint": "Set the date and time when this photo was captured.", + "apply": "Apply", + "applying": "Applying...", + "backward": "Shift backward", + "cancel": "Cancel", + "current": "Current", + "date": "Date", + "days": "Days", + "editTooltip": "Edit Capture Date", + "failed": "Failed to update Capture Date", + "failedWithError": "Failed to update Capture Date: {{error}}", + "forward": "Shift forward", + "hours": "Hours", + "minutes": "Minutes", + "notSet": "Capture Date not set", + "partialSuccess_one": "Updated 1 photo; {{failed}} could not be updated.", + "partialSuccess_other": "Updated {{count}} photos; {{failed}} could not be updated.", + "preview": "Preview", + "revert": "Revert to original", + "revertUnavailable": "Revert is available after RapidRAW changes the Capture Date for every selected photo.", + "seconds": "Seconds", + "selectionCount_one": "1 photo selected", + "selectionCount_other": "{{count}} photos selected", + "shift": "Shift", + "shiftHint": "Move every selected Capture Date by the same amount.", + "sidecarOnlyHint": "This edit will be stored in RapidRAW metadata. Writing directly to originals is currently available for JPEGs only.", + "sourceWriteFailed_one": "RapidRAW metadata was updated, but 1 original JPEG could not be updated.", + "sourceWriteFailed_other": "RapidRAW metadata was updated, but {{count}} original JPEGs could not be updated.", + "success_one": "Updated Capture Date for 1 photo.", + "success_other": "Updated Capture Date for {{count}} photos.", + "time": "Time", + "title": "Edit Capture Date", + "writeOriginal_one": "Also write to original JPEG file", + "writeOriginal_other": "Also write to original JPEG files", + "writeOriginalHint_one": "Updates EXIF DateTimeOriginal while preserving the file modification date and allowing Revert.", + "writeOriginalHint_other": "Updates EXIF DateTimeOriginal while preserving file modification dates and allowing Revert." + }, "author": { "creatorDetails": "創作者詳情", "title": "作者與版權" diff --git a/src/store/useUIStore.ts b/src/store/useUIStore.ts index 044c3450f0..90bcd2ba48 100644 --- a/src/store/useUIStore.ts +++ b/src/store/useUIStore.ts @@ -104,6 +104,8 @@ interface UIState { isRenameFolderModalOpen: boolean; isRenameFileModalOpen: boolean; renameTargetPaths: Array; + isCaptureDateModalOpen: boolean; + captureDateTargetPaths: Array; isImportModalOpen: boolean; isCopyPasteSettingsModalOpen: boolean; importTargetFolder: string | null; @@ -182,6 +184,8 @@ export const useUIStore = create((set, get) => ({ isRenameFolderModalOpen: false, isRenameFileModalOpen: false, renameTargetPaths: [], + isCaptureDateModalOpen: false, + captureDateTargetPaths: [], isImportModalOpen: false, isCopyPasteSettingsModalOpen: false, importTargetFolder: null, From e321e1fdd7fbb9fa3cf9d462092a31a2117e0bee Mon Sep 17 00:00:00 2001 From: Dimitry Fayerman Date: Thu, 6 Aug 2026 15:55:41 -0400 Subject: [PATCH 2/2] fix: harden capture date editing --- src-tauri/src/file_management.rs | 1228 +++++++++++++++++++- src-tauri/src/image_loader.rs | 2 + src-tauri/src/image_processing.rs | 13 + src/components/modals/AppModals.tsx | 19 +- src/components/modals/CaptureDateModal.tsx | 139 ++- src/components/ui/GlobalTooltip.tsx | 37 + src/hooks/useAppContextMenus.ts | 1 + src/hooks/useLibraryActions.ts | 27 +- src/i18n/locales/ca.json | 91 +- src/i18n/locales/de.json | 76 +- src/i18n/locales/en.json | 20 +- src/i18n/locales/es.json | 91 +- src/i18n/locales/fr.json | 87 +- src/i18n/locales/it.json | 91 +- src/i18n/locales/ja.json | 76 +- src/i18n/locales/ko.json | 76 +- src/i18n/locales/pl.json | 106 +- src/i18n/locales/pt.json | 91 +- src/i18n/locales/ru.json | 106 +- src/i18n/locales/zh-CN.json | 76 +- src/i18n/locales/zh-TW.json | 76 +- src/store/useUIStore.ts | 2 + src/utils/ImageLRUCache.ts | 9 + 23 files changed, 1878 insertions(+), 662 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index cc3cb82fc7..7702256fca 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -3,9 +3,9 @@ use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; use std::fmt; -use std::fs; +use std::fs::{self, File, OpenOptions}; use std::hash::{Hash, Hasher}; -use std::io::{Cursor, Write}; +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; @@ -40,7 +40,7 @@ use crate::gpu_processing; use crate::image_loader; use crate::image_processing::GpuContext; use crate::image_processing::{ - CaptureDateBackup, Crop, ImageMetadata, apply_coarse_rotation, + CaptureDateBackup, CaptureDateSourceRecovery, Crop, ImageMetadata, apply_coarse_rotation, apply_cpu_default_raw_processing, apply_crop, apply_flip, apply_geometry_warp, apply_rotation, auto_results_to_json, get_all_adjustments_from_json, perform_auto_analysis, }; @@ -536,6 +536,7 @@ pub struct CaptureDateUpdate { path: String, new_date: Option, wrote_original: bool, + modified: Option, source_error: Option, } @@ -565,6 +566,16 @@ enum ResolvedCaptureDateOperation { Revert, } +fn file_modified_unix_seconds(path: &Path) -> Option { + fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()) +} + fn read_source_exif(path: &Path) -> HashMap { let path_str = path.to_string_lossy(); if let Ok(mmap) = read_file_mapped(path) { @@ -620,8 +631,37 @@ fn capture_date_revert_availability_for_paths( fn save_capture_date_metadata(path: &Path, metadata: &ImageMetadata) -> Result<(), String> { let sidecar_path = exif_processing::get_primary_sidecar_path(path); let json = serde_json::to_string_pretty(metadata).map_err(|e| e.to_string())?; - fs::write(&sidecar_path, json) - .map_err(|e| format!("Failed to write {}: {e}", sidecar_path.display())) + let parent = sidecar_path.parent().ok_or_else(|| { + format!( + "Sidecar has no parent directory: {}", + sidecar_path.display() + ) + })?; + let mut staged = NamedTempFile::new_in(parent).map_err(|e| { + format!( + "Failed to stage Capture Date metadata beside {}: {e}", + sidecar_path.display() + ) + })?; + staged + .write_all(json.as_bytes()) + .and_then(|_| staged.flush()) + .and_then(|_| staged.as_file().sync_all()) + .map_err(|e| format!("Failed to stage {}: {e}", sidecar_path.display()))?; + staged + .persist(&sidecar_path) + .map_err(|e| format!("Failed to replace {}: {}", sidecar_path.display(), e.error))?; + sync_parent_directory(parent).map_err(|e| format!("Failed to sync {}: {e}", parent.display())) +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> std::io::Result<()> { + File::open(path)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> std::io::Result<()> { + Ok(()) } fn is_jpeg(path: &Path) -> bool { @@ -634,27 +674,406 @@ fn is_jpeg(path: &Path) -> bool { ) } -fn write_jpeg_capture_date(path: &Path, capture_date: Option<&str>) -> Result<(), String> { - if !is_jpeg(path) { - return Err( - "Writing Capture Date to the original is currently supported only for JPEG files" - .to_string(), - ); +#[derive(Clone, Copy)] +enum TiffByteOrder { + Big, + Little, +} + +impl TiffByteOrder { + fn read_u16(self, bytes: &[u8], offset: usize) -> Option { + let value = bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?; + Some(match self { + Self::Big => u16::from_be_bytes(value), + Self::Little => u16::from_le_bytes(value), + }) + } + + fn read_u32(self, bytes: &[u8], offset: usize) -> Option { + let value = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?; + Some(match self { + Self::Big => u32::from_be_bytes(value), + Self::Little => u32::from_le_bytes(value), + }) } +} - let resolved_path = path - .canonicalize() - .map_err(|e| format!("Failed to resolve {}: {e}", path.display()))?; - let path = resolved_path.as_path(); +#[derive(Debug)] +struct ExifDateLocation { + offset: usize, + current_bytes: [u8; 20], + current_date: String, +} - let file_metadata = fs::metadata(path) - .map_err(|e| format!("Failed to read file attributes for {}: {e}", path.display()))?; - let accessed = filetime::FileTime::from_last_access_time(&file_metadata); - let modified = filetime::FileTime::from_last_modification_time(&file_metadata); - let permissions = file_metadata.permissions(); +#[derive(Debug)] +struct CaptureDateSourceWriteError { + message: String, + source_restored: bool, +} + +impl CaptureDateSourceWriteError { + fn unchanged(message: impl Into) -> Self { + Self { + message: message.into(), + source_restored: true, + } + } + + fn uncertain(message: impl Into) -> Self { + Self { + message: message.into(), + source_restored: false, + } + } +} + +fn strict_exif_date(bytes: &[u8]) -> Option { + if bytes.len() != 20 || bytes[19] != 0 { + return None; + } + let value = std::str::from_utf8(&bytes[..19]).ok()?; + let parsed = NaiveDateTime::parse_from_str(value, "%Y:%m:%d %H:%M:%S").ok()?; + let round_trip = parsed.format("%Y:%m:%d %H:%M:%S").to_string(); + (round_trip == value).then_some(round_trip) +} + +fn normalized_optional_capture_date(value: Option<&str>) -> Option { + value.map(|value| { + exif_processing::parse_creation_datetime(value) + .map(|date| date.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| value.trim().to_string()) + }) +} + +fn find_tiff_ifd_entry( + bytes: &[u8], + tiff_start: usize, + tiff_end: usize, + ifd_relative_offset: u32, + target_tag: u16, + byte_order: TiffByteOrder, +) -> Result, String> { + let ifd_start = tiff_start + .checked_add(ifd_relative_offset as usize) + .ok_or_else(|| "EXIF IFD offset overflowed".to_string())?; + if ifd_start < tiff_start || ifd_start >= tiff_end { + return Err("EXIF IFD offset is outside the APP1 segment".to_string()); + } + let count = byte_order + .read_u16(bytes, ifd_start) + .ok_or_else(|| "EXIF IFD entry count is truncated".to_string())? as usize; + let entries_start = ifd_start + .checked_add(2) + .ok_or_else(|| "EXIF IFD entry offset overflowed".to_string())?; + let entries_len = count + .checked_mul(12) + .ok_or_else(|| "EXIF IFD entry table is too large".to_string())?; + let entries_end = entries_start + .checked_add(entries_len) + .and_then(|end| end.checked_add(4)) + .ok_or_else(|| "EXIF IFD entry table overflowed".to_string())?; + if entries_end > tiff_end || entries_end > bytes.len() { + return Err("EXIF IFD entry table is truncated".to_string()); + } + + for index in 0..count { + let entry = entries_start + index * 12; + let tag = byte_order + .read_u16(bytes, entry) + .ok_or_else(|| "EXIF tag is truncated".to_string())?; + if tag == target_tag { + let field_type = byte_order + .read_u16(bytes, entry + 2) + .ok_or_else(|| "EXIF field type is truncated".to_string())?; + let component_count = byte_order + .read_u32(bytes, entry + 4) + .ok_or_else(|| "EXIF field count is truncated".to_string())?; + return Ok(Some((field_type, component_count, entry + 8))); + } + } + Ok(None) +} + +fn locate_datetime_original(bytes: &[u8]) -> Result, String> { + if !bytes.starts_with(&[0xff, 0xd8]) { + return Err("The file does not have a JPEG signature".to_string()); + } + + let mut cursor = 2usize; + while cursor < bytes.len() { + if bytes[cursor] != 0xff { + return Err("JPEG segment marker is malformed".to_string()); + } + while cursor < bytes.len() && bytes[cursor] == 0xff { + cursor += 1; + } + let marker = *bytes + .get(cursor) + .ok_or_else(|| "JPEG marker is truncated".to_string())?; + cursor += 1; + + if marker == 0xd9 || marker == 0xda { + break; + } + if marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + continue; + } + + let segment_length = u16::from_be_bytes( + bytes + .get(cursor..cursor + 2) + .ok_or_else(|| "JPEG segment length is truncated".to_string())? + .try_into() + .map_err(|_| "JPEG segment length is invalid".to_string())?, + ) as usize; + if segment_length < 2 { + return Err("JPEG segment length is invalid".to_string()); + } + let payload_start = cursor + 2; + let segment_end = cursor + .checked_add(segment_length) + .ok_or_else(|| "JPEG segment length overflowed".to_string())?; + if segment_end > bytes.len() { + return Err("JPEG segment is truncated".to_string()); + } + cursor = segment_end; + + if marker != 0xe1 || bytes.get(payload_start..payload_start + 6) != Some(b"Exif\0\0") { + continue; + } + + let tiff_start = payload_start + 6; + let byte_order = match bytes.get(tiff_start..tiff_start + 2) { + Some(b"II") => TiffByteOrder::Little, + Some(b"MM") => TiffByteOrder::Big, + _ => return Err("EXIF TIFF byte order is invalid".to_string()), + }; + if byte_order.read_u16(bytes, tiff_start + 2) != Some(42) { + return Err("EXIF TIFF signature is invalid".to_string()); + } + let ifd0_offset = byte_order + .read_u32(bytes, tiff_start + 4) + .ok_or_else(|| "EXIF TIFF header is truncated".to_string())?; + let Some((field_type, component_count, value_offset)) = find_tiff_ifd_entry( + bytes, + tiff_start, + segment_end, + ifd0_offset, + 0x8769, + byte_order, + )? + else { + return Ok(None); + }; + if field_type != 4 || component_count != 1 { + return Err("EXIF IFD pointer has an unexpected type or size".to_string()); + } + let exif_ifd_offset = byte_order + .read_u32(bytes, value_offset) + .ok_or_else(|| "EXIF IFD pointer is truncated".to_string())?; + let Some((field_type, component_count, value_offset)) = find_tiff_ifd_entry( + bytes, + tiff_start, + segment_end, + exif_ifd_offset, + 0x9003, + byte_order, + )? + else { + return Ok(None); + }; + if field_type != 2 || component_count != 20 { + return Err( + "EXIF DateTimeOriginal is not a fixed-width 20-byte ASCII field".to_string(), + ); + } + let relative_value_offset = byte_order + .read_u32(bytes, value_offset) + .ok_or_else(|| "EXIF DateTimeOriginal offset is truncated".to_string())?; + let absolute_value_offset = tiff_start + .checked_add(relative_value_offset as usize) + .ok_or_else(|| "EXIF DateTimeOriginal offset overflowed".to_string())?; + let value_end = absolute_value_offset + .checked_add(20) + .ok_or_else(|| "EXIF DateTimeOriginal length overflowed".to_string())?; + if absolute_value_offset < tiff_start || value_end > segment_end { + return Err("EXIF DateTimeOriginal is outside the APP1 segment".to_string()); + } + let current_slice = bytes + .get(absolute_value_offset..value_end) + .ok_or_else(|| "EXIF DateTimeOriginal value is truncated".to_string())?; + let current_bytes: [u8; 20] = current_slice + .try_into() + .map_err(|_| "EXIF DateTimeOriginal value has an invalid length".to_string())?; + let current_date = strict_exif_date(¤t_bytes).ok_or_else(|| { + "EXIF DateTimeOriginal is not in YYYY:MM:DD HH:MM:SS format".to_string() + })?; + return Ok(Some(ExifDateLocation { + offset: absolute_value_offset, + current_bytes, + current_date, + })); + } + + Ok(None) +} + +fn validate_expected_source_date( + path: &Path, + bytes: &[u8], + expected_date: Option<&str>, +) -> Result<(), String> { + let source_exif = exif_processing::read_exif_data_from_bytes(&path.to_string_lossy(), bytes); + let actual = + normalized_optional_capture_date(source_exif.get("DateTimeOriginal").map(String::as_str)); + let expected = normalized_optional_capture_date(expected_date); + if actual != expected { + return Err(format!( + "The original Capture Date changed outside RapidRAW (expected {}, found {})", + expected.as_deref().unwrap_or("not set"), + actual.as_deref().unwrap_or("not set") + )); + } + Ok(()) +} + +fn verify_jpeg_capture_date( + path: &Path, + bytes: &[u8], + intended_date: Option<&str>, +) -> Result<(), String> { + image::load_from_memory_with_format(bytes, image::ImageFormat::Jpeg) + .map_err(|e| format!("Updated JPEG could not be decoded: {e}"))?; + let exif = exif_processing::read_exif_data_from_bytes(&path.to_string_lossy(), bytes); + let actual = normalized_optional_capture_date(exif.get("DateTimeOriginal").map(String::as_str)); + let intended = normalized_optional_capture_date(intended_date); + if actual != intended { + return Err(format!( + "Updated JPEG has Capture Date {}, expected {}", + actual.as_deref().unwrap_or("not set"), + intended.as_deref().unwrap_or("not set") + )); + } + Ok(()) +} + +fn rewrite_existing_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .open(path) + .map_err(|e| format!("Failed to open {} for writing: {e}", path.display()))?; + file.write_all(bytes) + .and_then(|_| file.flush()) + .and_then(|_| file.sync_all()) + .map_err(|e| format!("Failed to rewrite {}: {e}", path.display())) +} + +fn recovery_path(path: &Path, recovery: &CaptureDateSourceRecovery) -> Result { + let backup_name = Path::new(&recovery.backup_file_name); + if backup_name.file_name().and_then(|name| name.to_str()) + != Some(recovery.backup_file_name.as_str()) + { + return Err("Capture Date recovery filename is invalid".to_string()); + } + Ok(path.with_file_name(&recovery.backup_file_name)) +} + +fn clear_recovery_state( + path: &Path, + source_written: bool, + source_date_written: Option<&str>, +) -> Result<(), String> { + let mut metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(path)); + if let Some(backup) = metadata.capture_date_backup.as_mut() { + backup.pending_source_rewrite = None; + backup.source_written = source_written; + backup.source_date_written = if source_written { + normalized_optional_capture_date(source_date_written) + } else { + None + }; + } + save_capture_date_metadata(path, &metadata) +} + +pub(crate) fn recover_pending_capture_date_rewrite(path: &Path) -> Result { + let sidecar_path = exif_processing::get_primary_sidecar_path(path); + let mut metadata = exif_processing::load_sidecar(&sidecar_path); + let Some(recovery) = metadata + .capture_date_backup + .as_ref() + .and_then(|backup| backup.pending_source_rewrite.clone()) + else { + return Ok(false); + }; + let backup_path = recovery_path(path, &recovery)?; + + if backup_path.exists() { + let backup_metadata = fs::metadata(&backup_path) + .map_err(|e| format!("Failed to inspect {}: {e}", backup_path.display()))?; + let accessed = filetime::FileTime::from_last_access_time(&backup_metadata); + let modified = filetime::FileTime::from_last_modification_time(&backup_metadata); + let original_bytes = fs::read(&backup_path) + .map_err(|e| format!("Failed to read {}: {e}", backup_path.display()))?; + rewrite_existing_file(path, &original_bytes)?; + verify_jpeg_capture_date(path, &original_bytes, recovery.original_date.as_deref())?; + filetime::set_file_times(path, accessed, modified) + .map_err(|e| format!("Failed to restore file times for {}: {e}", path.display()))?; + fs::remove_file(&backup_path) + .map_err(|e| format!("Failed to remove {}: {e}", backup_path.display()))?; + if let Some(parent) = path.parent() { + sync_parent_directory(parent) + .map_err(|e| format!("Failed to sync {}: {e}", parent.display()))?; + } + if let Some(backup) = metadata.capture_date_backup.as_mut() { + backup.pending_source_rewrite = None; + backup.source_written = recovery.source_written_before_rewrite; + backup.source_date_written = if recovery.source_written_before_rewrite { + recovery.original_date.clone() + } else { + None + }; + } + save_capture_date_metadata(path, &metadata)?; + log::warn!( + "Recovered an interrupted Capture Date rewrite for {}", + path.display() + ); + return Ok(true); + } + + let source_exif = read_source_exif(path); + let current = + normalized_optional_capture_date(source_exif.get("DateTimeOriginal").map(String::as_str)); + let intended = normalized_optional_capture_date(recovery.intended_date.as_deref()); + let original = normalized_optional_capture_date(recovery.original_date.as_deref()); + let source_written = if current == intended { + true + } else if current == original { + recovery.source_written_before_rewrite + } else { + return Err(format!( + "Capture Date recovery file is missing and {} has an unexpected Capture Date", + path.display() + )); + }; + if let Some(backup) = metadata.capture_date_backup.as_mut() { + backup.pending_source_rewrite = None; + backup.source_written = source_written; + backup.source_date_written = if source_written { current } else { None }; + } + save_capture_date_metadata(path, &metadata)?; + Ok(false) +} - let mut image_bytes = - fs::read(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; +fn build_updated_jpeg( + path: &Path, + original_bytes: &[u8], + capture_date: Option<&str>, +) -> Result, String> { + let mut image_bytes = original_bytes.to_vec(); let mut metadata = match Metadata::new_from_vec(&image_bytes, FileExtension::JPEG) { Ok(metadata) => metadata, Err(little_exif_error) => { @@ -670,7 +1089,6 @@ fn write_jpeg_capture_date(path: &Path, capture_date: Option<&str>) -> Result<() } } }; - metadata.remove_tag(ExifTag::DateTimeOriginal(String::new())); if let Some(value) = capture_date { let parsed = normalized_capture_date(value)?; @@ -681,31 +1099,271 @@ fn write_jpeg_capture_date(path: &Path, capture_date: Option<&str>) -> Result<() metadata .write_to_vec(&mut image_bytes, FileExtension::JPEG) .map_err(|e| format!("Failed to update JPEG metadata for {}: {e}", path.display()))?; + verify_jpeg_capture_date(path, &image_bytes, capture_date)?; + Ok(image_bytes) +} +fn create_source_recovery( + path: &Path, + original_date: Option<&str>, + intended_date: Option<&str>, + source_written_before_rewrite: bool, + accessed: filetime::FileTime, + modified: filetime::FileTime, +) -> Result<(CaptureDateSourceRecovery, PathBuf), String> { let parent = path .parent() .ok_or_else(|| format!("File has no parent directory: {}", path.display()))?; - let mut temp_file = NamedTempFile::new_in(parent).map_err(|e| { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("File name is not valid UTF-8: {}", path.display()))?; + let backup_file_name = format!(".{file_name}.{}.capture-date.rrrecover", Uuid::new_v4()); + let backup_path = parent.join(&backup_file_name); + let copied = fs::copy(path, &backup_path).map_err(|e| { format!( - "Failed to create a temporary file beside {}: {e}", - path.display() + "Failed to create recovery copy {}: {e}", + backup_path.display() ) })?; - temp_file - .as_file() - .set_permissions(permissions) - .map_err(|e| format!("Failed to preserve permissions for {}: {e}", path.display()))?; - temp_file - .write_all(&image_bytes) - .and_then(|_| temp_file.flush()) - .and_then(|_| temp_file.as_file().sync_all()) - .map_err(|e| format!("Failed to stage updated JPEG {}: {e}", path.display()))?; - temp_file - .persist(path) - .map_err(|e| format!("Failed to replace {}: {}", path.display(), e.error))?; - if let Err(error) = filetime::set_file_times(path, accessed, modified) { + let original_len = fs::metadata(path) + .map_err(|e| format!("Failed to inspect {}: {e}", path.display()))? + .len(); + if copied != original_len { + let _ = fs::remove_file(&backup_path); + return Err(format!( + "Recovery copy is incomplete: copied {copied} of {original_len} bytes" + )); + } + if let Err(error) = filetime::set_file_times(&backup_path, accessed, modified) { + let _ = fs::remove_file(&backup_path); + return Err(format!( + "Failed to preserve recovery timestamps for {}: {error}", + path.display() + )); + } + if let Err(error) = File::open(&backup_path) + .and_then(|file| file.sync_all()) + .and_then(|_| sync_parent_directory(parent)) + { + let _ = fs::remove_file(&backup_path); + return Err(format!( + "Failed to sync recovery copy {}: {error}", + backup_path.display() + )); + } + + Ok(( + CaptureDateSourceRecovery { + backup_file_name, + original_date: normalized_optional_capture_date(original_date), + intended_date: normalized_optional_capture_date(intended_date), + source_written_before_rewrite, + }, + backup_path, + )) +} + +fn write_jpeg_capture_date( + path: &Path, + capture_date: Option<&str>, + expected_date: Option<&str>, + source_written_before_rewrite: bool, +) -> Result<(), CaptureDateSourceWriteError> { + if !is_jpeg(path) { + return Err(CaptureDateSourceWriteError::unchanged( + "Writing Capture Date to the original is currently supported only for JPEG files" + .to_string(), + )); + } + + let file_metadata = fs::metadata(path).map_err(|e| { + CaptureDateSourceWriteError::unchanged(format!( + "Failed to read file attributes for {}: {e}", + path.display() + )) + })?; + let accessed = filetime::FileTime::from_last_access_time(&file_metadata); + let modified = filetime::FileTime::from_last_modification_time(&file_metadata); + let original_bytes = fs::read(path).map_err(|e| { + CaptureDateSourceWriteError::unchanged(format!("Failed to read {}: {e}", path.display())) + })?; + validate_expected_source_date(path, &original_bytes, expected_date) + .map_err(CaptureDateSourceWriteError::unchanged)?; + + let fixed_date_location = if capture_date.is_some() { + locate_datetime_original(&original_bytes).map_err(CaptureDateSourceWriteError::unchanged)? + } else { + None + }; + + if let Some(capture_date) = capture_date + && let Some(location) = fixed_date_location + { + let expected = normalized_optional_capture_date(expected_date); + let located = normalized_optional_capture_date(Some(&location.current_date)); + if expected != located { + return Err(CaptureDateSourceWriteError::unchanged( + "The original Capture Date changed before it could be updated", + )); + } + let parsed = normalized_capture_date(capture_date) + .map_err(CaptureDateSourceWriteError::unchanged)?; + let replacement = format!("{}\0", parsed.format("%Y:%m:%d %H:%M:%S")); + let replacement: [u8; 20] = replacement.as_bytes().try_into().map_err(|_| { + CaptureDateSourceWriteError::unchanged("Capture Date has an invalid length") + })?; + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| { + CaptureDateSourceWriteError::unchanged(format!( + "Failed to open {} for updating: {e}", + path.display() + )) + })?; + file.seek(SeekFrom::Start(location.offset as u64)) + .map_err(|e| CaptureDateSourceWriteError::unchanged(e.to_string()))?; + let mut current = [0u8; 20]; + file.read_exact(&mut current) + .map_err(|e| CaptureDateSourceWriteError::unchanged(e.to_string()))?; + if current != location.current_bytes { + return Err(CaptureDateSourceWriteError::unchanged( + "The original Capture Date changed before it could be updated", + )); + } + let write_result = (|| -> Result<(), String> { + file.seek(SeekFrom::Start(location.offset as u64)) + .map_err(|e| e.to_string())?; + file.write_all(&replacement) + .and_then(|_| file.flush()) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string())?; + let updated_bytes = fs::read(path).map_err(|e| e.to_string())?; + let updated_location = locate_datetime_original(&updated_bytes)? + .ok_or_else(|| "Updated EXIF DateTimeOriginal could not be found".to_string())?; + if updated_location.current_bytes != replacement { + return Err("Updated EXIF DateTimeOriginal did not verify".to_string()); + } + Ok(()) + })(); + if let Err(write_error) = write_result { + let rollback = (|| -> Result<(), String> { + file.seek(SeekFrom::Start(location.offset as u64)) + .map_err(|e| e.to_string())?; + file.write_all(&location.current_bytes) + .and_then(|_| file.flush()) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string())?; + let restored = fs::read(path).map_err(|e| e.to_string())?; + let restored_location = locate_datetime_original(&restored)?.ok_or_else(|| { + "Restored EXIF DateTimeOriginal could not be found".to_string() + })?; + if restored_location.current_bytes != location.current_bytes { + return Err("Restored EXIF DateTimeOriginal did not verify".to_string()); + } + filetime::set_file_times(path, accessed, modified) + .map_err(|e| format!("Failed to restore file times: {e}"))?; + Ok(()) + })(); + return match rollback { + Ok(()) => Err(CaptureDateSourceWriteError::unchanged(format!( + "Failed to verify the in-place Capture Date update: {write_error}" + ))), + Err(rollback_error) => Err(CaptureDateSourceWriteError::uncertain(format!( + "Capture Date update failed ({write_error}) and rollback failed ({rollback_error})" + ))), + }; + } + + return Ok(()); + } + + let updated_bytes = build_updated_jpeg(path, &original_bytes, capture_date) + .map_err(CaptureDateSourceWriteError::unchanged)?; + let (recovery, backup_path) = create_source_recovery( + path, + expected_date, + capture_date, + source_written_before_rewrite, + accessed, + modified, + ) + .map_err(CaptureDateSourceWriteError::unchanged)?; + + let mut sidecar = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(path)); + let Some(capture_backup) = sidecar.capture_date_backup.as_mut() else { + let _ = fs::remove_file(&backup_path); + return Err(CaptureDateSourceWriteError::unchanged( + "Capture Date recovery state is missing from RapidRAW metadata", + )); + }; + capture_backup.pending_source_rewrite = Some(recovery.clone()); + if let Err(error) = save_capture_date_metadata(path, &sidecar) { + let _ = fs::remove_file(&backup_path); + return Err(CaptureDateSourceWriteError::unchanged(error)); + } + + let rewrite_result = rewrite_existing_file(path, &updated_bytes).and_then(|_| { + let written = + fs::read(path).map_err(|e| format!("Failed to verify {}: {e}", path.display()))?; + verify_jpeg_capture_date(path, &written, capture_date) + }); + if let Err(rewrite_error) = rewrite_result { + let restore_result = rewrite_existing_file(path, &original_bytes).and_then(|_| { + verify_jpeg_capture_date(path, &original_bytes, expected_date)?; + filetime::set_file_times(path, accessed, modified) + .map_err(|e| format!("Failed to restore file times: {e}")) + }); + return match restore_result { + Ok(()) => { + let _ = fs::remove_file(&backup_path); + let _ = clear_recovery_state(path, source_written_before_rewrite, expected_date); + Err(CaptureDateSourceWriteError::unchanged(format!( + "Failed to rewrite Capture Date: {rewrite_error}" + ))) + } + Err(restore_error) => Err(CaptureDateSourceWriteError::uncertain(format!( + "Capture Date rewrite failed ({rewrite_error}); recovery copy retained at {} because restoration failed ({restore_error})", + backup_path.display() + ))), + }; + } + + if let Err(error) = fs::remove_file(&backup_path) { + let restore_result = rewrite_existing_file(path, &original_bytes).and_then(|_| { + verify_jpeg_capture_date(path, &original_bytes, expected_date)?; + filetime::set_file_times(path, accessed, modified) + .map_err(|e| format!("Failed to restore file times: {e}")) + }); + return match restore_result { + Ok(()) => { + let _ = clear_recovery_state(path, source_written_before_rewrite, expected_date); + Err(CaptureDateSourceWriteError::unchanged(format!( + "Failed to remove recovery copy {}: {error}", + backup_path.display() + ))) + } + Err(restore_error) => Err(CaptureDateSourceWriteError::uncertain(format!( + "Failed to remove recovery copy {} ({error}) and could not restore the original ({restore_error})", + backup_path.display() + ))), + }; + } + if let Some(parent) = path.parent() { + if let Err(error) = sync_parent_directory(parent) { + log::warn!( + "Capture Date recovery cleanup for {} could not be synced: {}", + path.display(), + error + ); + } + } + if let Err(error) = clear_recovery_state(path, true, capture_date) { log::warn!( - "Capture Date was written to {}, but file times could not be restored: {}", + "EXIF was updated for {}, but recovery metadata could not be cleared: {}", path.display(), error ); @@ -734,6 +1392,9 @@ fn resolve_capture_date_operation( } => { let desired = normalized_capture_date(new_date)?; let (reference_path, _) = parse_virtual_path(reference_path); + if !paths.contains(&reference_path) { + return Err("The reference photo must be part of the selection".to_string()); + } let reference_metadata = exif_processing::load_sidecar( &exif_processing::get_primary_sidecar_path(&reference_path), ); @@ -767,10 +1428,13 @@ pub async fn get_capture_date_revert_availability( ) -> Result { tauri::async_runtime::spawn_blocking(move || { let physical_paths = capture_date_physical_paths(&paths); - capture_date_revert_availability_for_paths(&physical_paths) + for path in &physical_paths { + recover_pending_capture_date_rewrite(path)?; + } + Ok(capture_date_revert_availability_for_paths(&physical_paths)) }) .await - .map_err(|e| format!("Task failed: {e}")) + .map_err(|e| format!("Task failed: {e}"))? } #[tauri::command] @@ -781,6 +1445,9 @@ pub async fn update_capture_dates( ) -> Result { tauri::async_runtime::spawn_blocking(move || { let physical_paths = capture_date_physical_paths(&paths); + for path in &physical_paths { + recover_pending_capture_date_rewrite(path)?; + } let resolved = resolve_capture_date_operation(&physical_paths, &operation)?; if matches!(&resolved, ResolvedCaptureDateOperation::Revert) { let availability = capture_date_revert_availability_for_paths(&physical_paths); @@ -799,6 +1466,7 @@ pub async fn update_capture_dates( let mut metadata = exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&path)); let mut exif = current_exif_for_path(&path, &metadata); + let logical_date_before_operation = exif.get("DateTimeOriginal").cloned(); let new_date = match &resolved { ResolvedCaptureDateOperation::Revert => { @@ -847,6 +1515,8 @@ pub async fn update_capture_dates( .unwrap_or_else(|| value.clone()) }), source_written: false, + source_date_written: None, + pending_source_rewrite: None, }); } @@ -859,18 +1529,44 @@ pub async fn update_capture_dates( metadata.exif = Some(exif); if is_revert { - let should_write_source = metadata + let source_was_written = metadata .capture_date_backup .as_ref() .is_some_and(|backup| backup.source_written); - if should_write_source - && let Err(error) = write_jpeg_capture_date(&path, new_date.as_deref()) - { - failures.push(CaptureDateFailure { - path: path_string, - error, - }); - continue; + let mut wrote_original = false; + if source_was_written { + let source_exif = read_source_exif(&path); + let actual_source_date = source_exif.get("DateTimeOriginal"); + let original_source_date = metadata + .capture_date_backup + .as_ref() + .and_then(|backup| backup.date_time_original.as_ref()); + let source_already_reverted = normalized_optional_capture_date( + actual_source_date.map(String::as_str), + ) == normalized_optional_capture_date(original_source_date.map(String::as_str)); + + if !source_already_reverted { + let expected_date = metadata + .capture_date_backup + .as_ref() + .and_then(|backup| backup.source_date_written.as_deref()) + .or(logical_date_before_operation.as_deref()); + match write_jpeg_capture_date( + &path, + new_date.as_deref(), + expected_date, + true, + ) { + Ok(()) => wrote_original = true, + Err(error) => { + failures.push(CaptureDateFailure { + path: path_string, + error: error.message, + }); + continue; + } + } + } } metadata.capture_date_backup = None; @@ -885,13 +1581,33 @@ pub async fn update_capture_dates( updates.push(CaptureDateUpdate { path: path_string, new_date, - wrote_original: should_write_source, + wrote_original, + modified: if wrote_original { + file_modified_unix_seconds(&path) + } else { + None + }, source_error: None, }); continue; } let should_write_source = write_to_original; + let source_written_before_rewrite = metadata + .capture_date_backup + .as_ref() + .is_some_and(|backup| backup.source_written); + let source_date_written_before_rewrite = metadata + .capture_date_backup + .as_ref() + .and_then(|backup| backup.source_date_written.clone()); + let source_date_before_write = if should_write_source { + read_source_exif(&path) + .get("DateTimeOriginal") + .cloned() + } else { + None + }; if should_write_source && let Some(backup) = metadata.capture_date_backup.as_mut() { // Mark conservatively before touching the source so an interrupted or failed // write can still be safely restored by Revert. @@ -907,17 +1623,62 @@ pub async fn update_capture_dates( } let source_result = if should_write_source { - Some(write_jpeg_capture_date(&path, new_date.as_deref())) + Some(write_jpeg_capture_date( + &path, + new_date.as_deref(), + source_date_before_write.as_deref(), + source_written_before_rewrite, + )) } else { None }; - let wrote_original = matches!(source_result, Some(Ok(()))); - let source_error = source_result.and_then(|result| result.err()); + let wrote_original = matches!(&source_result, Some(Ok(_))); + let mut source_error = None; + match source_result { + Some(Ok(())) => { + if let Some(backup) = metadata.capture_date_backup.as_mut() { + backup.source_written = true; + backup.source_date_written = normalized_optional_capture_date(new_date.as_deref()); + } + if let Err(error) = save_capture_date_metadata(&path, &metadata) { + source_error = Some(format!( + "Capture Date was written to the original, but its recovery state could not be saved: {error}" + )); + } + } + Some(Err(error)) => { + source_error = Some(error.message); + if error.source_restored && !source_written_before_rewrite { + if let Some(backup) = metadata.capture_date_backup.as_mut() { + backup.source_written = false; + backup.source_date_written = None; + backup.pending_source_rewrite = None; + } + if let Err(save_error) = save_capture_date_metadata(&path, &metadata) { + source_error = Some(format!( + "{}; failed to update recovery state: {save_error}", + source_error.unwrap_or_default() + )); + } + } + if error.source_restored && source_written_before_rewrite { + if let Some(backup) = metadata.capture_date_backup.as_mut() { + backup.source_date_written = source_date_written_before_rewrite; + } + } + } + None => {} + } updates.push(CaptureDateUpdate { path: path_string, new_date, wrote_original, + modified: if wrote_original { + file_modified_unix_seconds(&path) + } else { + None + }, source_error, }); } @@ -932,6 +1693,62 @@ pub async fn update_capture_dates( mod capture_date_tests { use super::*; + fn push_u16(bytes: &mut Vec, value: u16, order: TiffByteOrder) { + bytes.extend(match order { + TiffByteOrder::Big => value.to_be_bytes(), + TiffByteOrder::Little => value.to_le_bytes(), + }); + } + + fn push_u32(bytes: &mut Vec, value: u32, order: TiffByteOrder) { + bytes.extend(match order { + TiffByteOrder::Big => value.to_be_bytes(), + TiffByteOrder::Little => value.to_le_bytes(), + }); + } + + fn jpeg_with_fixed_width_capture_date(order: TiffByteOrder, capture_date: &str) -> Vec { + let mut jpeg = Vec::new(); + DynamicImage::new_rgb8(2, 2) + .write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg) + .unwrap(); + + let mut tiff = Vec::new(); + tiff.extend(match order { + TiffByteOrder::Big => *b"MM", + TiffByteOrder::Little => *b"II", + }); + push_u16(&mut tiff, 42, order); + push_u32(&mut tiff, 8, order); + push_u16(&mut tiff, 1, order); + push_u16(&mut tiff, 0x8769, order); + push_u16(&mut tiff, 4, order); + push_u32(&mut tiff, 1, order); + push_u32(&mut tiff, 26, order); + push_u32(&mut tiff, 0, order); + push_u16(&mut tiff, 1, order); + push_u16(&mut tiff, 0x9003, order); + push_u16(&mut tiff, 2, order); + push_u32(&mut tiff, 20, order); + push_u32(&mut tiff, 44, order); + push_u32(&mut tiff, 0, order); + tiff.extend(capture_date.as_bytes()); + tiff.push(0); + assert_eq!(tiff.len(), 64); + + let segment_length = 2 + 6 + tiff.len(); + let mut app1 = vec![0xff, 0xe1]; + app1.extend((segment_length as u16).to_be_bytes()); + app1.extend(b"Exif\0\0"); + app1.extend(tiff); + + let mut result = Vec::with_capacity(jpeg.len() + app1.len()); + result.extend(&jpeg[..2]); + result.extend(app1); + result.extend(&jpeg[2..]); + result + } + fn create_test_jpeg(path: &Path, capture_date: &str) { let mut bytes = Vec::new(); DynamicImage::new_rgb8(2, 2) @@ -954,6 +1771,55 @@ mod capture_date_tests { shifted.format("%Y-%m-%d %H:%M:%S").to_string(), "2024-03-01 00:30:00" ); + + let shifted = start.checked_add_signed(Duration::hours(-22)).unwrap(); + assert_eq!( + shifted.format("%Y-%m-%d %H:%M:%S").to_string(), + "2024-02-28 01:30:00" + ); + } + + #[test] + fn locates_strict_fixed_width_capture_dates_in_both_tiff_byte_orders() { + for order in [TiffByteOrder::Little, TiffByteOrder::Big] { + let bytes = jpeg_with_fixed_width_capture_date(order, "2024:02:29 23:30:01"); + let location = locate_datetime_original(&bytes).unwrap().unwrap(); + assert_eq!(location.current_date, "2024:02:29 23:30:01"); + assert_eq!( + &bytes[location.offset..location.offset + 20], + b"2024:02:29 23:30:01\0" + ); + } + } + + #[test] + fn rejects_non_calendar_fixed_width_capture_dates() { + let bytes = + jpeg_with_fixed_width_capture_date(TiffByteOrder::Little, "2024:02:31 23:30:01"); + assert!(locate_datetime_original(&bytes).is_err()); + } + + #[test] + fn malformed_fixed_width_capture_date_is_not_rewritten() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("malformed-date.jpg"); + let original = + jpeg_with_fixed_width_capture_date(TiffByteOrder::Little, "2024:02:31 23:30:01"); + fs::write(&path, &original).unwrap(); + + let error = match write_jpeg_capture_date( + &path, + Some("2024-03-01 23:30:01"), + Some("2024:02:31 23:30:01"), + false, + ) { + Ok(_) => panic!("a malformed fixed-width date should be rejected"), + Err(error) => error, + }; + + assert!(error.source_restored); + assert!(error.message.contains("YYYY:MM:DD HH:MM:SS")); + assert_eq!(fs::read(path).unwrap(), original); } #[test] @@ -1018,11 +1884,28 @@ mod capture_date_tests { let directory = tempfile::tempdir().unwrap(); let image_path = directory.path().join("capture-date.jpg"); create_test_jpeg(&image_path, "2020:01:02 03:04:05"); + let original_bytes = fs::read(&image_path).unwrap(); + let date_location = locate_datetime_original(&original_bytes).unwrap().unwrap(); let original_modified = filetime::FileTime::from_unix_time(1_600_000_000, 0); filetime::set_file_mtime(&image_path, original_modified).unwrap(); - write_jpeg_capture_date(&image_path, Some("2023-04-05 06:07:08")).unwrap(); + write_jpeg_capture_date( + &image_path, + Some("2023-04-05 06:07:08"), + Some("2020-01-02 03:04:05"), + false, + ) + .unwrap(); let updated_bytes = fs::read(&image_path).unwrap(); + assert_eq!(updated_bytes.len(), original_bytes.len()); + assert!( + original_bytes + .iter() + .zip(&updated_bytes) + .enumerate() + .all(|(index, (before, after))| before == after + || (date_location.offset..date_location.offset + 20).contains(&index)) + ); image::load_from_memory_with_format(&updated_bytes, image::ImageFormat::Jpeg).unwrap(); let updated_exif = exif_processing::read_exif_data_from_bytes( image_path.to_str().unwrap(), @@ -1036,12 +1919,22 @@ mod capture_date_tests { updated_exif.get("Make").map(String::as_str), Some("RapidRAW test camera") ); - assert_eq!( + assert_ne!( filetime::FileTime::from_last_modification_time(&fs::metadata(&image_path).unwrap()), original_modified ); - write_jpeg_capture_date(&image_path, None).unwrap(); + let mut rapidraw_metadata = ImageMetadata::default(); + rapidraw_metadata.capture_date_backup = Some(CaptureDateBackup { + date_time_original: Some("2020-01-02 03:04:05".to_string()), + source_written: true, + source_date_written: Some("2023-04-05 06:07:08".to_string()), + pending_source_rewrite: None, + }); + save_capture_date_metadata(&image_path, &rapidraw_metadata).unwrap(); + let before_remove_modified = filetime::FileTime::from_unix_time(1_600_000_100, 0); + filetime::set_file_mtime(&image_path, before_remove_modified).unwrap(); + write_jpeg_capture_date(&image_path, None, Some("2023-04-05 06:07:08"), true).unwrap(); let reverted_bytes = fs::read(&image_path).unwrap(); image::load_from_memory_with_format(&reverted_bytes, image::ImageFormat::Jpeg).unwrap(); let reverted_exif = exif_processing::read_exif_data_from_bytes( @@ -1053,6 +1946,10 @@ mod capture_date_tests { reverted_exif.get("Make").map(String::as_str), Some("RapidRAW test camera") ); + assert_ne!( + filetime::FileTime::from_last_modification_time(&fs::metadata(&image_path).unwrap()), + before_remove_modified + ); } #[test] @@ -1064,8 +1961,43 @@ mod capture_date_tests { .write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Jpeg) .unwrap(); fs::write(&image_path, bytes).unwrap(); + let original_modified = filetime::FileTime::from_unix_time(1_600_000_000, 0); + filetime::set_file_mtime(&image_path, original_modified).unwrap(); + + #[cfg(unix)] + let original_inode = { + use std::os::unix::fs::MetadataExt; + fs::metadata(&image_path).unwrap().ino() + }; + #[cfg(target_os = "macos")] + unsafe { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let path = CString::new(image_path.as_os_str().as_bytes()).unwrap(); + let name = CString::new("com.rapidraw.capture-date-test").unwrap(); + let value = b"preserve-me"; + assert_eq!( + libc::setxattr( + path.as_ptr(), + name.as_ptr(), + value.as_ptr().cast(), + value.len(), + 0, + 0, + ), + 0 + ); + } - write_jpeg_capture_date(&image_path, Some("1985-07-04 12:30:00")).unwrap(); + let mut rapidraw_metadata = ImageMetadata::default(); + rapidraw_metadata.capture_date_backup = Some(CaptureDateBackup { + date_time_original: None, + source_written: false, + source_date_written: None, + pending_source_rewrite: None, + }); + save_capture_date_metadata(&image_path, &rapidraw_metadata).unwrap(); + write_jpeg_capture_date(&image_path, Some("1985-07-04 12:30:00"), None, false).unwrap(); let updated_bytes = fs::read(&image_path).unwrap(); image::load_from_memory_with_format(&updated_bytes, image::ImageFormat::Jpeg).unwrap(); let updated_exif = exif_processing::read_exif_data_from_bytes( @@ -1076,6 +2008,81 @@ mod capture_date_tests { updated_exif.get("DateTimeOriginal").map(String::as_str), Some("1985:07:04 12:30:00") ); + assert_ne!( + filetime::FileTime::from_last_modification_time(&fs::metadata(&image_path).unwrap()), + original_modified + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(fs::metadata(&image_path).unwrap().ino(), original_inode); + } + #[cfg(target_os = "macos")] + unsafe { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let path = CString::new(image_path.as_os_str().as_bytes()).unwrap(); + let name = CString::new("com.rapidraw.capture-date-test").unwrap(); + let mut value = [0u8; 11]; + let read = libc::getxattr( + path.as_ptr(), + name.as_ptr(), + value.as_mut_ptr().cast(), + value.len(), + 0, + 0, + ); + assert_eq!(read, value.len() as isize); + assert_eq!(&value, b"preserve-me"); + } + assert!( + fs::read_dir(directory.path()) + .unwrap() + .filter_map(Result::ok) + .all(|entry| !entry.file_name().to_string_lossy().ends_with(".rrrecover")) + ); + } + + #[test] + fn pending_full_rewrite_is_restored_from_recovery_copy() { + let directory = tempfile::tempdir().unwrap(); + let image_path = directory.path().join("interrupted.jpg"); + create_test_jpeg(&image_path, "2020:01:02 03:04:05"); + let backup_file_name = ".interrupted.jpg.test.capture-date.rrrecover"; + let backup_path = directory.path().join(backup_file_name); + fs::copy(&image_path, &backup_path).unwrap(); + + let mut changed = fs::read(&image_path).unwrap(); + let location = locate_datetime_original(&changed).unwrap().unwrap(); + changed[location.offset..location.offset + 20].copy_from_slice(b"2021:02:03 04:05:06\0"); + fs::write(&image_path, changed).unwrap(); + + let mut metadata = ImageMetadata::default(); + metadata.capture_date_backup = Some(CaptureDateBackup { + date_time_original: Some("2020-01-02 03:04:05".to_string()), + source_written: true, + source_date_written: Some("2021-02-03 04:05:06".to_string()), + pending_source_rewrite: Some(CaptureDateSourceRecovery { + backup_file_name: backup_file_name.to_string(), + original_date: Some("2020-01-02 03:04:05".to_string()), + intended_date: Some("2021-02-03 04:05:06".to_string()), + source_written_before_rewrite: false, + }), + }); + save_capture_date_metadata(&image_path, &metadata).unwrap(); + + assert!(recover_pending_capture_date_rewrite(&image_path).unwrap()); + assert!(!backup_path.exists()); + let restored = read_source_exif(&image_path); + assert_eq!( + restored.get("DateTimeOriginal").map(String::as_str), + Some("2020:01:02 03:04:05") + ); + let restored_metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); + let capture_backup = restored_metadata.capture_date_backup.unwrap(); + assert!(!capture_backup.source_written); + assert!(capture_backup.pending_source_rewrite.is_none()); } #[test] @@ -1100,12 +2107,18 @@ mod capture_date_tests { .unwrap(); assert_eq!(adjusted.updates.len(), 1); assert!(adjusted.updates[0].source_error.is_none()); + assert!(adjusted.updates[0].wrote_original); + assert!(adjusted.updates[0].modified.is_some()); let adjusted_metadata = exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); let backup = adjusted_metadata.capture_date_backup.as_ref().unwrap(); assert!(backup.date_time_original.is_none()); assert!(backup.source_written); + assert_eq!( + backup.source_date_written.as_deref(), + Some("1985-07-04 12:30:00") + ); let reverted = tauri::async_runtime::block_on(update_capture_dates( vec![path], @@ -1115,6 +2128,8 @@ mod capture_date_tests { .unwrap(); assert_eq!(reverted.updates.len(), 1); assert!(reverted.failures.is_empty()); + assert!(reverted.updates[0].wrote_original); + assert!(reverted.updates[0].modified.is_some()); let reverted_metadata = exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); @@ -1125,6 +2140,58 @@ mod capture_date_tests { ); } + #[test] + fn revert_does_not_overwrite_capture_date_changed_outside_rapidraw() { + let directory = tempfile::tempdir().unwrap(); + let image_path = directory.path().join("external-change.jpg"); + create_test_jpeg(&image_path, "2020:01:02 03:04:05"); + let path = image_path.to_string_lossy().to_string(); + + let adjusted = tauri::async_runtime::block_on(update_capture_dates( + vec![path.clone()], + CaptureDateOperation::Adjust { + reference_path: path.clone(), + new_date: "2021-02-03 04:05:06".to_string(), + }, + true, + )) + .unwrap(); + assert_eq!(adjusted.updates.len(), 1); + assert!(adjusted.updates[0].wrote_original); + + let mut externally_changed = fs::read(&image_path).unwrap(); + let location = locate_datetime_original(&externally_changed) + .unwrap() + .unwrap(); + externally_changed[location.offset..location.offset + 20] + .copy_from_slice(b"2022:03:04 05:06:07\0"); + fs::write(&image_path, externally_changed).unwrap(); + + let reverted = tauri::async_runtime::block_on(update_capture_dates( + vec![path], + CaptureDateOperation::Revert, + false, + )) + .unwrap(); + assert!(reverted.updates.is_empty()); + assert_eq!(reverted.failures.len(), 1); + assert!( + reverted.failures[0] + .error + .contains("changed outside RapidRAW") + ); + assert_eq!( + read_source_exif(&image_path) + .get("DateTimeOriginal") + .map(String::as_str), + Some("2022:03:04 05:06:07") + ); + + let metadata = + exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); + assert!(metadata.capture_date_backup.is_some()); + } + #[test] fn batch_adjust_uses_reference_delta_and_revert_restores_each_original() { let directory = tempfile::tempdir().unwrap(); @@ -1148,6 +2215,13 @@ mod capture_date_tests { .unwrap(); assert_eq!(result.updates.len(), 2); assert!(result.failures.is_empty()); + assert!(result.updates.iter().all(|update| !update.wrote_original)); + assert!( + result + .updates + .iter() + .all(|update| update.modified.is_none()) + ); let availability = tauri::async_runtime::block_on(get_capture_date_revert_availability(paths.clone())) @@ -1238,7 +2312,31 @@ mod capture_date_tests { } #[test] - fn failed_source_revert_retains_backup_for_retry() { + fn batch_adjust_rejects_reference_outside_selection() { + let directory = tempfile::tempdir().unwrap(); + let selected_path = directory.path().join("selected.jpg"); + let other_path = directory.path().join("other.jpg"); + create_test_jpeg(&selected_path, "2020:01:02 03:04:05"); + create_test_jpeg(&other_path, "2020:01:02 05:04:05"); + + let result = tauri::async_runtime::block_on(update_capture_dates( + vec![selected_path.to_string_lossy().to_string()], + CaptureDateOperation::Adjust { + reference_path: other_path.to_string_lossy().to_string(), + new_date: "2020-01-02 04:04:05".to_string(), + }, + false, + )); + let error = match result { + Ok(_) => panic!("an out-of-selection reference should be rejected"), + Err(error) => error, + }; + + assert_eq!(error, "The reference photo must be part of the selection"); + } + + #[test] + fn failed_initial_source_write_still_allows_sidecar_revert() { let directory = tempfile::tempdir().unwrap(); let image_path = directory.path().join("not-a-jpeg.png"); create_test_jpeg(&image_path, "2020:01:02 03:04:05"); @@ -1255,6 +2353,8 @@ mod capture_date_tests { .unwrap(); assert_eq!(adjusted.updates.len(), 1); assert!(adjusted.updates[0].source_error.is_some()); + assert!(!adjusted.updates[0].wrote_original); + assert!(adjusted.updates[0].modified.is_none()); let reverted = tauri::async_runtime::block_on(update_capture_dates( vec![path], @@ -1262,19 +2362,19 @@ mod capture_date_tests { false, )) .unwrap(); - assert!(reverted.updates.is_empty()); - assert_eq!(reverted.failures.len(), 1); + assert_eq!(reverted.updates.len(), 1); + assert!(reverted.failures.is_empty()); let metadata = exif_processing::load_sidecar(&exif_processing::get_primary_sidecar_path(&image_path)); - assert!(metadata.capture_date_backup.is_some()); + assert!(metadata.capture_date_backup.is_none()); assert_eq!( metadata .exif .as_ref() .and_then(|exif| exif.get("DateTimeOriginal")) .map(String::as_str), - Some("2021-02-03 04:05:06") + Some("2020-01-02 03:04:05") ); } } diff --git a/src-tauri/src/image_loader.rs b/src-tauri/src/image_loader.rs index 2e638a6f06..6e2b98a4b7 100644 --- a/src-tauri/src/image_loader.rs +++ b/src-tauri/src/image_loader.rs @@ -773,6 +773,8 @@ pub async fn load_image( let (source_path, sidecar_path) = parse_virtual_path(&path); let source_path_str = source_path.to_string_lossy().to_string(); + crate::file_management::recover_pending_capture_date_rewrite(&source_path)?; + let metadata: ImageMetadata = crate::exif_processing::load_sidecar(&sidecar_path); let settings = load_settings(app_handle.clone()).unwrap_or_default(); diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index a88db1edef..56653d5024 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -66,6 +66,19 @@ pub struct CaptureDateBackup { pub date_time_original: Option, #[serde(default)] pub source_written: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_date_written: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_source_rewrite: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CaptureDateSourceRecovery { + pub backup_file_name: String, + pub original_date: Option, + pub intended_date: Option, + #[serde(default)] + pub source_written_before_rewrite: bool, } impl Default for ImageMetadata { diff --git a/src/components/modals/AppModals.tsx b/src/components/modals/AppModals.tsx index fbf13a2b37..0680afdbbc 100644 --- a/src/components/modals/AppModals.tsx +++ b/src/components/modals/AppModals.tsx @@ -66,6 +66,7 @@ export default function AppModals(props: AppModalsProps) { isCopyPasteSettingsModalOpen, folderActionTarget, renameTargetPaths, + captureDateReferencePath, captureDateTargetPaths, importSourcePaths, isCreateAlbumModalOpen, @@ -90,6 +91,7 @@ export default function AppModals(props: AppModalsProps) { isCopyPasteSettingsModalOpen: state.isCopyPasteSettingsModalOpen, folderActionTarget: state.folderActionTarget, renameTargetPaths: state.renameTargetPaths, + captureDateReferencePath: state.captureDateReferencePath, captureDateTargetPaths: state.captureDateTargetPaths, importSourcePaths: state.importSourcePaths, isCreateAlbumModalOpen: state.isCreateAlbumModalOpen, @@ -122,10 +124,14 @@ export default function AppModals(props: AppModalsProps) { ); const imageList = useLibraryStore((state) => state.imageList); - const captureDateReferencePath = captureDateTargetPaths[0] || ''; + const resolvedCaptureDateReferencePath = + captureDateReferencePath && captureDateTargetPaths.includes(captureDateReferencePath) + ? captureDateReferencePath + : captureDateTargetPaths[0] || ''; const captureDateReferenceImage = - imageList.find((image) => image.path === captureDateReferencePath) || - (selectedImage?.path === captureDateReferencePath ? selectedImage : null); + imageList.find((image) => image.path === resolvedCaptureDateReferencePath) || + (selectedImage?.path === resolvedCaptureDateReferencePath ? selectedImage : null); + const captureDateReferenceName = resolvedCaptureDateReferencePath.split(/[\\/]/).pop()?.split('?vc=')[0] || ''; const physicalCaptureDateTargetPaths = Array.from( new Set(captureDateTargetPaths.map((path) => path.split('?vc=')[0])), ); @@ -312,8 +318,11 @@ export default function AppModals(props: AppModalsProps) { handleUpdateCaptureDates(captureDateTargetPaths, operation, writeToOriginal) } onCheckRevertAvailability={checkCaptureDateRevertAvailability} - onClose={() => setUI({ captureDateTargetPaths: [], isCaptureDateModalOpen: false })} - referencePath={captureDateReferencePath} + onClose={() => + setUI({ captureDateReferencePath: null, captureDateTargetPaths: [], isCaptureDateModalOpen: false }) + } + referenceName={captureDateReferenceName} + referencePath={resolvedCaptureDateReferencePath} targetCount={physicalCaptureDateTargetPaths.length} /> diff --git a/src/components/modals/CaptureDateModal.tsx b/src/components/modals/CaptureDateModal.tsx index 0777dc354c..35a6566bc7 100644 --- a/src/components/modals/CaptureDateModal.tsx +++ b/src/components/modals/CaptureDateModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { CalendarClock, Check } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; @@ -17,6 +17,7 @@ interface CaptureDateModalProps { onApply(operation: CaptureDateOperation, writeToOriginal: boolean): Promise; onCheckRevertAvailability(): Promise; onClose(): void; + referenceName: string; referencePath: string; targetCount: number; } @@ -28,6 +29,15 @@ function captureDateParts(value?: string) { return match ? { date: `${match[1]}-${match[2]}-${match[3]}`, time: `${match[4]}:${match[5]}:${match[6]}` } : null; } +function currentLocalDateParts() { + const now = new Date(); + const pad = (part: number) => String(part).padStart(2, '0'); + return { + date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`, + time: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, + }; +} + function shiftCaptureDate(value: string | undefined, seconds: number) { const parts = captureDateParts(value); if (!parts) return null; @@ -45,6 +55,7 @@ export default function CaptureDateModal({ onApply, onCheckRevertAvailability, onClose, + referenceName, referencePath, targetCount, }: CaptureDateModalProps) { @@ -52,30 +63,35 @@ export default function CaptureDateModal({ const [mode, setMode] = useState('adjust'); const [date, setDate] = useState(''); const [time, setTime] = useState('00:00:00'); - const [direction, setDirection] = useState<1 | -1>(1); - const [days, setDays] = useState(0); - const [hours, setHours] = useState(0); - const [minutes, setMinutes] = useState(0); - const [seconds, setSeconds] = useState(0); + const [days, setDays] = useState('0'); + const [hours, setHours] = useState('0'); + const [minutes, setMinutes] = useState('0'); + const [seconds, setSeconds] = useState('0'); const [writeToOriginal, setWriteToOriginal] = useState(false); const [isApplying, setIsApplying] = useState(false); const [canRevert, setCanRevert] = useState(false); const [isCheckingRevert, setIsCheckingRevert] = useState(false); + const dialogRef = useRef(null); useEffect(() => { if (!isOpen) return; - const parts = captureDateParts(currentDate); + const parts = captureDateParts(currentDate) || currentLocalDateParts(); setMode('adjust'); - setDate(parts?.date || ''); - setTime(parts?.time || '00:00:00'); - setDirection(1); - setDays(0); - setHours(0); - setMinutes(0); - setSeconds(0); + setDate(parts.date); + setTime(parts.time); + setDays('0'); + setHours('0'); + setMinutes('0'); + setSeconds('0'); setWriteToOriginal(false); }, [currentDate, isOpen]); + useEffect(() => { + if (!isOpen) return; + const frame = requestAnimationFrame(() => dialogRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [isOpen]); + useEffect(() => { if (!isOpen) return; let cancelled = false; @@ -96,7 +112,12 @@ export default function CaptureDateModal({ }; }, [isOpen, onCheckRevertAvailability]); - const shiftSeconds = direction * (days * 86400 + hours * 3600 + minutes * 60 + seconds); + const shiftValue = (value: string, max: number) => Math.max(-max, Math.min(max, Number.parseInt(value, 10) || 0)); + const shiftSeconds = + shiftValue(days, 99999) * 86400 + + shiftValue(hours, 23) * 3600 + + shiftValue(minutes, 59) * 60 + + shiftValue(seconds, 59); const preview = useMemo( () => (mode === 'adjust' ? (date ? `${date} ${time}` : null) : shiftCaptureDate(currentDate, shiftSeconds)), [currentDate, date, mode, shiftSeconds, time], @@ -105,7 +126,10 @@ export default function CaptureDateModal({ const parts = captureDateParts(currentDate); return parts ? `${parts.date} ${parts.time}` : null; }, [currentDate]); - const canApply = mode === 'adjust' ? Boolean(date && time && preview !== normalizedCurrentDate) : shiftSeconds !== 0; + const canApply = + mode === 'adjust' + ? Boolean(date && time && (!normalizedCurrentDate || preview !== normalizedCurrentDate)) + : shiftSeconds !== 0 && preview !== null; const reportResult = (result: CaptureDateBatchResult) => { const sourceFailures = result.updates.filter((update) => update.sourceError); @@ -169,7 +193,11 @@ export default function CaptureDateModal({ if (!isOpen) return null; - const numberInput = (label: string, value: number, setValue: (value: number) => void, max: number) => ( + const requestClose = () => { + if (!isApplying) onClose(); + }; + + const numberInput = (label: string, value: string, setValue: (value: string) => void, max: number) => (