From 39d6a8d6683a5afcbcaed88ae151cf69dba2d3f3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:08:55 +0000 Subject: [PATCH] refactor(lightcraft-engine): split rename.rs into rename/ --- crates/engine/src/rename.rs | 1163 +-------------------------- crates/engine/src/rename/fs.rs | 242 ++++++ crates/engine/src/rename/moves.rs | 168 ++++ crates/engine/src/rename/plan.rs | 295 +++++++ crates/engine/src/rename/sidecar.rs | 174 ++++ crates/engine/src/rename/tokens.rs | 271 +++++++ 6 files changed, 1176 insertions(+), 1137 deletions(-) create mode 100644 crates/engine/src/rename/fs.rs create mode 100644 crates/engine/src/rename/moves.rs create mode 100644 crates/engine/src/rename/plan.rs create mode 100644 crates/engine/src/rename/sidecar.rs create mode 100644 crates/engine/src/rename/tokens.rs diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index b48f31291..8b165245b 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -32,6 +32,32 @@ use serde::Serialize; use crate::sidecar::SidecarNaming; use crate::{EngineError, Result, Session}; +mod fs; +mod moves; +mod plan; +mod sidecar; +mod tokens; + +pub(crate) use fs::{MoveFs, RealFs}; +#[cfg(test)] +use fs::{folded_path, same_contents}; +pub(crate) use moves::move_all; +#[cfg(test)] +pub(crate) use moves::move_file_with; +pub use moves::{MoveError, move_file}; +pub use plan::{plan_rename_photos, rename_photos}; +#[cfg(test)] +pub(crate) use sidecar::PacketVersion; +#[cfg(test)] +use sidecar::RetainedPacket; +pub(crate) use sidecar::{SidecarMove, retry_sidecars, validate_sidecars}; +#[cfg(test)] +use tokens::format_date; +pub use tokens::{ + DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, TokenHelp, expand, expand_folder, expand_tokens, expand_tokens_with, folder_template_error, + sample_photo, token_example, token_help_json, token_summary, unknown_tokens, +}; + /// One planned rename. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct RenamePlan { @@ -45,1142 +71,5 @@ pub struct RenamePlan { pub to_path: Option, } -fn split_ext(name: &str) -> (&str, &str) { - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => (stem, ext), - _ => (name, ""), - } -} - -fn sanitize(s: &str) -> String { - let t: String = - s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect(); - t.trim().trim_matches('.').trim().to_string() -} - -/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name -/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month -/// that doesn't parse gives nothing. -fn format_date(iso: &str, fmt: &str) -> String { - let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); - let month = iso.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); - let mut out = String::new(); - let mut it = fmt.chars(); - while let Some(c) = it.next() { - if c != '%' { - out.push(c); - continue; - } - match it.next() { - Some('Y') => out.push_str(&part(0, 4)), - Some('y') => out.push_str(&part(2, 4)), - Some('m') => out.push_str(&part(5, 7)), - Some('B') => out.push_str(month), - Some('b') => out.push_str(month.get(..3).unwrap_or(month)), - Some('d') => out.push_str(&part(8, 10)), - Some('H') => out.push_str(&part(11, 13)), - Some('M') => out.push_str(&part(14, 16)), - Some('S') => out.push_str(&part(17, 19)), - Some('%') => out.push('%'), - Some(o) => { - out.push('%'); - out.push(o); - } - None => out.push('%'), - } - } - out -} - -/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and -/// command description shows; a test checks that [`expand_tokens`] knows each of them. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -pub struct TokenHelp { - /// The token as typed, e.g. `{seq:3}`. - pub tag: &'static str, - /// Other spellings with the same meaning. - pub aliases: &'static [&'static str], - pub meaning: &'static str, -} - -/// Every template token, in the order the help shows them. -pub const TOKENS: &[TokenHelp] = &[ - TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, - TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, - TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, - TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, - TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, - TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, - TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, - TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, - TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, - TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, - TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, - TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, - TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, - TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, -]; - -/// The `%` directives of `{date:…}`. -pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ - ("%Y", "year, 4 digits"), - ("%y", "year, 2 digits"), - ("%m", "month 01–12"), - ("%B", "month name (January)"), - ("%b", "short month name (Jan)"), - ("%d", "day 01–31"), - ("%H", "hour 00–23"), - ("%M", "minute"), - ("%S", "second"), - ("%%", "a literal %"), -]; - -/// How templates behave, one sentence each (shown under the token list). -pub const TEMPLATE_NOTES: &[&str] = &[ - "The original extension is always added; {ext} only puts it inside the name as well.", - "A blank template keeps the original names.", - "{date} is the capture time; a photo without one uses the time it was imported.", - "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", - "Unknown tags stay as typed — check the preview for a {typo}.", - "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", -]; - -/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. -pub fn token_summary() -> String { - TOKENS.iter().map(|t| t.tag).collect::>().join(" ") -} - -/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). -pub fn sample_photo() -> Photo { - let mut p = - Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); - p.captured = Some("2026-01-14T05:58:48".into()); - p.rating = 4; - p.meta.camera = "Canon EOS R5".into(); - p.meta.lens = "RF24-70mm F2.8".into(); - p.meta.iso = Some(400); - p.meta.title = "Harbour".into(); - p.meta.creator = "Ann Lee".into(); - p -} - -/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. -pub fn token_example(tag: &str) -> String { - expand_tokens(tag, &sample_photo(), 1, 1) -} - -/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be -/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels. -pub fn folder_template_error(template: &str) -> Option { - let t = template.trim(); - if t.is_empty() { - return Some("the folder template is empty".into()); - } - let b = t.as_bytes(); - if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') { - return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into()); - } - if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) { - return Some("the folder template may not contain . or .. folders".into()); - } - None -} - -/// The folders (relative to the destination) a folder template gives `p`: the template's own `/` -/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder -/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and -/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty -/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s -/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped. -pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec { - template - .split(['/', '\\']) - .map(str::trim) - .filter(|s| !s.is_empty() && !matches!(*s, "." | "..")) - .map(|s| { - let v = sanitize(&expand_tokens(s, p, seq, 1)); - if v.is_empty() { "unknown".to_string() } else { v } - }) - .collect() -} - -/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. -pub fn unknown_tokens(template: &str) -> Vec { - let p = sample_photo(); - let mut out = Vec::new(); - let mut rest = template; - while let Some(i) = rest.find('{') { - let Some(j) = rest[i..].find('}') else { break }; - let tag = &rest[i..i + j + 1]; - if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { - out.push(tag.to_string()); - } - rest = &rest[i + j + 1..]; - } - out -} - -/// Every token with its meaning and example, the date directives and the notes, as JSON (the -/// `photo.renameTokens` command). -pub fn token_help_json() -> serde_json::Value { - let tokens: Vec = TOKENS - .iter() - .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) - .collect(); - let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); - serde_json::json!({ - "tokens": tokens, - "dateDirectives": directives, - "notes": TEMPLATE_NOTES, - "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", - }) -} - -/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. -pub fn expand(template: &str, p: &Photo, seq: usize) -> String { - let (stem, ext) = split_ext(&p.file_name); - let out = expand_tokens(template, p, seq, 1); - let mut stem_out = sanitize(&out); - if stem_out.is_empty() { - stem_out = sanitize(stem); - } - if stem_out.is_empty() { - stem_out = "photo".into(); - } - if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") } -} - -/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` -/// is zero-padded to `seq_width` digits. -pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { - expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) -} - -/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text -/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename -/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. -pub fn expand_tokens_with( - template: &str, - p: &Photo, - seq: usize, - seq_width: usize, - extra: &dyn Fn(&str, Option<&str>, &str) -> Option, -) -> String { - let (stem, ext) = split_ext(&p.file_name); - let mut out = String::new(); - let mut rest = template; - while let Some(i) = rest.find('{') { - out.push_str(&rest[..i]); - let Some(j) = rest[i..].find('}') else { - out.push_str(&rest[i..]); - rest = ""; - break; - }; - let tok = &rest[i + 1..i + j]; - let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); - let lower = name.trim().to_ascii_lowercase(); - let v = match extra(&lower, arg, tok) { - Some(v) => v, - None => match lower.as_str() { - "name" | "filename" => stem.to_string(), - "num" => { - let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); - stem[stem.len() - digits..].to_string() - } - "seq" | "n" => { - let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); - format!("{seq:0w$}") - } - "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), - "folder" => match &p.source { - Source::File { path } => { - Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() - } - Source::Demo { .. } => String::new(), - }, - "camera" => p.meta.camera.clone(), - "lens" => p.meta.lens.clone(), - "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), - "rating" => p.rating.to_string(), - "title" => p.meta.title.clone(), - "creator" => p.meta.creator.clone(), - "ext" => ext.to_string(), - _ => format!("{{{tok}}}"), - }, - }; - out.push_str(&v); - rest = &rest[i + j + 1..]; - } - out.push_str(rest); - out -} - -/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). -fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { - let mut v = Vec::new(); - for naming in [SidecarNaming::Stem, SidecarNaming::Full] { - let p = crate::sidecar::sidecar_path(path, naming); - v.push((p.with_extension("XMP"), naming, true)); - v.push((p, naming, false)); - } - v -} - -/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of -/// a case-insensitive or case-sensitive volume (and inject failures). -pub(crate) trait MoveFs { - /// Something (file, folder, link) answers to `p`. - fn exists(&self, p: &Path) -> bool; - fn is_file(&self, p: &Path) -> bool; - /// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive - /// volume). Decided by file identity, never by comparing names: on a case-sensitive volume - /// they are two different photos. When in doubt: `false`. - fn same_file(&self, a: &Path, b: &Path) -> bool; - /// Rename `a` to `b`, failing (never replacing) when `b` exists. - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; - /// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists. - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; - fn remove_file(&self, p: &Path) -> std::io::Result<()>; - /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. - /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. - fn stem_sibling(&self, sidecar: &Path) -> Option; - /// The folder lists an entry spelled exactly like `p` (letter case included). On a - /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two - /// apart. When the folder can't be listed: `true`, so nothing is skipped. - fn listed(&self, p: &Path) -> bool { - let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; - let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; - match std::fs::read_dir(dir) { - Ok(entries) => entries.flatten().any(|e| e.file_name() == name), - Err(_) => true, - } - } - /// Content observed in a packet, independent of its pathname or inode. Model volumes - /// override this; disk implementations stream a bounded buffer without retaining the XML. - fn packet_version(&self, path: &Path) -> std::io::Result { - use std::io::Read; - let mut file = std::fs::File::open(path)?; - let mut hash = lightcraft_preview::Hasher128::new(); - let mut len = 0u64; - let mut buffer = [0u8; 16 * 1024]; - loop { - let n = match file.read(&mut buffer) { - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - result => result?, - }; - if n == 0 { - return Ok(PacketVersion { len, hash: hash.finish() }); - } - let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?; - len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?; - hash.update(bytes); - } - } -} - -/// The real file system. -pub(crate) struct RealFs; - -impl MoveFs for RealFs { - fn exists(&self, p: &Path) -> bool { - std::fs::symlink_metadata(p).is_ok() - } - - fn is_file(&self, p: &Path) -> bool { - p.is_file() - } - - fn same_file(&self, a: &Path, b: &Path) -> bool { - if a == b { - return true; - } - let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false }; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - ma.dev() == mb.dev() && ma.ino() == mb.ino() - } - #[cfg(not(unix))] - { - // no stable file id in std here: `a` and `b` are one file under two spellings when - // they look alike and the folder doesn't list both (on a case-sensitive folder - // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the - // listed one: the answer must not depend on the argument order. - let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; - let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); - if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { - return false; - } - let Ok(entries) = std::fs::read_dir(dir) else { return false }; - let (mut listed_a, mut listed_b) = (false, false); - for e in entries.flatten() { - let name = e.file_name(); - listed_a |= name == name_a; - listed_b |= name == name_b; - } - !(listed_a && listed_b) - } - } - - fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); - } - // A hard link fails when `b` exists — even if it appeared since the check above — so - // nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back - // to a plain rename after the check. - match std::fs::hard_link(a, b) { - Ok(()) => std::fs::remove_file(a).inspect_err(|_| { - // both names are the same file: dropping the new one loses nothing - let _ = std::fs::remove_file(b); - }), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), - Err(_) => std::fs::rename(a, b) - .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), - } - } - - fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.exists(b) { - return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); - } - // Streamed (a large raw or video is never read into memory whole), into a hidden name - // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so - // the partial file can't be removed either, leaves no truncated file under the photo's - // name for a retry to take for the photo. - let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't - // block the next - let partial = (0..1000u32) - .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) - .find(|p| !self.exists(p)) - .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; - let copied = (|| { - let mut from = std::fs::File::open(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; - std::io::copy(&mut from, &mut f)?; - f.sync_all() - })(); - let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); - if placed.is_err() { - let _ = std::fs::remove_file(&partial); - } - placed - } - - fn remove_file(&self, p: &Path) -> std::io::Result<()> { - std::fs::remove_file(p) - } - - fn stem_sibling(&self, sidecar: &Path) -> Option { - crate::import_move::sibling_photo(sidecar) - } -} - -/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original -/// byte for byte, and only then remove the original. A copy that doesn't match is removed. -fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { - fs.copy_no_replace(a, b)?; - if !same_contents(a, b).unwrap_or(false) { - let _ = std::fs::remove_file(b); - return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); - } - std::fs::remove_file(a).inspect_err(|_| { - // the original stays: drop the copy so the file isn't in two places - let _ = std::fs::remove_file(b); - }) -} - -/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so -/// a large raw or video is never read into memory whole. -fn same_contents(a: &Path, b: &Path) -> std::io::Result { - const CHUNK: usize = 1 << 20; - let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); - if fa.metadata()?.len() != fb.metadata()?.len() { - return Ok(false); - } - let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); - loop { - let n = read_full(&mut fa, &mut ba)?; - let m = read_full(&mut fb, &mut bb)?; - if n != m || ba.get(..n) != bb.get(..m) { - return Ok(false); - } - if n == 0 { - return Ok(true); - } - } -} - -/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer -/// only at the end of the file). -fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { - let mut filled = 0; - while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { - match r.read(rest) { - Ok(0) => break, - Ok(k) => filled = filled.saturating_add(k), - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } - Ok(filled) -} - -/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive -/// volume): the rename must go through a temporary name. -fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { - from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to) -} - -/// Windows accepts both separators; spelling differences must not hide file identity. -fn folded_path(path: &str) -> String { - if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() } -} - -/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a -/// unique temporary name so case-insensitive volumes see a real change. -fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> { - if !is_respelling(fs, a, b) { - return fs.rename_no_replace(a, b); - } - let tmp = (0..1000u32) - .map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id()))) - .find(|t| !fs.exists(t)) - .ok_or_else(|| std::io::Error::other("no free temporary name"))?; - fs.rename_no_replace(a, &tmp)?; - fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) { - Ok(()) => e, - Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())), - }) -} - -/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may -/// already have followed its new path, so a retry cannot infer this work from the catalog. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct SidecarMove { - pub from: PathBuf, - pub to: PathBuf, - copied: bool, - /// Observed packets in the partial move, including the separated source. Absent planned - /// destinations are never owned; a completed repair advances these observations. - companions: Vec, -} - -/// A content version, not an assertion that a pathname still names the same packet. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct PacketVersion { - len: u64, - hash: lightcraft_preview::Hash128, -} - -impl PacketVersion { - fn of(bytes: &[u8]) -> Self { - Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) } - } -} - -#[derive(Clone, Debug, PartialEq)] -struct RetainedPacket { - path: PathBuf, - // An unreadable observation remains untrusted even if a later read succeeds. - version: Option, -} - -impl RetainedPacket { - fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { - if self.version.is_none() { - return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display())); - } - let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?; - if self.version != Some(current) { - return Err(format!( - "retained XMP {} changed since the partial move; packet ownership must be resolved before retrying", - self.path.display() - )); - } - Ok(()) - } -} - -/// Advance only packets whose operation actually succeeded. A copy retains its original; -/// a move retires vanished source aliases and adds the observed destination. -fn advance_packets(packets: &mut Vec, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) { - let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return }; - if !copied { - packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path)); - } - packet.path = to.to_path_buf(); - if !packets.iter().any(|p| p.path == packet.path) { - packets.push(packet); - } -} - -fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec { - packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect() -} - -impl SidecarMove { - fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { - for packet in &self.companions { - packet.validate(fs)?; - } - Ok(()) - } - - /// Validate a queued retry and return the copy decision for this physical operation. - fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result { - self.validate_packets(fs)?; - let respelling = is_respelling(fs, &self.from, &self.to); - // Batch rollback may have restored another photo using this source packet. - let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some()); - if fs.exists(&self.to) && (copied || !respelling) { - return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display())); - } - Ok(copied) - } - - fn needed(&self, fs: &dyn MoveFs) -> bool { - fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to))) - } - - fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool { - let same_path = |endpoint: &Path| { - endpoint == path - || fs.same_file(endpoint, path) - || (endpoint.file_name() == path.file_name() - && endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b))) - }; - same_path(&self.from) || same_path(&self.to) - } - - fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> { - if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) } - } -} - -/// Complete known unfinished sidecars before moving any more photos. Completed operations -/// leave the queue; a failure retains that operation and the rest, without replacing a file. -pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec) -> std::result::Result<(), String> { - validate_sidecars(fs, pending)?; - while !pending.is_empty() { - apply_repair(fs, pending, 0)?; - } - Ok(()) -} - -/// Preflight a detached history entry before global queues are allowed to retire. The -/// caller can validate without mutating it; retry revalidates before each physical operation. -pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> { - for repair in pending { - repair.validate_repair(fs)?; - } - Ok(()) -} - -/// Finish one operation and transfer its observed content to the remaining repairs. Do not -/// infer ownership by reading an occupied destination or by retaining a planned pathname. -fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec, i: usize) -> std::result::Result<(), String> { - let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) }; - repair.copied = repair.validate_repair(fs)?; - let aliases = packet_aliases(&repair.companions, fs, &repair.from); - repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?; - for remaining in pending.iter_mut() { - advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs); - } - pending.remove(i); - Ok(()) -} - -/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals -/// must not block healthy metadata saves. Completed repairs retire; failures stay queued. -pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec, paths: &[PathBuf]) -> std::result::Result<(), String> { - for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) { - repair.validate_repair(fs)?; - } - let mut i = 0; - while let Some(m) = pending.get(i) { - if paths.iter().any(|path| m.touches(fs, path)) { - // Path/alias equality only locates an observation; validation above proves its - // content. A newly occupied, previously absent peer destination is a collision. - if let Some(path) = paths.iter().find(|path| { - fs.exists(path) - && !fs.same_file(path, &m.from) - && !fs.same_file(path, &m.to) - && !m.companions.iter().any(|known| fs.same_file(path, &known.path)) - }) { - return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display())); - } - apply_repair(fs, pending, i)?; - } else { - i += 1; - } - } - Ok(()) -} - -/// Every record came from an actual packet observed before this move. Successful physical -/// operations have already advanced its path; missing planned destinations are not recorded. -fn note_companions(pending: &mut [SidecarMove], packets: Vec) { - for repair in pending { - repair.companions.clone_from(&packets); - } -} - -/// Why [`move_file`] failed, and what it left behind. -#[derive(Clone, Debug, PartialEq)] -pub struct MoveError { - /// What went wrong, including every step that could not be undone and where that file is now. - pub message: String, - /// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file - /// back failed too. Its sidecars that did move stay with it. - pub moved: bool, - pub(crate) pending_sidecars: Vec, -} - -impl std::fmt::Display for MoveError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the -/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]). -pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> { - move_file_with(&RealFs, from, to) -} - -/// [`move_file`] on the file system `fs`. -pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> { - if Path::new(from) == Path::new(to) { - return Ok(()); - } - let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() }; - let (f, t) = (Path::new(from), Path::new(to)); - // `to` may be this very file spelled differently (case-insensitive volume); any other file - // there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced - if fs.exists(t) && !is_respelling(fs, f, t) { - return Err(fail(format!("{to} already exists"))); - } - // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the - // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. - let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); - let mut packets: Vec = - sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); - move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; - let mut done: Vec = Vec::new(); - let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { - let mut dst = crate::sidecar::sidecar_path(to, naming); - if upper { - dst = dst.with_extension("XMP"); - } - // A stem sidecar another photo still uses is copied, not taken away (issue #92). - let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); - SidecarMove { from: sc, to: dst, copied, companions: Vec::new() } - }); - while let Some(m) = remaining.next() { - if !m.needed(fs) { - continue; // never overwrite: the old sidecar stays where it was - } - let aliases = packet_aliases(&packets, fs, &m.from); - if let Err(e) = m.apply(fs) { - let mut message = format!("rename {}: {e}", m.from.display()); - // The file first: if it can't go back, sidecars that moved stay with it. - if let Err(e) = move_one(fs, t, f) { - message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}")); - if !done.is_empty() { - let names: Vec = done.iter().map(|m| m.to.display().to_string()).collect(); - message.push_str(&format!(" with its sidecar {}", names.join(", "))); - } - let mut pending_sidecars = vec![m]; - for m in remaining.filter(|m| m.needed(fs)) { - // The two case spellings can name one file. Keep one operation for - // those aliases, while retaining distinct sidecars on sensitive volumes. - let alias = pending_sidecars.iter().any(|p| { - folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy()) - && folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy()) - && fs.same_file(&p.from, &m.from) - }); - if !alias { - pending_sidecars.push(m); - } - } - note_companions(&mut pending_sidecars, packets); - return Err(MoveError { message, moved: true, pending_sidecars }); - } - let mut pending_sidecars = Vec::new(); - for m in done.iter().rev() { - if m.copied { - // The original is still there: a leftover copy loses nothing. - if fs.remove_file(&m.to).is_ok() { - packets.retain(|p| p.path != m.to); - } - } else { - let aliases = packet_aliases(&packets, fs, &m.to); - if let Err(e) = move_one(fs, &m.to, &m.from) { - message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display())); - pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() }); - } else { - advance_packets(&mut packets, &aliases, &m.from, false, fs); - } - } - } - note_companions(&mut pending_sidecars, packets); - return Err(MoveError { message, moved: false, pending_sidecars }); - } - advance_packets(&mut packets, &aliases, &m.to, m.copied, fs); - done.push(m); - } - Ok(()) -} - -/// What a failed batch of moves ([`move_all`]) left behind. -#[derive(Debug)] -pub(crate) struct BatchMoveError { - /// The first failure and every move that could not be undone. - pub message: String, - /// Moves that stayed done, (from, to): those files are at `to` now. - pub stuck: Vec<(String, String)>, - /// Sidecars still separated from the path where their photo ended up, in either direction. - pub pending_sidecars: Vec, -} - -/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone. -/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored. -pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> { - let mut done: Vec<&(String, String)> = Vec::new(); - for m in moves { - let Err(e) = move_file_with(fs, &m.0, &m.1) else { - done.push(m); - continue; - }; - let mut message = e.message; - let mut pending_sidecars = e.pending_sidecars; - let mut stuck = Vec::new(); - if e.moved { - stuck.push(m.clone()); - } - for d in done.iter().rev() { - if let Err(mut e) = move_file_with(fs, &d.1, &d.0) { - message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message)); - if !e.moved { - stuck.push((*d).clone()); - } - pending_sidecars.append(&mut e.pending_sidecars); - } - } - return Err(BatchMoveError { message, stuck, pending_sidecars }); - } - Ok(()) -} - -/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward. -fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec) { - let mut pending = vec![op]; - let mut order = Vec::new(); - let mut final_ops = HashMap::new(); - while let Some(op) = pending.pop() { - match op { - Op::Batch { ops } => pending.extend(ops.iter().rev()), - Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id), - _ => {} - } - } - out.extend(order.into_iter().filter_map(|id| { - let op = final_ops.get(&id).copied()?; - matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone()) - })); -} - -/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) -/// rename once. No file-system calls. -pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec> { - let mut seen_paths: HashSet = HashSet::new(); - ids.iter() - .filter_map(|id| cat.photo(*id)) - .filter(|p| match &p.source { - Source::File { path } => seen_paths.insert(path.clone()), - _ => true, - }) - .cloned() - .collect() -} - -/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check -/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow -/// drive). Whether a case-only change names this very file is decided on the disk. -pub fn plan_rename_photos(photos: &[std::sync::Arc], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec { - plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b)) -} - -/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether -/// two spellings name one file (see [`MoveFs`]). -fn plan_rename_core( - photos: &[std::sync::Arc], - template: &str, - start: usize, - exists: &dyn Fn(&Path) -> bool, - same_file: &dyn Fn(&Path, &Path) -> bool, -) -> Vec { - let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch - let mut out = Vec::new(); - for (i, p) in photos.iter().enumerate() { - let seq = start.saturating_add(i); - let want = expand(template, p, seq); - let (stem, ext) = split_ext(&want); - let (stem, ext) = (stem.to_string(), ext.to_string()); - let candidate = |k: usize| { - if k == 0 { - want.clone() - } else if ext.is_empty() { - format!("{stem}-{k}") - } else { - format!("{stem}-{k}.{ext}") - } - }; - let (to, to_path) = match &p.source { - Source::File { path } => { - let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); - let mut k = 0; - loop { - let name = candidate(k); - let tp = dir.join(&name).to_string_lossy().to_string(); - let key = folded_path(&tp); - // this very file, maybe spelled differently (case-insensitive volume)? By - // identity: on a case-sensitive volume `img_1.jpg` may be another photo - let (from, to) = (Path::new(path), Path::new(&tp)); - let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to)); - // free: not claimed in this batch and not on disk (unless it is this very file). - // A file this batch moves away still counts as taken: simple and safe. - if !taken.contains(&key) && (same || !exists(to)) { - taken.insert(key); - break (name, Some(tp)); - } - k += 1; - } - } - Source::Demo { .. } => { - let mut k = 0; - loop { - let name = candidate(k); - if taken.insert(format!("demo:{}", name.to_lowercase())) { - break (name, None); - } - k += 1; - } - } - }; - let from_path = match &p.source { - Source::File { path } => Some(path.clone()), - _ => None, - }; - out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path }); - } - out -} - -impl Session { - /// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions. - pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec { - self.plan_rename_with(&RealFs, ids, template, start) - } - - /// [`Session::plan_rename`] on the file system `fs`. - pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec { - plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b)) - } - - /// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op - /// that updates the photos (and their virtual copies). - /// - /// When a failure can't be fully rolled back, the files that kept their new names are - /// committed as a smaller undoable rename, so the library points at them (nothing goes - /// missing), and the error lists them (old → new). - pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result { - self.apply_rename_with(&RealFs, plans) - } - - /// Complete retained repairs before a file or folder change can stale their exact paths. - pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> { - for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { - repair.validate_repair(fs).map_err(EngineError::Other)?; - } - retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?; - for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { - retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?; - } - Ok(()) - } - - pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { - self.validate_sidecar_packets_for_paths(paths, fs)?; - retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?; - for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { - retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?; - } - Ok(()) - } - - /// A healthy shared packet may be saved while another photo's repair is unavailable. - /// Check its retained content too, without forcing that unrelated physical operation. - pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { - for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { - if paths.iter().any(|path| repair.touches(fs, path)) { - repair.validate_repair(fs).map_err(EngineError::Other)?; - } else { - for packet in &repair.companions { - if paths.iter().any(|path| fs.same_file(path, &packet.path)) { - packet.validate(fs).map_err(EngineError::Other)?; - } - } - } - } - Ok(()) - } - - /// Only a successful application write updates a retained content version. Atomic writes - /// replace inodes; aliases that now resolve to the written path observe the new content. - pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) { - let version = PacketVersion::of(bytes); - for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) { - for packet in &mut repair.companions { - if RealFs.same_file(path, &packet.path) { - packet.version = Some(version); - } - } - } - } - - /// [`Session::apply_rename`] on the file system `fs`. - pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { - self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) - } - - /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars - /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the - /// photos (and their virtual copies) at the new paths. Undo moves the files back. - pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { - // A partly completed rename can already have the requested photo name. Repair - // its sidecars before treating that empty move list as a successful retry. - self.repair_sidecars_with(fs)?; - let mut ops = Vec::new(); - let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); - for p in self.catalog.photos() { - let plan = match &p.source { - Source::File { path } => by_path.get(path.as_str()).copied(), - Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()), - }; - let Some(plan) = plan else { continue }; - let source = match &plan.to_path { - Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(), - Some(t) => Source::File { path: t.clone() }, - None => p.source.clone(), - }; - if plan.to != p.file_name || source != p.source { - ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source }); - } - } - let n = ops.len(); - let op = Op::Batch { ops }; - let moves: Vec<(String, String)> = plans - .iter() - .filter_map(|pl| match (&pl.from_path, &pl.to_path) { - (Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())), - _ => None, - }) - .collect(); - if let Err(e) = move_all(fs, &moves) { - return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); - } - if n > 0 - && let Err(err) = self.commit(&label(n), op) - { - let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); - if let Err(e) = move_all(fs, &back) { - return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message))); - } - return Err(err); - } - Ok(n) - } - - /// After a failed batch of moves for `op`: point the photos whose files stayed moved at - /// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged - /// change), so the library matches the disk. Returns the message and, for a history retry, - /// the correction's inverse file ops (so a retry doesn't lose the original paths). - pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec) { - let mut msg = e.message; - let pending_sidecars = e.pending_sidecars; - if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) { - return (msg, Vec::new()); - } - let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect(); - let mut keep = Vec::new(); - set_file_ops_to(op, &to, &mut keep); - let list: Vec = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect(); - let n = e.stuck.len(); - let s = if n == 1 { "" } else { "s" }; - let fix = Op::Batch { ops: keep }; - let mut inverses = Vec::new(); - let applied = if undoable { - let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") }; - self.commit(&label, fix).map(|()| { - if let Some(entry) = self.undo.last_mut() { - entry.retry_sidecars.extend(pending_sidecars); - } - }) - } else { - match self.apply_catalog(fix.clone()) { - Ok(inv) => { - inverses = match inv { - Op::Batch { ops } => ops, - op => vec![op], - }; - self.pending_log.push(fix); - Ok(()) - } - Err(e) => Err(e), - } - }; - match applied { - Ok(()) if n > 0 => msg - .push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))), - Ok(()) => {} - Err(err) => msg.push_str(&format!( - ". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}", - list.join(", ") - )), - } - (msg, inverses) - } - - /// Physical moves to the final `SetFile` destination of each photo. A merged history - /// batch can rename the same photo repeatedly; intermediate catalog paths need no file. - pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { - let mut pending = vec![op]; - let mut order = Vec::new(); - let mut targets = HashMap::new(); - while let Some(op) = pending.pop() { - match op { - Op::Batch { ops } => pending.extend(ops.iter().rev()), - Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => { - order.push(*id); - } - _ => {} - } - } - // Virtual copies share one physical move, keeping the original batch order. - let mut seen = HashSet::new(); - order - .into_iter() - .filter_map(|id| { - let Source::File { path: to } = targets.get(&id).copied()? else { return None }; - let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None }; - (Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone())) - }) - .collect() - } -} - #[cfg(test)] pub(crate) mod tests; diff --git a/crates/engine/src/rename/fs.rs b/crates/engine/src/rename/fs.rs new file mode 100644 index 000000000..352140b38 --- /dev/null +++ b/crates/engine/src/rename/fs.rs @@ -0,0 +1,242 @@ +use super::sidecar::PacketVersion; +use super::*; + +/// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). +pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { + let mut v = Vec::new(); + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + let p = crate::sidecar::sidecar_path(path, naming); + v.push((p.with_extension("XMP"), naming, true)); + v.push((p, naming, false)); + } + v +} + +/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of +/// a case-insensitive or case-sensitive volume (and inject failures). +pub(crate) trait MoveFs { + /// Something (file, folder, link) answers to `p`. + fn exists(&self, p: &Path) -> bool; + fn is_file(&self, p: &Path) -> bool; + /// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive + /// volume). Decided by file identity, never by comparing names: on a case-sensitive volume + /// they are two different photos. When in doubt: `false`. + fn same_file(&self, a: &Path, b: &Path) -> bool; + /// Rename `a` to `b`, failing (never replacing) when `b` exists. + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; + /// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists. + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>; + fn remove_file(&self, p: &Path) -> std::io::Result<()>; + /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. + /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. + fn stem_sibling(&self, sidecar: &Path) -> Option; + /// The folder lists an entry spelled exactly like `p` (letter case included). On a + /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two + /// apart. When the folder can't be listed: `true`, so nothing is skipped. + fn listed(&self, p: &Path) -> bool { + let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; + let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; + match std::fs::read_dir(dir) { + Ok(entries) => entries.flatten().any(|e| e.file_name() == name), + Err(_) => true, + } + } + /// Content observed in a packet, independent of its pathname or inode. Model volumes + /// override this; disk implementations stream a bounded buffer without retaining the XML. + fn packet_version(&self, path: &Path) -> std::io::Result { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hash = lightcraft_preview::Hasher128::new(); + let mut len = 0u64; + let mut buffer = [0u8; 16 * 1024]; + loop { + let n = match file.read(&mut buffer) { + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + result => result?, + }; + if n == 0 { + return Ok(PacketVersion { len, hash: hash.finish() }); + } + let bytes = buffer.get(..n).ok_or_else(|| std::io::Error::other("invalid sidecar read length"))?; + len = len.checked_add(n as u64).ok_or_else(|| std::io::Error::other("sidecar is too large"))?; + hash.update(bytes); + } + } +} + +/// The real file system. +pub(crate) struct RealFs; + +impl MoveFs for RealFs { + fn exists(&self, p: &Path) -> bool { + std::fs::symlink_metadata(p).is_ok() + } + + fn is_file(&self, p: &Path) -> bool { + p.is_file() + } + + fn same_file(&self, a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else { return false }; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + ma.dev() == mb.dev() && ma.ino() == mb.ino() + } + #[cfg(not(unix))] + { + // no stable file id in std here: `a` and `b` are one file under two spellings when + // they look alike and the folder doesn't list both (on a case-sensitive folder + // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the + // listed one: the answer must not depend on the argument order. + let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; + let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); + if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { + return false; + } + let Ok(entries) = std::fs::read_dir(dir) else { return false }; + let (mut listed_a, mut listed_b) = (false, false); + for e in entries.flatten() { + let name = e.file_name(); + listed_a |= name == name_a; + listed_b |= name == name_b; + } + !(listed_a && listed_b) + } + } + + fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // A hard link fails when `b` exists — even if it appeared since the check above — so + // nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back + // to a plain rename after the check. + match std::fs::hard_link(a, b) { + Ok(()) => std::fs::remove_file(a).inspect_err(|_| { + // both names are the same file: dropping the new one loses nothing + let _ = std::fs::remove_file(b); + }), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), + Err(_) => std::fs::rename(a, b) + .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), + } + } + + fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed + } + + fn remove_file(&self, p: &Path) -> std::io::Result<()> { + std::fs::remove_file(p) + } + + fn stem_sibling(&self, sidecar: &Path) -> Option { + crate::import_move::sibling_photo(sidecar) + } +} + +/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original +/// byte for byte, and only then remove the original. A copy that doesn't match is removed. +fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { + fs.copy_no_replace(a, b)?; + if !same_contents(a, b).unwrap_or(false) { + let _ = std::fs::remove_file(b); + return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); + } + std::fs::remove_file(a).inspect_err(|_| { + // the original stays: drop the copy so the file isn't in two places + let _ = std::fs::remove_file(b); + }) +} + +/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so +/// a large raw or video is never read into memory whole. +pub(super) fn same_contents(a: &Path, b: &Path) -> std::io::Result { + const CHUNK: usize = 1 << 20; + let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); + if fa.metadata()?.len() != fb.metadata()?.len() { + return Ok(false); + } + let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); + loop { + let n = read_full(&mut fa, &mut ba)?; + let m = read_full(&mut fb, &mut bb)?; + if n != m || ba.get(..n) != bb.get(..m) { + return Ok(false); + } + if n == 0 { + return Ok(true); + } + } +} + +/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer +/// only at the end of the file). +fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { + match r.read(rest) { + Ok(0) => break, + Ok(k) => filled = filled.saturating_add(k), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(filled) +} + +/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive +/// volume): the rename must go through a temporary name. +pub(super) fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { + from != to && folded_path(&from.to_string_lossy()) == folded_path(&to.to_string_lossy()) && fs.exists(to) && fs.same_file(from, to) +} + +/// Windows accepts both separators; spelling differences must not hide file identity. +pub(super) fn folded_path(path: &str) -> String { + if cfg!(windows) { path.replace('\\', "/").to_lowercase() } else { path.to_lowercase() } +} + +/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a +/// unique temporary name so case-insensitive volumes see a real change. +pub(super) fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> { + if !is_respelling(fs, a, b) { + return fs.rename_no_replace(a, b); + } + let tmp = (0..1000u32) + .map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id()))) + .find(|t| !fs.exists(t)) + .ok_or_else(|| std::io::Error::other("no free temporary name"))?; + fs.rename_no_replace(a, &tmp)?; + fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) { + Ok(()) => e, + Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())), + }) +} diff --git a/crates/engine/src/rename/moves.rs b/crates/engine/src/rename/moves.rs new file mode 100644 index 000000000..6f7c245c9 --- /dev/null +++ b/crates/engine/src/rename/moves.rs @@ -0,0 +1,168 @@ +use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars}; +use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases}; +use super::*; + +/// Why [`move_file`] failed, and what it left behind. +#[derive(Clone, Debug, PartialEq)] +pub struct MoveError { + /// What went wrong, including every step that could not be undone and where that file is now. + pub message: String, + /// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file + /// back failed too. Its sidecars that did move stay with it. + pub moved: bool, + pub(crate) pending_sidecars: Vec, +} + +impl std::fmt::Display for MoveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the +/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]). +pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> { + move_file_with(&RealFs, from, to) +} + +/// [`move_file`] on the file system `fs`. +pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> { + if Path::new(from) == Path::new(to) { + return Ok(()); + } + let fail = |message: String| MoveError { message, moved: false, pending_sidecars: Vec::new() }; + let (f, t) = (Path::new(from), Path::new(to)); + // `to` may be this very file spelled differently (case-insensitive volume); any other file + // there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced + if fs.exists(t) && !is_respelling(fs, f, t) { + return Err(fail(format!("{to} already exists"))); + } + // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the + // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. + let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); + let mut packets: Vec = + sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); + move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; + let mut done: Vec = Vec::new(); + let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { + let mut dst = crate::sidecar::sidecar_path(to, naming); + if upper { + dst = dst.with_extension("XMP"); + } + // A stem sidecar another photo still uses is copied, not taken away (issue #92). + let copied = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some(); + SidecarMove { from: sc, to: dst, copied, companions: Vec::new() } + }); + while let Some(m) = remaining.next() { + if !m.needed(fs) { + continue; // never overwrite: the old sidecar stays where it was + } + let aliases = packet_aliases(&packets, fs, &m.from); + if let Err(e) = m.apply(fs) { + let mut message = format!("rename {}: {e}", m.from.display()); + // The file first: if it can't go back, sidecars that moved stay with it. + if let Err(e) = move_one(fs, t, f) { + message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}")); + if !done.is_empty() { + let names: Vec = done.iter().map(|m| m.to.display().to_string()).collect(); + message.push_str(&format!(" with its sidecar {}", names.join(", "))); + } + let mut pending_sidecars = vec![m]; + for m in remaining.filter(|m| m.needed(fs)) { + // The two case spellings can name one file. Keep one operation for + // those aliases, while retaining distinct sidecars on sensitive volumes. + let alias = pending_sidecars.iter().any(|p| { + folded_path(&p.from.to_string_lossy()) == folded_path(&m.from.to_string_lossy()) + && folded_path(&p.to.to_string_lossy()) == folded_path(&m.to.to_string_lossy()) + && fs.same_file(&p.from, &m.from) + }); + if !alias { + pending_sidecars.push(m); + } + } + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: true, pending_sidecars }); + } + let mut pending_sidecars = Vec::new(); + for m in done.iter().rev() { + if m.copied { + // The original is still there: a leftover copy loses nothing. + if fs.remove_file(&m.to).is_ok() { + packets.retain(|p| p.path != m.to); + } + } else { + let aliases = packet_aliases(&packets, fs, &m.to); + if let Err(e) = move_one(fs, &m.to, &m.from) { + message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", m.from.display(), m.to.display())); + pending_sidecars.push(SidecarMove { from: m.to.clone(), to: m.from.clone(), copied: false, companions: Vec::new() }); + } else { + advance_packets(&mut packets, &aliases, &m.from, false, fs); + } + } + } + note_companions(&mut pending_sidecars, packets); + return Err(MoveError { message, moved: false, pending_sidecars }); + } + advance_packets(&mut packets, &aliases, &m.to, m.copied, fs); + done.push(m); + } + Ok(()) +} + +/// What a failed batch of moves ([`move_all`]) left behind. +#[derive(Debug)] +pub(crate) struct BatchMoveError { + /// The first failure and every move that could not be undone. + pub message: String, + /// Moves that stayed done, (from, to): those files are at `to` now. + pub stuck: Vec<(String, String)>, + /// Sidecars still separated from the path where their photo ended up, in either direction. + pub pending_sidecars: Vec, +} + +/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone. +/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored. +pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> { + let mut done: Vec<&(String, String)> = Vec::new(); + for m in moves { + let Err(e) = move_file_with(fs, &m.0, &m.1) else { + done.push(m); + continue; + }; + let mut message = e.message; + let mut pending_sidecars = e.pending_sidecars; + let mut stuck = Vec::new(); + if e.moved { + stuck.push(m.clone()); + } + for d in done.iter().rev() { + if let Err(mut e) = move_file_with(fs, &d.1, &d.0) { + message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message)); + if !e.moved { + stuck.push((*d).clone()); + } + pending_sidecars.append(&mut e.pending_sidecars); + } + } + return Err(BatchMoveError { message, stuck, pending_sidecars }); + } + Ok(()) +} + +/// Final `SetFile` per photo (nested batches included), filtered by destination only afterward. +pub(super) fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec) { + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut final_ops = HashMap::new(); + while let Some(op) = pending.pop() { + match op { + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, .. } if final_ops.insert(*id, op).is_none() => order.push(*id), + _ => {} + } + } + out.extend(order.into_iter().filter_map(|id| { + let op = final_ops.get(&id).copied()?; + matches!(op, Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str())).then(|| op.clone()) + })); +} diff --git a/crates/engine/src/rename/plan.rs b/crates/engine/src/rename/plan.rs new file mode 100644 index 000000000..331297ba4 --- /dev/null +++ b/crates/engine/src/rename/plan.rs @@ -0,0 +1,295 @@ +use super::fs::{MoveFs, RealFs, folded_path}; +use super::moves::{BatchMoveError, move_all, set_file_ops_to}; +use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths}; +use super::tokens::{expand, split_ext}; +use super::*; + +/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) +/// rename once. No file-system calls. +pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec> { + let mut seen_paths: HashSet = HashSet::new(); + ids.iter() + .filter_map(|id| cat.photo(*id)) + .filter(|p| match &p.source { + Source::File { path } => seen_paths.insert(path.clone()), + _ => true, + }) + .cloned() + .collect() +} + +/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check +/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow +/// drive). Whether a case-only change names this very file is decided on the disk. +pub fn plan_rename_photos(photos: &[std::sync::Arc], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec { + plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b)) +} + +/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether +/// two spellings name one file (see [`MoveFs`]). +fn plan_rename_core( + photos: &[std::sync::Arc], + template: &str, + start: usize, + exists: &dyn Fn(&Path) -> bool, + same_file: &dyn Fn(&Path, &Path) -> bool, +) -> Vec { + let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch + let mut out = Vec::new(); + for (i, p) in photos.iter().enumerate() { + let seq = start.saturating_add(i); + let want = expand(template, p, seq); + let (stem, ext) = split_ext(&want); + let (stem, ext) = (stem.to_string(), ext.to_string()); + let candidate = |k: usize| { + if k == 0 { + want.clone() + } else if ext.is_empty() { + format!("{stem}-{k}") + } else { + format!("{stem}-{k}.{ext}") + } + }; + let (to, to_path) = match &p.source { + Source::File { path } => { + let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); + let mut k = 0; + loop { + let name = candidate(k); + let tp = dir.join(&name).to_string_lossy().to_string(); + let key = folded_path(&tp); + // this very file, maybe spelled differently (case-insensitive volume)? By + // identity: on a case-sensitive volume `img_1.jpg` may be another photo + let (from, to) = (Path::new(path), Path::new(&tp)); + let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to)); + // free: not claimed in this batch and not on disk (unless it is this very file). + // A file this batch moves away still counts as taken: simple and safe. + if !taken.contains(&key) && (same || !exists(to)) { + taken.insert(key); + break (name, Some(tp)); + } + k += 1; + } + } + Source::Demo { .. } => { + let mut k = 0; + loop { + let name = candidate(k); + if taken.insert(format!("demo:{}", name.to_lowercase())) { + break (name, None); + } + k += 1; + } + } + }; + let from_path = match &p.source { + Source::File { path } => Some(path.clone()), + _ => None, + }; + out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path }); + } + out +} + +impl Session { + /// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions. + pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec { + self.plan_rename_with(&RealFs, ids, template, start) + } + + /// [`Session::plan_rename`] on the file system `fs`. + pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec { + plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b)) + } + + /// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op + /// that updates the photos (and their virtual copies). + /// + /// When a failure can't be fully rolled back, the files that kept their new names are + /// committed as a smaller undoable rename, so the library points at them (nothing goes + /// missing), and the error lists them (old → new). + pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result { + self.apply_rename_with(&RealFs, plans) + } + + /// Complete retained repairs before a file or folder change can stale their exact paths. + pub(crate) fn repair_sidecars_with(&mut self, fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } + retry_sidecars(fs, &mut self.pending_sidecar_repairs).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars(fs, &mut entry.retry_sidecars).map_err(EngineError::Other)?; + } + Ok(()) + } + + pub(crate) fn repair_sidecars_for_paths(&mut self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + self.validate_sidecar_packets_for_paths(paths, fs)?; + retry_sidecars_for_paths(fs, &mut self.pending_sidecar_repairs, paths).map_err(EngineError::Other)?; + for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { + retry_sidecars_for_paths(fs, &mut entry.retry_sidecars, paths).map_err(EngineError::Other)?; + } + Ok(()) + } + + /// A healthy shared packet may be saved while another photo's repair is unavailable. + /// Check its retained content too, without forcing that unrelated physical operation. + pub(crate) fn validate_sidecar_packets_for_paths(&self, paths: &[PathBuf], fs: &dyn MoveFs) -> Result<()> { + for repair in self.pending_sidecar_repairs.iter().chain(self.undo.iter().chain(&self.redo).flat_map(|e| &e.retry_sidecars)) { + if paths.iter().any(|path| repair.touches(fs, path)) { + repair.validate_repair(fs).map_err(EngineError::Other)?; + } else { + for packet in &repair.companions { + if paths.iter().any(|path| fs.same_file(path, &packet.path)) { + packet.validate(fs).map_err(EngineError::Other)?; + } + } + } + } + Ok(()) + } + + /// Only a successful application write updates a retained content version. Atomic writes + /// replace inodes; aliases that now resolve to the written path observe the new content. + pub(crate) fn note_sidecar_write(&mut self, path: &Path, bytes: &[u8]) { + let version = PacketVersion::of(bytes); + for repair in self.pending_sidecar_repairs.iter_mut().chain(self.undo.iter_mut().chain(&mut self.redo).flat_map(|e| &mut e.retry_sidecars)) { + for packet in &mut repair.companions { + if RealFs.same_file(path, &packet.path) { + packet.version = Some(version); + } + } + } + } + + /// [`Session::apply_rename`] on the file system `fs`. + pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { + self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) + } + + /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars + /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the + /// photos (and their virtual copies) at the new paths. Undo moves the files back. + pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { + // A partly completed rename can already have the requested photo name. Repair + // its sidecars before treating that empty move list as a successful retry. + self.repair_sidecars_with(fs)?; + let mut ops = Vec::new(); + let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); + for p in self.catalog.photos() { + let plan = match &p.source { + Source::File { path } => by_path.get(path.as_str()).copied(), + Source::Demo { .. } => plans.iter().find(|x| x.id == p.id.0 && x.from_path.is_none()), + }; + let Some(plan) = plan else { continue }; + let source = match &plan.to_path { + Some(t) if matches!(&p.source, Source::File { path } if Path::new(path) == Path::new(t)) => p.source.clone(), + Some(t) => Source::File { path: t.clone() }, + None => p.source.clone(), + }; + if plan.to != p.file_name || source != p.source { + ops.push(Op::SetFile { id: p.id, file_name: plan.to.clone(), source }); + } + } + let n = ops.len(); + let op = Op::Batch { ops }; + let moves: Vec<(String, String)> = plans + .iter() + .filter_map(|pl| match (&pl.from_path, &pl.to_path) { + (Some(a), Some(b)) if Path::new(a) != Path::new(b) => Some((a.clone(), b.clone())), + _ => None, + }) + .collect(); + if let Err(e) = move_all(fs, &moves) { + return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); + } + if n > 0 + && let Err(err) = self.commit(&label(n), op) + { + let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); + if let Err(e) = move_all(fs, &back) { + return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message))); + } + return Err(err); + } + Ok(n) + } + + /// After a failed batch of moves for `op`: point the photos whose files stayed moved at + /// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged + /// change), so the library matches the disk. Returns the message and, for a history retry, + /// the correction's inverse file ops (so a retry doesn't lose the original paths). + pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> (String, Vec) { + let mut msg = e.message; + let pending_sidecars = e.pending_sidecars; + if e.stuck.is_empty() && (!undoable || pending_sidecars.is_empty()) { + return (msg, Vec::new()); + } + let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect(); + let mut keep = Vec::new(); + set_file_ops_to(op, &to, &mut keep); + let list: Vec = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect(); + let n = e.stuck.len(); + let s = if n == 1 { "" } else { "s" }; + let fix = Op::Batch { ops: keep }; + let mut inverses = Vec::new(); + let applied = if undoable { + let label = if n == 0 { "Repair Rename Sidecars".to_string() } else { format!("Rename {n} Photo{s} (partly)") }; + self.commit(&label, fix).map(|()| { + if let Some(entry) = self.undo.last_mut() { + entry.retry_sidecars.extend(pending_sidecars); + } + }) + } else { + match self.apply_catalog(fix.clone()) { + Ok(inv) => { + inverses = match inv { + Op::Batch { ops } => ops, + op => vec![op], + }; + self.pending_log.push(fix); + Ok(()) + } + Err(e) => Err(e), + } + }; + match applied { + Ok(()) if n > 0 => msg + .push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))), + Ok(()) => {} + Err(err) => msg.push_str(&format!( + ". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}", + list.join(", ") + )), + } + (msg, inverses) + } + + /// Physical moves to the final `SetFile` destination of each photo. A merged history + /// batch can rename the same photo repeatedly; intermediate catalog paths need no file. + pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> { + let mut pending = vec![op]; + let mut order = Vec::new(); + let mut targets = HashMap::new(); + while let Some(op) = pending.pop() { + match op { + Op::Batch { ops } => pending.extend(ops.iter().rev()), + Op::SetFile { id, source, .. } if targets.insert(*id, source).is_none() => { + order.push(*id); + } + _ => {} + } + } + // Virtual copies share one physical move, keeping the original batch order. + let mut seen = HashSet::new(); + order + .into_iter() + .filter_map(|id| { + let Source::File { path: to } = targets.get(&id).copied()? else { return None }; + let Source::File { path: from } = &self.catalog.photo(id)?.source else { return None }; + (Path::new(from) != Path::new(to) && seen.insert(from)).then(|| (from.clone(), to.clone())) + }) + .collect() + } +} diff --git a/crates/engine/src/rename/sidecar.rs b/crates/engine/src/rename/sidecar.rs new file mode 100644 index 000000000..24ebcd3f9 --- /dev/null +++ b/crates/engine/src/rename/sidecar.rs @@ -0,0 +1,174 @@ +use super::fs::{MoveFs, is_respelling, move_one}; +use super::*; + +/// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may +/// already have followed its new path, so a retry cannot infer this work from the catalog. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SidecarMove { + pub from: PathBuf, + pub to: PathBuf, + pub(super) copied: bool, + /// Observed packets in the partial move, including the separated source. Absent planned + /// destinations are never owned; a completed repair advances these observations. + pub(super) companions: Vec, +} + +/// A content version, not an assertion that a pathname still names the same packet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PacketVersion { + pub(super) len: u64, + pub(super) hash: lightcraft_preview::Hash128, +} + +impl PacketVersion { + pub(super) fn of(bytes: &[u8]) -> Self { + Self { len: bytes.len() as u64, hash: lightcraft_preview::hash_bytes(bytes) } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct RetainedPacket { + pub(super) path: PathBuf, + // An unreadable observation remains untrusted even if a later read succeeds. + pub(super) version: Option, +} + +impl RetainedPacket { + pub(super) fn validate(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + if self.version.is_none() { + return Err(format!("can't verify retained XMP {}: its content was unreadable when the partial move was recorded", self.path.display())); + } + let current = fs.packet_version(&self.path).map_err(|e| format!("can't verify retained XMP {}: {e}", self.path.display()))?; + if self.version != Some(current) { + return Err(format!( + "retained XMP {} changed since the partial move; packet ownership must be resolved before retrying", + self.path.display() + )); + } + Ok(()) + } +} + +/// Advance only packets whose operation actually succeeded. A copy retains its original; +/// a move retires vanished source aliases and adds the observed destination. +pub(super) fn advance_packets(packets: &mut Vec, aliases: &[PathBuf], to: &Path, copied: bool, fs: &dyn MoveFs) { + let Some(mut packet) = packets.iter().find(|p| aliases.contains(&p.path)).cloned() else { return }; + if !copied { + packets.retain(|p| !aliases.contains(&p.path) || fs.is_file(&p.path)); + } + packet.path = to.to_path_buf(); + if !packets.iter().any(|p| p.path == packet.path) { + packets.push(packet); + } +} + +pub(super) fn packet_aliases(packets: &[RetainedPacket], fs: &dyn MoveFs, from: &Path) -> Vec { + packets.iter().filter(|p| p.path == from || fs.same_file(&p.path, from)).map(|p| p.path.clone()).collect() +} + +impl SidecarMove { + fn validate_packets(&self, fs: &dyn MoveFs) -> std::result::Result<(), String> { + for packet in &self.companions { + packet.validate(fs)?; + } + Ok(()) + } + + /// Validate a queued retry and return the copy decision for this physical operation. + pub(super) fn validate_repair(&self, fs: &dyn MoveFs) -> std::result::Result { + self.validate_packets(fs)?; + let respelling = is_respelling(fs, &self.from, &self.to); + // Batch rollback may have restored another photo using this source packet. + let copied = self.copied || (!respelling && fs.stem_sibling(&self.from).is_some()); + if fs.exists(&self.to) && (copied || !respelling) { + return Err(format!("{} already exists while repairing {} → {}", self.to.display(), self.from.display(), self.to.display())); + } + Ok(copied) + } + + pub(super) fn needed(&self, fs: &dyn MoveFs) -> bool { + fs.is_file(&self.from) && !(fs.exists(&self.to) && (self.copied || !is_respelling(fs, &self.from, &self.to))) + } + + pub(super) fn touches(&self, fs: &dyn MoveFs, path: &Path) -> bool { + let same_path = |endpoint: &Path| { + endpoint == path + || fs.same_file(endpoint, path) + || (endpoint.file_name() == path.file_name() + && endpoint.parent().zip(path.parent()).is_some_and(|(a, b)| a == b || fs.same_file(a, b))) + }; + same_path(&self.from) || same_path(&self.to) + } + + pub(super) fn apply(&self, fs: &dyn MoveFs) -> std::io::Result<()> { + if self.copied { fs.copy_no_replace(&self.from, &self.to) } else { move_one(fs, &self.from, &self.to) } + } +} + +/// Complete known unfinished sidecars before moving any more photos. Completed operations +/// leave the queue; a failure retains that operation and the rest, without replacing a file. +pub(crate) fn retry_sidecars(fs: &dyn MoveFs, pending: &mut Vec) -> std::result::Result<(), String> { + validate_sidecars(fs, pending)?; + while !pending.is_empty() { + apply_repair(fs, pending, 0)?; + } + Ok(()) +} + +/// Preflight a detached history entry before global queues are allowed to retire. The +/// caller can validate without mutating it; retry revalidates before each physical operation. +pub(crate) fn validate_sidecars(fs: &dyn MoveFs, pending: &[SidecarMove]) -> std::result::Result<(), String> { + for repair in pending { + repair.validate_repair(fs)?; + } + Ok(()) +} + +/// Finish one operation and transfer its observed content to the remaining repairs. Do not +/// infer ownership by reading an occupied destination or by retaining a planned pathname. +fn apply_repair(fs: &dyn MoveFs, pending: &mut Vec, i: usize) -> std::result::Result<(), String> { + let Some(mut repair) = pending.get(i).cloned() else { return Ok(()) }; + repair.copied = repair.validate_repair(fs)?; + let aliases = packet_aliases(&repair.companions, fs, &repair.from); + repair.apply(fs).map_err(|e| format!("rename {} → {}: {e}", repair.from.display(), repair.to.display()))?; + for remaining in pending.iter_mut() { + advance_packets(&mut remaining.companions, &aliases, &repair.to, repair.copied, fs); + } + pending.remove(i); + Ok(()) +} + +/// Repair only operations that affect a sidecar being saved. Unrelated unavailable originals +/// must not block healthy metadata saves. Completed repairs retire; failures stay queued. +pub(crate) fn retry_sidecars_for_paths(fs: &dyn MoveFs, pending: &mut Vec, paths: &[PathBuf]) -> std::result::Result<(), String> { + for repair in pending.iter().filter(|m| paths.iter().any(|path| m.touches(fs, path))) { + repair.validate_repair(fs)?; + } + let mut i = 0; + while let Some(m) = pending.get(i) { + if paths.iter().any(|path| m.touches(fs, path)) { + // Path/alias equality only locates an observation; validation above proves its + // content. A newly occupied, previously absent peer destination is a collision. + if let Some(path) = paths.iter().find(|path| { + fs.exists(path) + && !fs.same_file(path, &m.from) + && !fs.same_file(path, &m.to) + && !m.companions.iter().any(|known| fs.same_file(path, &known.path)) + }) { + return Err(format!("{} already exists while repairing {} → {}", path.display(), m.from.display(), m.to.display())); + } + apply_repair(fs, pending, i)?; + } else { + i += 1; + } + } + Ok(()) +} + +/// Every record came from an actual packet observed before this move. Successful physical +/// operations have already advanced its path; missing planned destinations are not recorded. +pub(super) fn note_companions(pending: &mut [SidecarMove], packets: Vec) { + for repair in pending { + repair.companions.clone_from(&packets); + } +} diff --git a/crates/engine/src/rename/tokens.rs b/crates/engine/src/rename/tokens.rs new file mode 100644 index 000000000..3490e272b --- /dev/null +++ b/crates/engine/src/rename/tokens.rs @@ -0,0 +1,271 @@ +use super::*; + +pub(super) fn split_ext(name: &str) -> (&str, &str) { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => (stem, ext), + _ => (name, ""), + } +} + +fn sanitize(s: &str) -> String { + let t: String = + s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '-' } else { c }).collect(); + t.trim().trim_matches('.').trim().to_string() +} + +/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name +/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month +/// that doesn't parse gives nothing. +pub(super) fn format_date(iso: &str, fmt: &str) -> String { + let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); + let month = iso.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); + let mut out = String::new(); + let mut it = fmt.chars(); + while let Some(c) = it.next() { + if c != '%' { + out.push(c); + continue; + } + match it.next() { + Some('Y') => out.push_str(&part(0, 4)), + Some('y') => out.push_str(&part(2, 4)), + Some('m') => out.push_str(&part(5, 7)), + Some('B') => out.push_str(month), + Some('b') => out.push_str(month.get(..3).unwrap_or(month)), + Some('d') => out.push_str(&part(8, 10)), + Some('H') => out.push_str(&part(11, 13)), + Some('M') => out.push_str(&part(14, 16)), + Some('S') => out.push_str(&part(17, 19)), + Some('%') => out.push('%'), + Some(o) => { + out.push('%'); + out.push(o); + } + None => out.push('%'), + } + } + out +} + +/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and +/// command description shows; a test checks that [`expand_tokens`] knows each of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct TokenHelp { + /// The token as typed, e.g. `{seq:3}`. + pub tag: &'static str, + /// Other spellings with the same meaning. + pub aliases: &'static [&'static str], + pub meaning: &'static str, +} + +/// Every template token, in the order the help shows them. +pub const TOKENS: &[TokenHelp] = &[ + TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" }, + TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" }, + TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" }, + TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" }, + TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" }, + TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" }, + TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" }, + TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" }, + TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" }, + TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" }, + TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" }, + TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" }, + TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" }, + TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" }, +]; + +/// The `%` directives of `{date:…}`. +pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ + ("%Y", "year, 4 digits"), + ("%y", "year, 2 digits"), + ("%m", "month 01–12"), + ("%B", "month name (January)"), + ("%b", "short month name (Jan)"), + ("%d", "day 01–31"), + ("%H", "hour 00–23"), + ("%M", "minute"), + ("%S", "second"), + ("%%", "a literal %"), +]; + +/// How templates behave, one sentence each (shown under the token list). +pub const TEMPLATE_NOTES: &[&str] = &[ + "The original extension is always added; {ext} only puts it inside the name as well.", + "A blank template keeps the original names.", + "{date} is the capture time; a photo without one uses the time it was imported.", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.", + "Unknown tags stay as typed — check the preview for a {typo}.", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….", +]; + +/// The tokens as one line (`{name} {num} {seq} …`), for compact hints. +pub fn token_summary() -> String { + TOKENS.iter().map(|t| t.tag).collect::>().join(" ") +} + +/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48). +pub fn sample_photo() -> Photo { + let mut p = + Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00"); + p.captured = Some("2026-01-14T05:58:48".into()); + p.rating = 4; + p.meta.camera = "Canon EOS R5".into(); + p.meta.lens = "RF24-70mm F2.8".into(); + p.meta.iso = Some(400); + p.meta.title = "Harbour".into(); + p.meta.creator = "Ann Lee".into(); + p +} + +/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column. +pub fn token_example(tag: &str) -> String { + expand_tokens(tag, &sample_photo(), 1, 1) +} + +/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be +/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels. +pub fn folder_template_error(template: &str) -> Option { + let t = template.trim(); + if t.is_empty() { + return Some("the folder template is empty".into()); + } + let b = t.as_bytes(); + if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') { + return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into()); + } + if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) { + return Some("the folder template may not contain . or .. folders".into()); + } + None +} + +/// The folders (relative to the destination) a folder template gives `p`: the template's own `/` +/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder +/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and +/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty +/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s +/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped. +pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec { + template + .split(['/', '\\']) + .map(str::trim) + .filter(|s| !s.is_empty() && !matches!(*s, "." | "..")) + .map(|s| { + let v = sanitize(&expand_tokens(s, p, seq, 1)); + if v.is_empty() { "unknown".to_string() } else { v } + }) + .collect() +} + +/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning. +pub fn unknown_tokens(template: &str) -> Vec { + let p = sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(j) = rest[i..].find('}') else { break }; + let tag = &rest[i..i + j + 1]; + if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = &rest[i + j + 1..]; + } + out +} + +/// Every token with its meaning and example, the date directives and the notes, as JSON (the +/// `photo.renameTokens` command). +pub fn token_help_json() -> serde_json::Value { + let tokens: Vec = TOKENS + .iter() + .map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)})) + .collect(); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect(); + serde_json::json!({ + "tokens": tokens, + "dateDirectives": directives, + "notes": TEMPLATE_NOTES, + "sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48", + }) +} + +/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension. +pub fn expand(template: &str, p: &Photo, seq: usize) -> String { + let (stem, ext) = split_ext(&p.file_name); + let out = expand_tokens(template, p, seq, 1); + let mut stem_out = sanitize(&out); + if stem_out.is_empty() { + stem_out = sanitize(stem); + } + if stem_out.is_empty() { + stem_out = "photo".into(); + } + if ext.is_empty() { stem_out } else { format!("{stem_out}.{ext}") } +} + +/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` +/// is zero-padded to `seq_width` digits. +pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { + expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) +} + +/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text +/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename +/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. +pub fn expand_tokens_with( + template: &str, + p: &Photo, + seq: usize, + seq_width: usize, + extra: &dyn Fn(&str, Option<&str>, &str) -> Option, +) -> String { + let (stem, ext) = split_ext(&p.file_name); + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + out.push_str(&rest[..i]); + let Some(j) = rest[i..].find('}') else { + out.push_str(&rest[i..]); + rest = ""; + break; + }; + let tok = &rest[i + 1..i + j]; + let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); + let lower = name.trim().to_ascii_lowercase(); + let v = match extra(&lower, arg, tok) { + Some(v) => v, + None => match lower.as_str() { + "name" | "filename" => stem.to_string(), + "num" => { + let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); + stem[stem.len() - digits..].to_string() + } + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "folder" => match &p.source { + Source::File { path } => { + Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + } + Source::Demo { .. } => String::new(), + }, + "camera" => p.meta.camera.clone(), + "lens" => p.meta.lens.clone(), + "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), + "rating" => p.rating.to_string(), + "title" => p.meta.title.clone(), + "creator" => p.meta.creator.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), + }, + }; + out.push_str(&v); + rest = &rest[i + j + 1..]; + } + out.push_str(rest); + out +}