Synchronize Folder: an absent folder is offline, disks only on request (review fix)

A folder that isn't there (moved whole, or on a disk that isn't
connected) used to scan as "every photo missing", one tick away from
moving a whole disk to Recently Deleted. The scan (on its worker, so an
asleep share never stalls the UI) now reports it as offline and both
commands refuse it; the dialog says so. A whole disk or a folder holding
disks needs `disk: true`, as Remove Disk from Library does; the disk rows
pass it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 18:08:36 -04:00
co-authored by Claude Opus 5.5
parent 832d3c8d3d
commit 1259506edd
14 changed files with 106 additions and 25 deletions
+1 -1
View File
@@ -1323,7 +1323,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
/// one disk but a way to reach several.
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
ids.iter()
.filter_map(|id| s.catalog.photo(*id))
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
+4 -4
View File
@@ -17,12 +17,12 @@ pub fn specs() -> Vec<CommandSpec> {
"Scan Folder for Changes",
[],
None,
"{path} — 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, 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";
let path = path(p, C)?;
let changes = scan(s, &path).map_err(|e| bad(C, e.to_string()))?;
let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?;
let v = serde_json::to_value(&changes).unwrap_or_default();
s.folder_changes = Some(changes);
Ok(v)
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Synchronize Folder",
[],
None,
"{path, 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, 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]]}",
always,
sync
),
@@ -51,7 +51,7 @@ fn sync(s: &mut Session, p: &Value) -> Result<Value> {
};
let changes = match s.take_folder_changes(&path) {
Some(c) => c,
None => scan(s, &path).map_err(|e| bad(C, e.to_string()))?,
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}))
+27 -5
View File
@@ -65,6 +65,9 @@ pub struct FolderChanges {
pub missing: Vec<FolderPhoto>,
/// Photos whose sidecar has news.
pub metadata: Vec<FolderPhoto>,
/// 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,
/// The probes of the new files, kept for the import that follows.
#[serde(skip)]
probes: HashMap<String, ProbeInfo>,
@@ -110,8 +113,10 @@ fn invalid(msg: impl Into<String>) -> EngineError {
}
impl SyncInput {
/// The scan of folder `path`: a folder the library holds photos in, not the startup disk.
pub fn new(s: &mut Session, path: &str) -> Result<SyncInput> {
/// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A
/// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk`
/// (as for Remove Disk from Library: its missing photos can be a whole disk's).
pub fn new(s: &mut Session, path: &str, disk: bool) -> Result<SyncInput> {
let path = path.trim();
if path.is_empty() {
return Err(invalid("missing `path`"));
@@ -124,6 +129,9 @@ impl SyncInput {
if ids.is_empty() {
return Err(invalid(format!("{path}: no photo in the library was imported from it")));
}
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
}
let photos = ids.iter().filter_map(|id| s.catalog.photo(*id).map(|p| (Arc::clone(p), s.sidecar_naming(*id)))).collect();
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
Ok(SyncInput { folder: path.to_string(), revision: s.catalog.revision, scan, photos, labels: s.catalog.clone() })
@@ -137,6 +145,9 @@ impl SyncInput {
/// library's photos, which run on several threads too. Stops early when `progress.cancel` is set.
pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges {
let SyncInput { folder, revision, scan, photos, labels } = input;
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
return FolderChanges { path: folder, revision, offline: true, ..Default::default() };
}
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), progress), check_photos(&photos, &labels, progress));
@@ -224,9 +235,17 @@ fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progre
}
/// [`scan_with`] on the calling thread.
pub fn scan(s: &mut Session, path: &str) -> Result<FolderChanges> {
let input = SyncInput::new(s, path)?;
Ok(scan_with(input, &ScanProgress::default()))
pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result<FolderChanges> {
let input = SyncInput::new(s, path, disk)?;
let changes = scan_with(input, &ScanProgress::default());
if changes.offline {
return Err(not_there(&changes.path));
}
Ok(changes)
}
fn not_there(path: &str) -> EngineError {
invalid(format!("{path} is not there (moved, renamed, or on a disk that isn't connected)"))
}
impl Session {
@@ -265,6 +284,9 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool
/// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo
/// whose file is back by now is left alone.
pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<SyncReport> {
if changes.offline {
return Err(not_there(&changes.path));
}
let mark = s.undo.len();
let mut report = SyncReport::default();
let FolderChanges { new, missing, metadata, probes, .. } = changes;
+38 -1
View File
@@ -243,7 +243,7 @@ fn a_cancelled_scan_stops_checking_files() {
let dir = Scratch::new("cancel");
let mut s = library(&dir);
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip")).unwrap();
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
let progress = crate::import::ScanProgress::default();
progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
let c = crate::sync::scan_with(input, &progress);
@@ -270,3 +270,40 @@ fn many_photos_are_checked_in_parallel_with_the_same_answer() {
assert_eq!(paths(&r, "missing"), expect_missing);
assert_eq!(paths(&r, "metadata"), expect_meta);
}
#[test]
fn a_folder_that_is_not_there_is_not_scanned_as_if_everything_went_missing() {
let dir = Scratch::new("offline");
let mut s = library(&dir);
// an unplugged disk, or a folder moved away whole
std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap();
for cmd in ["folder.scanChanges", "folder.synchronize"] {
let e = s.execute(cmd, &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap_err().to_string();
assert!(e.contains("not there"), "{cmd}: {e}");
}
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was removed");
}
#[test]
fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() {
let dir = Scratch::new("disks");
let mut s = library(&dir);
// a library photo on another disk makes the scratch folder's root look like it holds disks
let id = s.catalog.alloc_photo_id();
let p = lightcraft_catalog::Photo::new(
id,
lightcraft_catalog::Source::File { path: "/Volumes/nas/x.jpg".into() },
"x.jpg",
"JPEG",
6,
4,
"2026-01-01",
);
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
if std::path::Path::new("/Volumes").is_dir() {
let e = s.execute("folder.scanChanges", &json!({"path": "/Volumes"})).unwrap_err().to_string();
assert!(e.contains("disk: true"), "{e}");
}
// a folder inside one disk needs nothing more
assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok());
}
+2 -1
View File
@@ -1903,5 +1903,6 @@
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
"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"
"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."
}
+2 -1
View File
@@ -1748,5 +1748,6 @@
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Las fotos que faltan pasan a Eliminadas recientemente y se pueden restaurar; no se toca ningún archivo del disco.",
"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"
"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."
}
+2 -1
View File
@@ -1748,5 +1748,6 @@
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "見つからない写真は「最近削除した写真」に移動し、復元できます。ディスク上のファイルには手を加えません",
"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": "スキップ: 読み込めないファイル"
"Skipped: files that can't be read": "スキップ: 読み込めないファイル",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。"
}
+2 -1
View File
@@ -1748,5 +1748,6 @@
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "As fotos ausentes vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.",
"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"
"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."
}
+2 -1
View File
@@ -1421,5 +1421,6 @@
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Отсутствующие фото перемещаются в «Недавно удалённые» и могут быть восстановлены; файлы на диске не затрагиваются.",
"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": "Пропущено: файлы, которые не удаётся прочитать"
"Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён."
}
+2 -1
View File
@@ -1748,5 +1748,6 @@
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "缺失的照片会移到“最近删除”,可以恢复;磁盘上的文件不会被改动。",
"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": "已跳过:无法读取的文件"
"Skipped: files that can't be read": "已跳过:无法读取的文件",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。"
}
+2 -1
View File
@@ -1748,5 +1748,6 @@
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "遺失的照片會移至「最近刪除」,可以還原;磁碟上的檔案不會被更動。",
"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": "已略過:無法讀取的檔案"
"Skipped: files that can't be read": "已略過:無法讀取的檔案",
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。"
}
+2 -1
View File
@@ -1247,7 +1247,8 @@ fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode,
.on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps"));
crate::widgets::register(ui.ctx(), "folderSynchronize", r.rect);
if r.clicked() {
if let Err(e) = crate::sync::open(app, &n.path, name) {
// a disk, or a folder holding disks, was chosen as such
if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) {
app.toast(ui.ctx(), e);
}
ui.close();
+6
View File
@@ -671,6 +671,9 @@ pub enum Dialog {
path: String,
/// What the dialog calls it (a folder's last two names).
name: String,
/// A whole disk, or a folder holding disks (`folder.synchronize` `disk`).
#[serde(default)]
disk: bool,
/// What the scan found (`None` while it runs).
counts: Option<SyncCounts>,
import_new: bool,
@@ -685,6 +688,9 @@ pub enum Dialog {
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SyncCounts {
/// The folder isn't there (moved, renamed, or on a disk that isn't connected).
#[serde(default)]
pub offline: bool,
pub new: usize,
pub duplicates: usize,
pub unreadable: usize,
+14 -6
View File
@@ -22,9 +22,9 @@ pub struct SyncTask {
}
/// Open the dialog for folder `path` (called `name` in it) and start scanning.
pub fn open(app: &mut LightcraftApp, path: &str, name: &str) -> Result<(), String> {
pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> {
cancel(app);
let input = SyncInput::new(&mut app.session, path).map_err(|e| e.to_string())?;
let input = SyncInput::new(&mut app.session, path, disk).map_err(|e| e.to_string())?;
let progress = Arc::new(ScanProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
let p = progress.clone();
@@ -40,6 +40,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str) -> Result<(), Strin
app.ui.dialog = Some(Dialog::SynchronizeFolder {
path: path.to_string(),
name: name.to_string(),
disk,
counts: None,
import_new: d.import_new,
remove_missing: d.remove_missing,
@@ -87,6 +88,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
};
app.sync = None;
let c = SyncCounts {
offline: changes.offline,
new: changes.new.len(),
duplicates: changes.duplicates,
unreadable: changes.unreadable.len(),
@@ -106,7 +108,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, 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:"));
@@ -123,6 +125,10 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
});
return;
};
if c.offline {
ui.label(crate::i18n::tr("This folder isn't there: it was moved or renamed, or its disk isn't connected."));
return;
}
let choice = |ui: &mut egui::Ui, on: &mut bool, n: usize, text: String, widget: &str| {
let r = ui.add_enabled(n > 0, egui::Checkbox::new(on, text));
crate::widgets::register(ui.ctx(), widget, r.rect);
@@ -152,11 +158,13 @@ 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, counts: Some(_), import_new, remove_missing, read_metadata, .. } = dlg else {
let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, remove_missing, read_metadata, .. } = dlg else {
return Err("the folder is still being scanned".into());
};
let r =
app.run("folder.synchronize", json!({"path": path, "importNew": import_new, "removeMissing": remove_missing, "readMetadata": read_metadata}));
let r = app.run(
"folder.synchronize",
json!({"path": path, "disk": disk, "importNew": import_new, "removeMissing": remove_missing, "readMetadata": read_metadata}),
);
app.session.folder_changes = None;
r
}