Synchronize Folder: Local files are new; moved files are relinked (review fixes)

A file only browsed in Local was counted as already in the library and
silently dropped; it is now new, and synchronizing brings it into the
library, as an import does.

A photo whose file was renamed or moved within the folder showed as
missing while its file counted as a skipped duplicate, so "Remove
missing" sent the photo and its edits to Recently Deleted and imported
nothing. The scan now matches a missing photo's content hash among the
folder's files and lists it as moved; synchronizing relinks it (on by
default, "Relink photos whose file was renamed or moved"), in the same
undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 18:10:48 -04:00
co-authored by Claude Opus 5.5
parent 1259506edd
commit 5c7b655fe6
13 changed files with 130 additions and 27 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
}
fn relink_op(id: PhotoId, path: &str) -> Op {
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
}
+4 -3
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Scan Folder for Changes",
[],
None,
"{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}",
"{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}",
always,
|s, p| {
const C: &str = "folder.scanChanges";
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Synchronize Folder",
[],
None,
"{path, disk?: bool, importNew?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, removed, read, failed: [[path, error]]}",
"{path, disk?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}",
always,
sync
),
@@ -46,6 +46,7 @@ fn sync(s: &mut Session, p: &Value) -> Result<Value> {
let d = SyncChoice::default();
let choice = SyncChoice {
import_new: bool_or(p, "importNew", d.import_new),
relink_moved: bool_or(p, "relinkMoved", d.relink_moved),
remove_missing: bool_or(p, "removeMissing", d.remove_missing),
read_metadata: bool_or(p, "readMetadata", d.read_metadata),
};
@@ -54,5 +55,5 @@ fn sync(s: &mut Session, p: &Value) -> Result<Value> {
None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?,
};
let r = synchronize(s, changes, choice)?;
Ok(json!({"imported": r.imported, "removed": r.removed, "read": r.read, "failed": r.failed}))
Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed}))
}
+65 -12
View File
@@ -65,6 +65,8 @@ pub struct FolderChanges {
pub missing: Vec<FolderPhoto>,
/// Photos whose sidecar has news.
pub metadata: Vec<FolderPhoto>,
/// Photos whose file was renamed or moved within the folder (found by its content).
pub moved: Vec<FolderMoved>,
/// The folder itself is not there (moved or renamed whole, or on a disk that isn't
/// connected): nothing else is reported, rather than every photo as missing.
pub offline: bool,
@@ -83,18 +85,28 @@ pub struct FolderPhoto {
pub path: String,
}
/// A photo whose file is now somewhere else in the folder.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct FolderMoved {
pub id: u64,
pub from: String,
pub to: String,
}
/// What [`synchronize`] does with the changes found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SyncChoice {
pub import_new: bool,
pub relink_moved: bool,
pub remove_missing: bool,
pub read_metadata: bool,
}
impl Default for SyncChoice {
/// Lightroom Classic's defaults: import the new files, leave the rest to be looked at.
/// Lightroom Classic's defaults: import the new files, leave the rest to be looked at; and
/// photos whose file moved follow it (nothing is lost, and undo puts them back).
fn default() -> Self {
SyncChoice { import_new: true, remove_missing: false, read_metadata: false }
SyncChoice { import_new: true, relink_moved: true, remove_missing: false, read_metadata: false }
}
}
@@ -102,6 +114,7 @@ impl Default for SyncChoice {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct SyncReport {
pub imported: usize,
pub relinked: usize,
pub removed: usize,
pub read: usize,
/// (path or photo, why) for what couldn't be done.
@@ -160,15 +173,7 @@ pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges {
// (a check that panicked counts as nothing found; the scan still reports the rest)
(out, checks.join().unwrap_or_default())
});
let mut changes = FolderChanges { path: folder, probes: out.probes, revision, ..Default::default() };
for c in out.candidates {
match (&c.duplicate, &c.error) {
(Some(d), _) if d == "path" => {}
(Some(_), _) => changes.duplicates += 1,
(None, Some(_)) => changes.unreadable.push(c),
(None, None) => changes.new.push(c),
}
}
let mut changes = FolderChanges { path: folder, revision, ..Default::default() };
for ((p, _), check) in photos.iter().zip(checks) {
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
let found = FolderPhoto { id: p.id.0, path: file.to_string() };
@@ -178,6 +183,42 @@ pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges {
Check::Nothing => {}
}
}
// a missing photo's content found under another name in the folder: the file was renamed or
// moved, and the photo (with its edits) follows it rather than going missing
let mut gone_by_hash: HashMap<&str, &str> = HashMap::new();
for m in &changes.missing {
if let Some(h) = labels.photo(PhotoId(m.id)).and_then(|p| p.content_hash.as_deref()) {
gone_by_hash.entry(h).or_insert(m.path.as_str());
}
}
let mut moved_from: HashMap<String, String> = HashMap::new();
for c in out.candidates {
let hash = out.probes.get(&c.path).and_then(|i| i.content_hash.as_deref());
if c.error.is_none()
&& c.duplicate.as_deref() != Some("path")
&& let Some(from) = hash.and_then(|h| gone_by_hash.remove(h))
{
moved_from.insert(from.to_string(), c.path.clone());
continue;
}
let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted);
match (c.duplicate.as_deref(), &c.error) {
// a file only looked at in Local is not in the library yet: it is new
(Some("path"), _) if browsed => changes.new.push(c),
(Some("path"), _) => {}
(Some(_), _) => changes.duplicates += 1,
(None, Some(_)) => changes.unreadable.push(c),
(None, None) => changes.new.push(c),
}
}
let missing = std::mem::take(&mut changes.missing);
for m in missing {
match moved_from.get(&m.path) {
Some(to) => changes.moved.push(FolderMoved { id: m.id, from: m.path, to: to.clone() }),
None => changes.missing.push(m),
}
}
changes.probes = out.probes;
changes
}
@@ -289,7 +330,7 @@ pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice)
}
let mark = s.undo.len();
let mut report = SyncReport::default();
let FolderChanges { new, missing, metadata, probes, .. } = changes;
let FolderChanges { new, missing, metadata, moved, probes, .. } = changes;
if choice.import_new && !new.is_empty() {
s.import_probes = probes;
let files: Vec<String> = new.into_iter().map(|c| c.path).collect();
@@ -297,6 +338,18 @@ pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice)
report.imported = r.imported.len();
report.failed.extend(r.failed);
}
if choice.relink_moved {
// still where the scan found it, and still gone from where the photo says
let ops: Vec<Op> = moved
.iter()
.filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists() && s.catalog.photo(PhotoId(m.id)).is_some())
.map(|m| crate::cmd::missing::relink_op(PhotoId(m.id), &m.to))
.collect();
if !ops.is_empty() {
report.relinked = ops.len();
s.commit("Relink Moved Photos", Op::Batch { ops })?;
}
}
if choice.remove_missing {
let gone: Vec<PhotoId> = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect();
let ops: Vec<Op> = gone
+35
View File
@@ -307,3 +307,38 @@ fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() {
// a folder inside one disk needs nothing more
assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok());
}
#[test]
fn a_file_only_browsed_in_local_is_new_and_joins_the_library() {
let dir = Scratch::new("local");
let mut s = library(&dir);
write_png(&dir.path("trip/c.png"), 3);
// looking at the folder in Local catalogues c.png as a browse record, not a library photo
s.execute("library.browse", &json!({"path": dir.path("trip")})).unwrap();
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.local));
let r = scan(&mut s, &dir.path("trip"));
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png")], "{r}");
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
assert_eq!(r["imported"], 1, "{r}");
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.in_library()));
}
#[test]
fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() {
let dir = Scratch::new("moved");
let mut s = library(&dir);
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/day1/a-renamed.png")).unwrap();
let r = scan(&mut s, &dir.path("trip"));
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
assert_eq!(r["moved"][0]["id"], a.0, "{r}");
assert_eq!(r["moved"][0]["to"], dir.path("trip/day1/a-renamed.png"), "{r}");
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
assert_eq!((r["relinked"].as_u64(), r["removed"].as_u64(), r["imported"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
let p = s.catalog.photo(a).unwrap();
assert!(p.in_library() && p.rating == 4, "the same photo, edits and all");
assert_eq!(p.source, lightcraft_catalog::Source::File { path: dir.path("trip/day1/a-renamed.png") });
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") });
}
+2 -1
View File
@@ -1904,5 +1904,6 @@
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.",
"Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind",
"Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden."
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.",
"Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde"
}
+2 -1
View File
@@ -1749,5 +1749,6 @@
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leer un archivo complementario sustituye la valoración, las etiquetas, los metadatos y las ediciones de la foto por lo que contiene.",
"Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar",
"Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado."
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado.",
"Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió"
}
+2 -1
View File
@@ -1749,5 +1749,6 @@
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "サイドカーを読み込むと、写真のレーティング、ラベル、メタデータ、編集内容がその内容に置き換わります。",
"Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル",
"Skipped: files that can't be read": "スキップ: 読み込めないファイル",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。"
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。",
"Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す"
}
+2 -1
View File
@@ -1749,5 +1749,6 @@
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Ler um arquivo auxiliar substitui a classificação, os rótulos, os metadados e as edições da foto pelo que ele contém.",
"Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar",
"Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado."
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado.",
"Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido"
}
+2 -1
View File
@@ -1422,5 +1422,6 @@
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение файла XMP заменяет рейтинг, метки, метаданные и правки фото его содержимым.",
"Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте",
"Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён."
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён.",
"Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён"
}
+2 -1
View File
@@ -1749,5 +1749,6 @@
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "读取附属文件会用其内容替换照片的评级、标签、元数据和编辑。",
"Skipped: files already in the library elsewhere": "已跳过:已在图库其他位置的文件",
"Skipped: files that can't be read": "已跳过:无法读取的文件",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。"
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。",
"Relink photos whose file was renamed or moved": "重新链接文件被重命名或移动的照片"
}
+2 -1
View File
@@ -1749,5 +1749,6 @@
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "讀取附屬檔案會以其內容取代照片的評等、標籤、中繼資料和編輯。",
"Skipped: files already in the library elsewhere": "已略過:已在照片庫其他位置的檔案",
"Skipped: files that can't be read": "已略過:無法讀取的檔案",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。"
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。",
"Relink photos whose file was renamed or moved": "重新連結檔案被重新命名或移動的照片"
}
+4
View File
@@ -677,6 +677,8 @@ pub enum Dialog {
/// What the scan found (`None` while it runs).
counts: Option<SyncCounts>,
import_new: bool,
#[serde(default)]
relink_moved: bool,
remove_missing: bool,
read_metadata: bool,
},
@@ -696,6 +698,8 @@ pub struct SyncCounts {
pub unreadable: usize,
pub missing: usize,
pub metadata: usize,
#[serde(default)]
pub moved: usize,
}
impl Default for UiState {
+7 -4
View File
@@ -43,6 +43,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu
disk,
counts: None,
import_new: d.import_new,
relink_moved: d.relink_moved,
remove_missing: d.remove_missing,
read_metadata: d.read_metadata,
});
@@ -94,6 +95,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
unreadable: changes.unreadable.len(),
missing: changes.missing.len(),
metadata: changes.metadata.len(),
moved: changes.moved.len(),
};
app.session.folder_changes = Some(changes);
if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog {
@@ -108,7 +110,7 @@ fn progress(app: &LightcraftApp) -> Option<(usize, usize)> {
/// The dialog's body.
pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
let Dialog::SynchronizeFolder { path, name, counts, import_new, remove_missing, read_metadata, .. } = dlg else { return };
let Dialog::SynchronizeFolder { path, name, counts, import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return };
let t = Tokens::get(ui.ctx());
ui.set_min_width(420.0);
ui.label(crate::i18n::tr("Bring the library up to date with this folder and the folders inside it:"));
@@ -135,11 +137,12 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
};
let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text));
choice(ui, import_new, c.new, count("Import new photos", c.new), "syncImportNew");
choice(ui, relink_moved, c.moved, count("Relink photos whose file was renamed or moved", c.moved), "syncRelinkMoved");
choice(ui, remove_missing, c.missing, count("Remove missing photos from the library", c.missing), "syncRemoveMissing");
choice(ui, read_metadata, c.metadata, count("Read metadata updates from XMP sidecars", c.metadata), "syncReadMetadata");
ui.add_space(6.0);
let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim));
if c.new + c.missing + c.metadata == 0 {
if c.new + c.moved + c.missing + c.metadata == 0 {
dim(ui, crate::i18n::tr("The library is up to date with this folder."));
}
if c.missing > 0 {
@@ -158,12 +161,12 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
/// Synchronize: `folder.synchronize` with the dialog's choices.
pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, remove_missing, read_metadata, .. } = dlg else {
let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else {
return Err("the folder is still being scanned".into());
};
let r = app.run(
"folder.synchronize",
json!({"path": path, "disk": disk, "importNew": import_new, "removeMissing": remove_missing, "readMetadata": read_metadata}),
json!({"path": path, "disk": disk, "importNew": import_new, "relinkMoved": relink_moved, "removeMissing": remove_missing, "readMetadata": read_metadata}),
);
app.session.folder_changes = None;
r