mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
refactor(lightcraft-engine): split rename.rs into rename/
This commit is contained in:
+26
-1137
File diff suppressed because it is too large
Load Diff
@@ -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<String>;
|
||||
/// 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<PacketVersion> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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<usize> {
|
||||
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())),
|
||||
})
|
||||
}
|
||||
@@ -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<SidecarMove>,
|
||||
}
|
||||
|
||||
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<RetainedPacket> =
|
||||
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<SidecarMove> = 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<String> = 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<SidecarMove>,
|
||||
}
|
||||
|
||||
/// 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<Op>) {
|
||||
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())
|
||||
}));
|
||||
}
|
||||
@@ -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<std::sync::Arc<Photo>> {
|
||||
let mut seen_paths: HashSet<String> = 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<Photo>], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec<RenamePlan> {
|
||||
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<Photo>],
|
||||
template: &str,
|
||||
start: usize,
|
||||
exists: &dyn Fn(&Path) -> bool,
|
||||
same_file: &dyn Fn(&Path, &Path) -> bool,
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = 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<RenamePlan> {
|
||||
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<RenamePlan> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
// 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<Op>) {
|
||||
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<String> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<RetainedPacket>,
|
||||
}
|
||||
|
||||
/// 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<PacketVersion>,
|
||||
}
|
||||
|
||||
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<RetainedPacket>, 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<PathBuf> {
|
||||
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<bool, String> {
|
||||
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<SidecarMove>) -> 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<SidecarMove>, 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<SidecarMove>, 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<RetainedPacket>) {
|
||||
for repair in pending {
|
||||
repair.companions.clone_from(&packets);
|
||||
}
|
||||
}
|
||||
@@ -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::<usize>().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::<Vec<_>>().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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<serde_json::Value> = 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<serde_json::Value> = 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>,
|
||||
) -> 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
|
||||
}
|
||||
Reference in New Issue
Block a user