diff --git a/src/ai.rs b/src/ai.rs index ac3ddd8..9dfede9 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -404,6 +404,7 @@ mod tests { camera: None, width: None, height: None, + raw_relpath: None, } } diff --git a/src/catalog.rs b/src/catalog.rs index 8b40e59..5d6c113 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -8,7 +8,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use crate::library::album_paths; use crate::meta::PhotoMeta; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Photo { pub relpath: String, pub album: String, @@ -19,6 +19,8 @@ pub struct Photo { pub camera: Option, pub width: Option, pub height: Option, + /// Same-stem DNG/RAW twin kept on disk but not shown as its own grid cell. + pub raw_relpath: Option, } pub fn open(root: &Path, create: bool) -> Result { @@ -60,30 +62,43 @@ pub fn open(root: &Path, create: bool) -> Result { captured_at TEXT, camera TEXT, width INTEGER, - height INTEGER + height INTEGER, + raw_relpath TEXT ); CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album); CREATE INDEX IF NOT EXISTS idx_photos_captured ON photos(captured_at);", )?; } + migrate(&conn)?; Ok(conn) } -/// All `(relpath → (mtime, size))` rows in one query so bulk scans don't pay -/// a point-`SELECT` per file (N+1 reads on large libraries / slow mounts). -pub fn all_mtime_sizes(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT relpath, mtime, size FROM photos")?; +fn migrate(conn: &Connection) -> Result<()> { + let has_raw = conn + .prepare("SELECT raw_relpath FROM photos LIMIT 0") + .is_ok(); + if !has_raw { + conn.execute("ALTER TABLE photos ADD COLUMN raw_relpath TEXT", [])?; + } + Ok(()) +} + +/// All `(relpath → (mtime, size, raw_relpath))` rows in one query so bulk scans +/// don't pay a point-`SELECT` per file (N+1 reads on large libraries / slow mounts). +pub fn all_index_state(conn: &Connection) -> Result)>> { + let mut stmt = conn.prepare("SELECT relpath, mtime, size, raw_relpath FROM photos")?; let rows = stmt.query_map([], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, + r.get::<_, Option>(3)?, )) })?; let mut map = HashMap::new(); for row in rows { - let (rel, mtime, size) = row?; - map.insert(rel, (mtime, size)); + let (rel, mtime, size, raw) = row?; + map.insert(rel, (mtime, size, raw)); } Ok(map) } @@ -121,8 +136,8 @@ pub fn apply_index_changes( } for photo in photos { tx.execute( - "INSERT INTO photos (relpath, album, filename, mtime, size, captured_at, camera, width, height) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "INSERT INTO photos (relpath, album, filename, mtime, size, captured_at, camera, width, height, raw_relpath) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(relpath) DO UPDATE SET album=excluded.album, filename=excluded.filename, @@ -131,7 +146,8 @@ pub fn apply_index_changes( captured_at=excluded.captured_at, camera=excluded.camera, width=excluded.width, - height=excluded.height", + height=excluded.height, + raw_relpath=excluded.raw_relpath", params![ photo.relpath, photo.album, @@ -142,6 +158,7 @@ pub fn apply_index_changes( photo.camera, photo.width, photo.height, + photo.raw_relpath, ], )?; } @@ -151,8 +168,8 @@ pub fn apply_index_changes( pub fn upsert_photo(conn: &Connection, photo: &Photo) -> Result<()> { conn.execute( - "INSERT INTO photos (relpath, album, filename, mtime, size, captured_at, camera, width, height) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "INSERT INTO photos (relpath, album, filename, mtime, size, captured_at, camera, width, height, raw_relpath) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(relpath) DO UPDATE SET album=excluded.album, filename=excluded.filename, @@ -161,7 +178,8 @@ pub fn upsert_photo(conn: &Connection, photo: &Photo) -> Result<()> { captured_at=excluded.captured_at, camera=excluded.camera, width=excluded.width, - height=excluded.height", + height=excluded.height, + raw_relpath=excluded.raw_relpath", params![ photo.relpath, photo.album, @@ -172,6 +190,7 @@ pub fn upsert_photo(conn: &Connection, photo: &Photo) -> Result<()> { photo.camera, photo.width, photo.height, + photo.raw_relpath, ], )?; Ok(()) @@ -195,7 +214,7 @@ pub fn delete_under_prefix(conn: &Connection, dir_rel: &str) -> Result { pub fn photos_in_album(conn: &Connection, album: &str) -> Result> { let mut stmt = conn.prepare( - "SELECT relpath, album, filename, mtime, size, captured_at, camera, width, height + "SELECT relpath, album, filename, mtime, size, captured_at, camera, width, height, raw_relpath FROM photos WHERE album = ?1 ORDER BY COALESCE(captured_at, '') ASC, filename ASC", )?; @@ -210,6 +229,7 @@ pub fn photos_in_album(conn: &Connection, album: &str) -> Result> { camera: r.get(6)?, width: r.get(7)?, height: r.get(8)?, + raw_relpath: r.get(9)?, }) })?; Ok(rows.collect::>>()?) @@ -243,6 +263,7 @@ pub fn photo_from_file( mtime: i64, size: i64, meta: PhotoMeta, + raw_relpath: Option, ) -> Photo { Photo { relpath, @@ -254,6 +275,7 @@ pub fn photo_from_file( camera: meta.camera, width: meta.width.map(|w| w as i64), height: meta.height.map(|h| h as i64), + raw_relpath, } } @@ -302,6 +324,7 @@ mod tests { camera: None, width: None, height: None, + raw_relpath: None, }, ) .unwrap(); @@ -332,6 +355,7 @@ mod tests { camera: None, width: None, height: None, + raw_relpath: None, }, ) .unwrap(); diff --git a/src/clipboard.rs b/src/clipboard.rs index a33c2ce..c8d6f31 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -223,6 +223,8 @@ fn paste_file( .and_then(|name| name.to_str()) .ok_or_else(|| anyhow::anyhow!("could not paste {rel}"))?; let companions = delete::live_motion_paths_for_still(src); + let mut companions = companions; + companions.extend(delete::raw_dng_paths_for_still(src)); let (dest, companion_dests) = plan_dest(&dest_dir, filename, &companions)?; ensure_inside(root, &dest)?; transfer_file(src, &dest, cut)?; @@ -665,6 +667,33 @@ mod tests { assert!(root.join("Paris/photo-2.jpg").is_file()); } + #[test] + fn copy_keeps_still_and_dng_twin_in_both_albums() { + let (_tmp, root) = mini_library(); + write_still(&root.join("Rome/DSC_1.jpg")); + fs::write(root.join("Rome/DSC_1.DNG"), b"dng").unwrap(); + index::index_library(&root).unwrap(); + let clip = Clipboard { + op: ClipboardOp::Copy, + rels: vec!["Rome/DSC_1.jpg".into()], + }; + let result = paste(&root, &clip, "Paris").unwrap(); + assert_eq!(result.pasted(), vec!["Paris/DSC_1.jpg"]); + assert!(root.join("Rome/DSC_1.jpg").is_file()); + assert!(root.join("Rome/DSC_1.DNG").is_file()); + assert!(root.join("Paris/DSC_1.jpg").is_file()); + assert!(root.join("Paris/DSC_1.DNG").is_file()); + index::index_library(&root).unwrap(); + let conn = catalog::open(&root, false).unwrap(); + assert_eq!(catalog::count(&conn).unwrap(), 2); + let paris = catalog::photos_in_album(&conn, "Paris").unwrap(); + assert_eq!(paris.len(), 1); + assert_eq!( + paris[0].raw_relpath.as_deref(), + Some("Paris/DSC_1.DNG") + ); + } + #[test] fn copy_keeps_still_and_companion_in_both_albums() { let (_tmp, root) = mini_library(); diff --git a/src/delete.rs b/src/delete.rs index 0368156..3becad0 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -9,7 +9,7 @@ use walkdir::WalkDir; use crate::catalog::Photo; use crate::library::ALBUM_DIR; -use crate::media::{is_hidden, is_image, is_media_ext, VIDEO_EXTS}; +use crate::media::{self, is_hidden, is_image, is_media_ext, VIDEO_EXTS}; use crate::thumbs; pub fn delete_rels( @@ -69,6 +69,7 @@ pub fn paths_to_unlink(root: &Path, rels: &[String]) -> Vec { out.push(abs.clone()); out.push(thumbs::thumb_path(root, rel)); out.extend(live_motion_paths_for_still(&abs)); + out.extend(raw_dng_paths_for_still(&abs)); } out } @@ -124,6 +125,12 @@ pub(crate) fn live_motion_paths_for_still(still: &Path) -> Vec { out } +pub(crate) fn raw_dng_paths_for_still(still: &Path) -> Vec { + media::dng_twin_for_still(still) + .map(|path| vec![path.canonicalize().unwrap_or(path)]) + .unwrap_or_default() +} + fn check_rel(rel: &str) -> Result<()> { let path = Path::new(rel); let unsafe_rel = rel.is_empty() @@ -213,6 +220,7 @@ mod tests { camera: None, width: None, height: None, + raw_relpath: None, } } @@ -306,6 +314,47 @@ mod tests { .any(|p| p.starts_with(thumbs::thumbs_dir(dir.path())))); } + #[test] + fn paths_to_unlink_include_dng_twin() { + let dir = tempfile::tempdir().unwrap(); + let still = dir.path().join("Rome/DSC_1.jpg"); + fs::create_dir_all(still.parent().unwrap()).unwrap(); + fs::write(&still, b"still").unwrap(); + fs::write(dir.path().join("Rome/DSC_1.DNG"), b"dng").unwrap(); + let paths = paths_to_unlink(dir.path(), &["Rome/DSC_1.jpg".into()]); + assert!(paths.iter().any(|p| p.ends_with("Rome/DSC_1.jpg"))); + assert!(paths.iter().any(|p| p + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("DSC_1.DNG")))); + } + + #[test] + fn unlink_then_reindex_drops_still_and_dng_twin() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("library"); + let album = root.join("Rome"); + fs::create_dir_all(&album).unwrap(); + catalog::open(&root, true).unwrap(); + let still = album.join("DSC_1.jpg"); + RgbImage::from_pixel(32, 24, Rgb([10, 20, 30])) + .save(&still) + .unwrap(); + fs::write(album.join("DSC_1.DNG"), b"dng").unwrap(); + index::index_library(&root).unwrap(); + { + let conn = catalog::open(&root, false).unwrap(); + assert_eq!(catalog::count(&conn).unwrap(), 1); + } + + unlink_media(&root, &["Rome/DSC_1.jpg".into()]).unwrap(); + assert!(!still.exists()); + assert!(!album.join("DSC_1.DNG").exists()); + + index::index_library(&root).unwrap(); + let conn = catalog::open(&root, false).unwrap(); + assert_eq!(catalog::count(&conn).unwrap(), 0); + } + #[test] fn refuses_parent_dir_relpath() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/index.rs b/src/index.rs index b076404..4fc929c 100644 --- a/src/index.rs +++ b/src/index.rs @@ -134,7 +134,7 @@ fn index_library_inner(root: &Path, progress: Option<&CliProgress>) -> Result) -> Result) -> Result) -> Result { if let Err(e) = catalog::upsert_photo(&conn, &photo) { @@ -435,7 +446,7 @@ pub fn index_pasted( } fn build_photo( - _root: &Path, + root: &Path, abs: &Path, rel: &str, captured_at_fallback: Option<&str>, @@ -469,9 +480,18 @@ fn build_photo( mtime, size, photo_meta, + raw_relpath_for_still(root, abs), )) } +fn raw_relpath_for_still(root: &Path, still: &Path) -> Option { + media::dng_twin_for_still(still).and_then(|raw| { + raw.strip_prefix(root) + .ok() + .map(|p| p.to_string_lossy().replace('\\', "/")) + }) +} + fn mtime_epoch(meta: &fs::Metadata) -> Result { let d = meta .modified() @@ -611,4 +631,34 @@ mod tests { .iter() .any(|p| p.relpath == "Paris/a.jpg")); } + + #[test] + fn jpeg_dng_twins_index_as_one_photo() { + let (_tmp, root) = mini_library(); + write_still(&root.join("Rome/DSC_0001.jpg")); + std::fs::write(root.join("Rome/DSC_0001.DNG"), b"dng").unwrap(); + index_library(&root).unwrap(); + let conn = catalog::open(&root, false).unwrap(); + assert_eq!(catalog::count(&conn).unwrap(), 1); + let photos = catalog::photos_in_album(&conn, "Rome").unwrap(); + assert_eq!(photos.len(), 1); + assert_eq!(photos[0].relpath, "Rome/DSC_0001.jpg"); + assert_eq!( + photos[0].raw_relpath.as_deref(), + Some("Rome/DSC_0001.DNG") + ); + } + + #[test] + fn standalone_dng_indexes_without_raw_twin() { + let (_tmp, root) = mini_library(); + std::fs::write(root.join("Rome/orphan.DNG"), b"dng").unwrap(); + index_library(&root).unwrap(); + let conn = catalog::open(&root, false).unwrap(); + assert_eq!(catalog::count(&conn).unwrap(), 1); + let photos = catalog::photos_in_album(&conn, "Rome").unwrap(); + assert_eq!(photos.len(), 1); + assert_eq!(photos[0].relpath, "Rome/orphan.DNG"); + assert!(photos[0].raw_relpath.is_none()); + } } diff --git a/src/media.rs b/src/media.rs index 8979ce4..39c43ce 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,5 +1,5 @@ use std::env; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; pub const IMAGE_EXTS: &[&str] = &[ @@ -11,6 +11,11 @@ pub const VIDEO_EXTS: &[&str] = &["mov", "mp4"]; const LIVE_STILL_EXT_VARIANTS: &[&str] = &["heic", "HEIC", "heif", "HEIF", "jpg", "JPG", "jpeg", "JPEG"]; +/// Developed still extensions that pair with a same-stem DNG twin. +const JPEG_DNG_STILL_EXT_VARIANTS: &[&str] = &["jpg", "JPG", "jpeg", "JPEG"]; + +const DNG_EXT_VARIANTS: &[&str] = &["dng", "DNG"]; + pub fn is_image(path: &Path) -> bool { ext_in(path, IMAGE_EXTS) } @@ -99,6 +104,80 @@ fn probe_content_identifier(path: &Path) -> anyhow::Result> { } /// Same-stem still sibling (`.{heic,heif,jpg,jpeg}`), case variants on the extension. +/// True when `stem` names an AI edit sidecar (`{stem}-edited`, `{stem}-edited-N`). +pub fn is_edited_sidecar_stem(stem: &str) -> bool { + stem.ends_with("-edited") || stem.contains("-edited-") +} + +/// True when `path` is a JPEG with a same-stem DNG sibling in the same folder. +pub fn is_jpeg_dng_developed_still(path: &Path) -> bool { + if !is_jpeg_dng_still(path) { + return false; + } + dng_twin_for_still(path).is_some() +} + +/// True when `path` is a DNG that should not be indexed (JPEG twin exists). +pub fn is_dng_raw_companion(path: &Path) -> bool { + if !is_dng(path) { + return false; + } + jpeg_dng_still_for_raw(path).is_some() +} + +fn is_jpeg_dng_still(path: &Path) -> bool { + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + return false; + }; + if stem.is_empty() || is_edited_sidecar_stem(stem) { + return false; + } + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| JPEG_DNG_STILL_EXT_VARIANTS.iter().any(|v| ext == *v)) + .unwrap_or(false) +} + +/// Same-stem DNG sibling for a developed JPEG still, if present. +pub fn dng_twin_for_still(still: &Path) -> Option { + if !is_jpeg_dng_still(still) { + return None; + } + let parent = still.parent()?; + let stem = still.file_stem()?.to_str()?; + dng_at_stem(parent, stem) +} + +/// Same-stem JPEG sibling for a DNG raw file, if present. +pub fn jpeg_dng_still_for_raw(raw: &Path) -> Option { + let parent = raw.parent()?; + let stem = raw.file_stem()?.to_str()?; + if stem.is_empty() || is_edited_sidecar_stem(stem) { + return None; + } + jpeg_dng_still_at_stem(parent, stem) +} + +fn dng_at_stem(parent: &Path, stem: &str) -> Option { + for ext in DNG_EXT_VARIANTS { + let candidate = parent.join(format!("{stem}.{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn jpeg_dng_still_at_stem(parent: &Path, stem: &str) -> Option { + for ext in JPEG_DNG_STILL_EXT_VARIANTS { + let candidate = parent.join(format!("{stem}.{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + pub fn live_photo_companion_by_stem(path: &Path) -> bool { let Some(parent) = path.parent() else { return false; @@ -201,4 +280,49 @@ mod tests { &dir.path().join("img_1234.MOV") )); } + + #[test] + fn jpeg_dng_twins_pair_by_same_stem() { + let dir = tempfile::tempdir().unwrap(); + File::create(dir.path().join("DSC_0001.JPG")).unwrap(); + File::create(dir.path().join("DSC_0001.DNG")).unwrap(); + let jpg = dir.path().join("DSC_0001.JPG"); + let dng = dir.path().join("DSC_0001.DNG"); + assert!(is_jpeg_dng_developed_still(&jpg)); + assert!(is_dng_raw_companion(&dng)); + assert_eq!(dng_twin_for_still(&jpg).unwrap(), dng); + assert_eq!(jpeg_dng_still_for_raw(&dng).unwrap(), jpg); + } + + #[test] + fn jpeg_dng_skips_edited_sidecar_stems() { + let dir = tempfile::tempdir().unwrap(); + File::create(dir.path().join("DSC_0001-edited.jpg")).unwrap(); + File::create(dir.path().join("DSC_0001.DNG")).unwrap(); + let edited = dir.path().join("DSC_0001-edited.jpg"); + assert!(!is_jpeg_dng_developed_still(&edited)); + assert!(dng_twin_for_still(&edited).is_none()); + } + + #[test] + fn standalone_dng_is_not_a_companion() { + let dir = tempfile::tempdir().unwrap(); + File::create(dir.path().join("solo.DNG")).unwrap(); + assert!(!is_dng_raw_companion(&dir.path().join("solo.DNG"))); + } + + #[test] + fn standalone_dng_does_not_twin_with_itself() { + let dir = tempfile::tempdir().unwrap(); + let orphan = dir.path().join("orphan.DNG"); + File::create(&orphan).unwrap(); + assert!(dng_twin_for_still(&orphan).is_none()); + } + + #[test] + fn standalone_jpeg_without_dng_is_not_paired() { + let dir = tempfile::tempdir().unwrap(); + File::create(dir.path().join("solo.jpg")).unwrap(); + assert!(!is_jpeg_dng_developed_still(&dir.path().join("solo.jpg"))); + } } diff --git a/src/tui.rs b/src/tui.rs index ab1a4d7..87adb5a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2706,6 +2706,7 @@ mod tests { camera: None, width: None, height: None, + raw_relpath: None, } }