Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ mod tests {
camera: None,
width: None,
height: None,
raw_relpath: None,
}
}

Expand Down
54 changes: 39 additions & 15 deletions src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +19,8 @@ pub struct Photo {
pub camera: Option<String>,
pub width: Option<i64>,
pub height: Option<i64>,
/// Same-stem DNG/RAW twin kept on disk but not shown as its own grid cell.
pub raw_relpath: Option<String>,
}

pub fn open(root: &Path, create: bool) -> Result<Connection> {
Expand Down Expand Up @@ -60,30 +62,43 @@ pub fn open(root: &Path, create: bool) -> Result<Connection> {
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<HashMap<String, (i64, i64)>> {
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<HashMap<String, (i64, i64, Option<String>)>> {
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<String>>(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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -142,6 +158,7 @@ pub fn apply_index_changes(
photo.camera,
photo.width,
photo.height,
photo.raw_relpath,
],
)?;
}
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -172,6 +190,7 @@ pub fn upsert_photo(conn: &Connection, photo: &Photo) -> Result<()> {
photo.camera,
photo.width,
photo.height,
photo.raw_relpath,
],
)?;
Ok(())
Expand All @@ -195,7 +214,7 @@ pub fn delete_under_prefix(conn: &Connection, dir_rel: &str) -> Result<usize> {

pub fn photos_in_album(conn: &Connection, album: &str) -> Result<Vec<Photo>> {
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",
)?;
Expand All @@ -210,6 +229,7 @@ pub fn photos_in_album(conn: &Connection, album: &str) -> Result<Vec<Photo>> {
camera: r.get(6)?,
width: r.get(7)?,
height: r.get(8)?,
raw_relpath: r.get(9)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
Expand Down Expand Up @@ -243,6 +263,7 @@ pub fn photo_from_file(
mtime: i64,
size: i64,
meta: PhotoMeta,
raw_relpath: Option<String>,
) -> Photo {
Photo {
relpath,
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -302,6 +324,7 @@ mod tests {
camera: None,
width: None,
height: None,
raw_relpath: None,
},
)
.unwrap();
Expand Down Expand Up @@ -332,6 +355,7 @@ mod tests {
camera: None,
width: None,
height: None,
raw_relpath: None,
},
)
.unwrap();
Expand Down
29 changes: 29 additions & 0 deletions src/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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();
Expand Down
51 changes: 50 additions & 1 deletion src/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -69,6 +69,7 @@ pub fn paths_to_unlink(root: &Path, rels: &[String]) -> Vec<PathBuf> {
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
}
Expand Down Expand Up @@ -124,6 +125,12 @@ pub(crate) fn live_motion_paths_for_still(still: &Path) -> Vec<PathBuf> {
out
}

pub(crate) fn raw_dng_paths_for_still(still: &Path) -> Vec<PathBuf> {
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()
Expand Down Expand Up @@ -213,6 +220,7 @@ mod tests {
camera: None,
width: None,
height: None,
raw_relpath: None,
}
}

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading