From 738a49aa47303cec842ef382f8386dc714f3d4fb Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 11 Oct 2026 16:37:04 -0400 Subject: [PATCH] LRC-LIB-LIBFOLDERS: Synchronize Folder's scan can continue in the background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan already ran on a worker thread, but its dialog was modal and closing it stopped the scan, so a large folder on a network share held the app for minutes. While the folder is scanned the dialog's button is now Continue in Background: the dialog closes and the scan goes on under its activity row. When it is done nothing pops up. A notice in the activity stack says how many changes were found (or that the folder isn't there); a click opens the dialog with that result and what was ticked, without scanning again, and ✕ lets go of it. A scan that found nothing to do only says so in a toast. Cancel and the row's ✕ stop the scan as before. One folder at a time: Synchronize Folder on another folder is refused while a background scan runs or its notice waits, so minutes of scanning are not thrown away unasked. Another library opened lets go of the scan. A result looked at later can be stale: Synchronize already refuses a scan whose folder's photos changed meanwhile and scans again. The dialog's one button reads Continue in Background and then Synchronize, so Synchronize is unavailable for 0.6 s after the scan ends: a click meant for the first can't start the second. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 5 +- crates/ui-egui/locales/de.json | 8 +- crates/ui-egui/locales/es-formats.json | 5 +- crates/ui-egui/locales/es.json | 8 +- crates/ui-egui/locales/fr-formats.json | 5 +- crates/ui-egui/locales/fr.json | 8 +- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 8 +- crates/ui-egui/locales/pt-br-formats.json | 5 +- crates/ui-egui/locales/pt-br.json | 8 +- crates/ui-egui/locales/ru-formats.json | 5 +- crates/ui-egui/locales/ru.json | 8 +- crates/ui-egui/locales/uk-formats.json | 5 +- crates/ui-egui/locales/uk.json | 8 +- crates/ui-egui/locales/zh-hans-formats.json | 5 +- crates/ui-egui/locales/zh-hans.json | 8 +- crates/ui-egui/locales/zh-hant-formats.json | 5 +- crates/ui-egui/locales/zh-hant.json | 8 +- crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/panels/activity.rs | 73 ++++- crates/ui-egui/src/panels/dialogs.rs | 10 +- crates/ui-egui/src/sync.rs | 163 +++++++++++- crates/ui-egui/src/tests_sync.rs | 281 ++++++++++++++++++++ docs/parity-checklist.md | 8 +- docs/whats-new.md | 7 + 25 files changed, 625 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..fbe5715f9 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "Changes found: {n}": "Gefundene Änderungen: {n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "„{name}“ wird noch durchsucht: Warten Sie darauf oder beenden Sie die Suche mit ✕ in ihrer Zeile", + "Look at the changes found in “{name}” first, or dismiss them": "Sehen Sie sich zuerst die in „{name}“ gefundenen Änderungen an oder verwerfen Sie sie" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..4a47c230c 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,11 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Continue in Background": "Im Hintergrund fortsetzen", + "Folder not found": "Ordner nicht gefunden", + "Dismiss": "Verwerfen", + "Review…": "Ansehen…", + "Open Synchronize Folder with what the scan found": "„Ordner synchronisieren“ mit dem Ergebnis der Suche öffnen", + "You can go on working: a notice at the top left says when the scan is done.": "Sie können weiterarbeiten: Ein Hinweis oben links meldet, wenn die Suche abgeschlossen ist." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..f9887b8db 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "Changes found: {n}": "Cambios encontrados: {n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "“{name}” aún se está analizando: espera a que termine o detenlo con ✕ en su fila", + "Look at the changes found in “{name}” first, or dismiss them": "Revisa primero los cambios encontrados en “{name}” o descártalos" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..e9e0a45fc 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,11 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "Continue in Background": "Continuar en segundo plano", + "Folder not found": "Carpeta no encontrada", + "Dismiss": "Descartar", + "Review…": "Revisar…", + "Open Synchronize Folder with what the scan found": "Abrir Sincronizar carpeta con lo que encontró la búsqueda", + "You can go on working: a notice at the top left says when the scan is done.": "Puedes seguir trabajando: un aviso arriba a la izquierda indica cuándo termina la búsqueda." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..c94e01503 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "Changes found: {n}": "Modifications trouvées : {n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "« {name} » est encore en cours d'analyse : attendez la fin ou arrêtez-la avec ✕ sur sa ligne", + "Look at the changes found in “{name}” first, or dismiss them": "Examinez d'abord les modifications trouvées dans « {name} », ou ignorez-les" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..d7404abc3 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,11 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "Continue in Background": "Continuer en arrière-plan", + "Folder not found": "Dossier introuvable", + "Dismiss": "Ignorer", + "Review…": "Examiner…", + "Open Synchronize Folder with what the scan found": "Ouvrir Synchroniser le dossier avec ce que l'analyse a trouvé", + "You can go on working: a notice at the top left says when the scan is done.": "Vous pouvez continuer à travailler : un avis en haut à gauche signale la fin de l'analyse." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..347c55086 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "Changes found: {n}": "見つかった変更: {n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "「{name}」はまだスキャン中です。終わるまで待つか、その行の ✕ で停止してください", + "Look at the changes found in “{name}” first, or dismiss them": "先に「{name}」で見つかった変更を確認するか、閉じてください" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..5275e4e6c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,11 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "Continue in Background": "バックグラウンドで続行", + "Folder not found": "フォルダが見つかりません", + "Dismiss": "閉じる", + "Review…": "確認…", + "Open Synchronize Folder with what the scan found": "スキャンの結果を「フォルダを同期」で開く", + "You can go on working: a notice at the top left says when the scan is done.": "作業を続けられます。スキャンが終わると左上に通知が表示されます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..9edf3e02c 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "Changes found: {n}": "Alterações encontradas: {n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "“{name}” ainda está sendo verificada: aguarde ou interrompa com ✕ na linha dela", + "Look at the changes found in “{name}” first, or dismiss them": "Revise primeiro as alterações encontradas em “{name}” ou dispense-as" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..cdb797000 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,11 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Continue in Background": "Continuar em segundo plano", + "Folder not found": "Pasta não encontrada", + "Dismiss": "Dispensar", + "Review…": "Revisar…", + "Open Synchronize Folder with what the scan found": "Abrir Sincronizar pasta com o que a verificação encontrou", + "You can go on working: a notice at the top left says when the scan is done.": "Você pode continuar trabalhando: um aviso no canto superior esquerdo informa quando a verificação terminar." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..0e0c75c9b 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "Changes found: {n}": "Найдено изменений: {n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "«{name}» ещё просматривается: дождитесь окончания или остановите поиск кнопкой ✕ в его строке", + "Look at the changes found in “{name}” first, or dismiss them": "Сначала просмотрите изменения, найденные в «{name}», или скройте их" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..2c11200e1 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,11 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "Continue in Background": "Продолжить в фоне", + "Folder not found": "Папка не найдена", + "Dismiss": "Скрыть", + "Review…": "Просмотреть…", + "Open Synchronize Folder with what the scan found": "Открыть «Синхронизировать папку» с результатами поиска", + "You can go on working: a notice at the top left says when the scan is done.": "Можно продолжать работу: уведомление слева вверху сообщит, когда поиск завершится." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..d205aee60 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "Changes found: {n}": "Знайдено змін: {n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "«{name}» ще переглядається: дочекайтеся завершення або зупиніть пошук кнопкою ✕ у його рядку", + "Look at the changes found in “{name}” first, or dismiss them": "Спершу перегляньте зміни, знайдені в «{name}», або сховайте їх" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..5e4d051c2 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,11 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Continue in Background": "Продовжити у фоні", + "Folder not found": "Теку не знайдено", + "Dismiss": "Сховати", + "Review…": "Переглянути…", + "Open Synchronize Folder with what the scan found": "Відкрити «Синхронізувати теку» з результатами пошуку", + "You can go on working: a notice at the top left says when the scan is done.": "Можна працювати далі: сповіщення вгорі ліворуч повідомить, коли пошук завершиться." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..24d3f6a94 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "Changes found: {n}": "发现的更改:{n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "“{name}”仍在扫描:请等待完成,或用其所在行的 ✕ 停止", + "Look at the changes found in “{name}” first, or dismiss them": "请先查看在“{name}”中发现的更改,或将其忽略" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..31784f8e0 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,11 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "Continue in Background": "在后台继续", + "Folder not found": "未找到文件夹", + "Dismiss": "忽略", + "Review…": "查看…", + "Open Synchronize Folder with what the scan found": "用扫描结果打开“同步文件夹”", + "You can go on working: a notice at the top left says when the scan is done.": "你可以继续工作:扫描完成时左上角会出现通知。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..9498b7aec 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "Changes found: {n}": "找到的變更:{n}", + "“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "「{name}」仍在掃描:請等待完成,或用其所在列的 ✕ 停止", + "Look at the changes found in “{name}” first, or dismiss them": "請先檢視在「{name}」中找到的變更,或將其忽略" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..c8fbca5e5 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,11 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "Continue in Background": "在背景繼續", + "Folder not found": "找不到資料夾", + "Dismiss": "忽略", + "Review…": "檢視…", + "Open Synchronize Folder with what the scan found": "以掃描結果開啟「同步資料夾」", + "You can go on working: a notice at the top left says when the scan is done.": "你可以繼續工作:掃描完成時左上角會出現通知。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..cdfc69355 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -356,6 +356,10 @@ pub struct LightcraftApp { pub sync: Option, /// `session.folder_changes` came from the Synchronize Folder dialog (and goes with it). pub sync_owns_changes: bool, + /// When (app time, s) the open Synchronize Folder dialog got its scan ([`sync::settled`]). + pub sync_scanned_at: f64, + /// A Synchronize Folder scan that finished in the background and waits to be looked at. + pub sync_ready: Option, /// A Synchronize Folder at work in the background. pub sync_run: Option, /// A Lightroom catalog inspect/import in progress. @@ -449,6 +453,8 @@ impl LightcraftApp { scan: None, sync: None, sync_owns_changes: false, + sync_scanned_at: f64::NEG_INFINITY, + sync_ready: None, sync_run: None, lightroom: None, lightroom_last: None, @@ -1254,6 +1260,7 @@ impl LightcraftApp { return; } self.shown_library = Some(generation); + sync::library_changed(self); if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { let path = l.dir.to_string_lossy().into_owned(); panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 7b0d449c6..9a6f5b7b8 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -56,14 +56,19 @@ fn detail_line(t: &TaskInfo) -> String { /// Draw the stack (called every frame, after the panels and dialogs). pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let all = app.session.activity.list(); + // a scan that finished in the background and waits to be looked at + let ready = app.sync_ready.clone(); if all.is_empty() { app.activity_expanded = false; - return; + if ready.is_none() { + return; + } + } else { + // look again soon: a young task becomes due, bars move + ctx.request_repaint_after(std::time::Duration::from_millis(100)); } - // look again soon: a young task becomes due, bars move - ctx.request_repaint_after(std::time::Duration::from_millis(100)); let tasks: Vec<&TaskInfo> = all.iter().filter(|t| t.age_ms >= SHOW_AFTER_MS).collect(); - if tasks.is_empty() { + if tasks.is_empty() && ready.is_none() { return; } let t = Tokens::get(ctx); @@ -72,6 +77,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let max_height = (ctx.content_rect().height() - t.top_bar_h - 60.0).max(80.0); let mut cancel = None; let mut toggle = false; + let mut notice = None; let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN)); egui::Area::new(egui::Id::new("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| { // An area lays out in the size it had last frame, and the scroll area would take that as all the room there @@ -83,6 +89,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.set_width(inner); ui.spacing_mut().item_spacing.y = 3.0; egui::ScrollArea::vertical().max_height(max_height).auto_shrink([false, true]).show(ui, |ui| { + if let Some(ready) = &ready { + notice = scan_notice(ui, &t, ready, inner); + if !tasks.is_empty() { + ui.add_space(6.0); + } + } for (i, task) in tasks.iter().take(shown).enumerate() { if i > 0 { ui.add_space(6.0); @@ -104,6 +116,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(id) = cancel { self::cancel(app, id); } + match notice { + Some(Notice::Review) => crate::sync::review(app), + Some(Notice::Dismiss) => crate::sync::dismiss(app), + None => {} + } if toggle { app.activity_expanded = !app.activity_expanded; } @@ -115,6 +132,54 @@ pub(crate) fn cancel(app: &mut LightcraftApp, id: u64) { let _ = app.session.execute("activity.cancel", &json!({"id": id})); } +/// What was done with a notice. +enum Notice { + Review, + Dismiss, +} + +/// The notice of a Synchronize Folder scan that finished in the background (widgets +/// `activity:scanReady`, its `:review` and `:dismiss`): what it found and in which folder. A +/// click on it opens the dialog with the result; ✕ lets go of it. It stays until one of +/// the two: the person looks when ready. +fn scan_notice(ui: &mut egui::Ui, t: &Tokens, ready: &crate::sync::SyncReady, width: f32) -> Option { + let mut dismissed = false; + let title = if ready.counts.offline { tr("Folder not found").to_string() } else { tr_format!("Changes found: {n}", n = ready.changes()) }; + let mut reviewed = false; + let r = ui + .vertical(|ui| { + ui.set_width(width); + ui.horizontal(|ui| { + let label_w = width - CROSS - 6.0; + ui.allocate_ui_with_layout(vec2(label_w, 18.0), Layout::left_to_right(Align::Center), |ui| { + ui.set_width(label_w); + ui.add(egui::Label::new(RichText::new(title).font(t.font(12.5)).color(t.text)).truncate()); + }); + let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); + paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); + register(ui.ctx(), "activity:scanReady:dismiss", rect); + dismissed = resp.on_hover_text(tr("Dismiss")).clicked(); + }); + // (the part below the ✕'s line takes the click, so the two never overlap) + let body = ui + .vertical(|ui| { + ui.set_width(width); + ui.add(egui::Label::new(RichText::new(ready.name.as_str()).font(t.font(11.0)).color(t.text_dim)).truncate()); + ui.add(egui::Label::new(RichText::new(tr("Review…")).font(t.font(11.0)).color(t.accent))); + }) + .response; + let click = ui.interact(body.rect, egui::Id::new("activity-scan-ready"), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + register(ui.ctx(), "activity:scanReady:review", body.rect); + reviewed = click.on_hover_text(tr("Open Synchronize Folder with what the scan found")).clicked(); + }) + .response; + register(ui.ctx(), "activity:scanReady", r.rect); + if dismissed { + return Some(Notice::Dismiss); + } + reviewed.then_some(Notice::Review) +} + /// One task: name and ✕, the bar, the detail line. True when ✕ was clicked. fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { let mut clicked = false; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..566edf6d3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1216,6 +1216,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", + // the scan need not be waited for + Dialog::SynchronizeFolder { counts: None, .. } => "Continue in Background", Dialog::SynchronizeFolder { .. } => "Synchronize", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", @@ -1225,12 +1227,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { _ => "OK", }; // installing waits for the licence to be accepted - // (and Synchronize waits for the scan) let can_confirm = informational - || !matches!( - &dlg, - Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } - ) + || !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. }) + // (Synchronize, just after its button stopped being Continue in Background) + && !(matches!(&dlg, Dialog::SynchronizeFolder { counts: Some(_), .. }) && !crate::sync::settled(app, ui.ctx())) // smart-album rules that can't mean anything wait to be fixed && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index cd4a267af..deffb6c12 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -3,6 +3,10 @@ //! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly //! the scan the dialog showed (see `lightcraft_engine::sync`). Both the scan and the run show a row in the activity //! stack (issue #345); its ✕ stops them like the dialog's Cancel. +//! +//! The scan need not be waited for: Continue in Background closes the dialog and the scan goes +//! on under its row. When it is done, a notice in the activity stack says so ([`SyncReady`]); +//! a click on it opens the dialog with what was found, whenever the person is ready for it. use std::sync::Arc; use std::sync::atomic::Ordering; @@ -18,16 +22,34 @@ use crate::theme::Tokens; /// A running scan of the dialog's folder. pub struct SyncTask { path: String, + /// What the dialog calls the folder. + name: String, + disk: bool, + /// The dialog was closed with Continue in Background: the scan goes on without it. + background: bool, + /// What was ticked in the dialog when it was closed that way (a dialog that had scanned + /// before and was scanning again keeps its ticks): the dialog opens with them again. + choice: SyncChoice, progress: Arc, rx: std::sync::mpsc::Receiver, /// Its row in the activity stack; ✕ cancels the scan and closes the dialog. guard: TaskGuard, } -/// Open the dialog for folder `path` (called `name` in it) and start scanning. +/// Open the dialog for folder `path` (called `name` in it) and start scanning. One folder at a +/// time: while another folder is scanned in the background, or what was found in it waits to +/// be looked at, this is refused (minutes of scanning are not thrown away unasked); the same +/// folder is scanned afresh. pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> { - cancel(app); + if let Some(other) = app.sync.as_ref().filter(|t| t.background && t.path != path) { + return Err(crate::i18n::tr_format!("“{name}” is still being scanned: wait for it, or stop it with ✕ on its row", name = other.name)); + } + if let Some(other) = app.sync_ready.as_ref().filter(|r| r.path != path) { + return Err(crate::i18n::tr_format!("Look at the changes found in “{name}” first, or dismiss them", name = other.name)); + } + // (a folder that can't be synchronized leaves what there is alone) let input = SyncInput::new(&mut app.session, path, disk).map_err(|e| e.to_string())?; + cancel(app); let progress = Arc::new(SyncProgress::default()); let (tx, rx) = std::sync::mpsc::channel(); let p = progress.clone(); @@ -40,7 +62,16 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu job(); let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone())); guard.detail(name); - app.sync = Some(SyncTask { path: path.to_string(), progress, rx, guard }); + app.sync = Some(SyncTask { + path: path.to_string(), + name: name.to_string(), + disk, + background: false, + choice: SyncChoice::default(), + progress, + rx, + guard, + }); let d = SyncChoice::default(); app.ui.dialog = Some(Dialog::SynchronizeFolder { path: path.to_string(), @@ -55,12 +86,72 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu Ok(()) } +/// A scan that finished in the background, waiting to be looked at: the notice in the activity +/// stack. Its changes are `session.folder_changes`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyncReady { + pub path: String, + /// What the dialog calls the folder. + pub name: String, + pub disk: bool, + pub counts: SyncCounts, + /// What was ticked when the dialog was closed. + pub choice: SyncChoice, +} + +impl SyncReady { + /// How many changes there are to look at. + pub fn changes(&self) -> usize { + let c = &self.counts; + c.new + c.moved + c.missing + c.metadata + } +} + +/// A click on the notice: open the dialog with what the background scan found. +pub fn review(app: &mut LightcraftApp) { + // (a dialog that is open is not taken away; the notice waits) + if app.ui.dialog.is_some() { + return; + } + let Some(ready) = app.sync_ready.take() else { return }; + let d = ready.choice; + app.ui.dialog = Some(Dialog::SynchronizeFolder { + path: ready.path, + name: ready.name, + disk: ready.disk, + counts: Some(ready.counts), + import_new: d.import_new, + relink_moved: d.relink_moved, + remove_missing: d.remove_missing, + read_metadata: d.read_metadata, + }); +} + +/// ✕ on the notice: what the scan found is let go of. +pub fn dismiss(app: &mut LightcraftApp) { + if app.sync_ready.is_some() { + cancel(app); + } +} + +/// Another library was opened: a scan of the one before, running or waiting to be looked at, +/// says nothing about this one. +pub(crate) fn library_changed(app: &mut LightcraftApp) { + if app.sync.is_some() || app.sync_ready.is_some() || app.sync_owns_changes { + cancel(app); + if matches!(app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })) { + app.ui.dialog = None; + } + } +} + /// Stop a running scan and let go of a finished one the dialog made (one an agent made with /// `folder.scanChanges` is the agent's). fn cancel(app: &mut LightcraftApp) { if let Some(t) = app.sync.take() { t.progress.files.cancel.store(true, Ordering::Relaxed); } + app.sync_ready = None; if std::mem::take(&mut app.sync_owns_changes) { app.session.folder_changes = None; } @@ -73,21 +164,25 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { Some(Dialog::SynchronizeFolder { path, .. }) => Some(path.clone()), _ => None, }; - let Some(open_for) = open_for else { - if app.sync.is_some() || app.sync_owns_changes { + let background = app.sync.as_ref().is_some_and(|t| t.background); + if open_for.is_none() && !background { + // (what a background scan found is kept for its notice) + if app.sync.is_some() || (app.sync_owns_changes && app.sync_ready.is_none()) { cancel(app); } return; - }; + } let Some(task) = app.sync.as_ref() else { return }; - if task.path != open_for { + if !background && open_for.as_deref() != Some(task.path.as_str()) { cancel(app); return; } if task.guard.is_cancelled() { // ✕ on its row: a scan stopped part-way has nothing to show cancel(app); - app.ui.dialog = None; + if !background { + app.ui.dialog = None; + } return; } let (done, total) = task.progress.counts(); @@ -98,12 +193,14 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(_) => { app.sync = None; - app.ui.dialog = None; + if !background { + app.ui.dialog = None; + } app.toast(ctx, crate::i18n::tr("Scan failed")); return; } }; - app.sync = None; + let Some(task) = app.sync.take() else { return }; let c = SyncCounts { offline: changes.offline, new: changes.new.len(), @@ -113,13 +210,38 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { metadata: changes.metadata.len(), moved: changes.moved.len(), }; + if background { + let ready = SyncReady { path: task.path.clone(), name: task.name.clone(), disk: task.disk, counts: c, choice: task.choice }; + if !ready.counts.offline && ready.changes() == 0 { + // nothing to look at: said and done + app.toast(ctx, format!("{}: {}", ready.name, crate::i18n::tr("The library is up to date with this folder."))); + return; + } + app.session.folder_changes = Some(changes); + app.sync_owns_changes = true; + app.sync_ready = Some(ready); + return; + } app.session.folder_changes = Some(changes); app.sync_owns_changes = true; if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog { *counts = Some(c); + // the button was Continue in Background until now: Synchronize waits a moment + app.sync_scanned_at = ctx.input(|i| i.time); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(SETTLE_SECONDS)); } } +/// How long Synchronize stays unavailable after the scan ended (s). The dialog's one button +/// reads Continue in Background while the folder is scanned and Synchronize after: a click meant +/// for the first, landing as the scan ends, must not start the second. +pub const SETTLE_SECONDS: f64 = 0.6; + +/// Whether the dialog's Synchronize can be pressed yet ([`SETTLE_SECONDS`]). +pub fn settled(app: &LightcraftApp, ctx: &egui::Context) -> bool { + ctx.input(|i| i.time) - app.sync_scanned_at >= SETTLE_SECONDS +} + /// How far the scan is (files probed, of how many). fn progress(app: &LightcraftApp) -> Option<(usize, usize)> { app.sync.as_ref().map(|t| t.progress.counts()) @@ -142,6 +264,9 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { _ => ui.label(crate::i18n::tr("Scanning…")), } }); + ui.label( + egui::RichText::new(crate::i18n::tr("You can go on working: a notice at the top left says when the scan is done.")).color(t.text_dim), + ); return; }; if c.offline { @@ -180,6 +305,24 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { /// thread at once ([`SyncRun`]); nothing is read from disk on the UI thread. A scan that went /// stale (the folder's photos changed meanwhile) is refused and made again, keeping the choices. pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { + if matches!(dlg, Dialog::SynchronizeFolder { counts: None, .. }) { + // Continue in Background: the scan goes on without the dialog, which the caller closes + return match &mut app.sync { + Some(task) => { + task.background = true; + if let Dialog::SynchronizeFolder { import_new, relink_moved, remove_missing, read_metadata, .. } = dlg { + task.choice = SyncChoice { + import_new: *import_new, + relink_moved: *relink_moved, + remove_missing: *remove_missing, + read_metadata: *read_metadata, + }; + } + Ok(json!({"background": true})) + } + None => Err("the folder is still being scanned".into()), + }; + } let Dialog::SynchronizeFolder { path, name, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return Err("the folder is still being scanned".into()); }; diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index b25ef6070..1a02a501a 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -302,3 +302,284 @@ fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() { h.settle(SETTLE); assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later"); } + +// ---- the scan need not be waited for ---- +// +// * While the folder is scanned, the dialog's button is Continue in Background: the dialog +// closes, the scan goes on under its row, and the app is free for other things. +// * When the scan is done nothing pops up: a notice in the activity stack says what was found. +// A click on it opens the dialog with that result; ✕ lets go of it. +// * A scan that found nothing to do only says so. +// * ✕ on the scan's row stops a background scan too, and no notice follows. + +/// A gate for the scan of `h`: it can't finish until the returned flag is set (reading the new +/// file waits on it). +fn gated(h: &mut Headless) -> std::sync::Arc { + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + gate +} + +/// Open the dialog on `trip` and press Continue in Background while the scan is held. +fn scan_in_background(h: &mut Headless, dir: &Scratch) { + crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap(); + let hidden = |h: &Headless| h.app.ui.dialog.is_none(); + for _ in 0..5 { + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + if h.step_until(Duration::from_secs(3), hidden) { + break; + } + } + assert!(h.app.ui.dialog.is_none(), "the dialog closes: {:?}", h.app.ui.dialog); + assert!(h.app.sync.is_some(), "and the scan goes on"); +} + +fn has_widget(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn click_widget(h: &mut Headless, id: &str, landed: impl Fn(&Headless) -> bool) { + for _ in 0..5 { + h.step(); + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + if h.step_until(Duration::from_secs(3), &landed) { + return; + } + } + panic!("the click on {id} never landed: {:?}", h.app.ui.dialog); +} + +#[test] +fn a_scan_continued_in_the_background_ends_in_a_notice_that_opens_its_result() { + let dir = Scratch::new("background"); + let mut h = changed_folder(&dir); + let gate = gated(&mut h); + scan_in_background(&mut h, &dir); + let rows = h.app.session.activity.list(); + assert_eq!((rows.len(), rows[0].label.as_str()), (1, "Looking for changes"), "its row stays: {rows:?}"); + + // meanwhile the app is free: a photo is rated, frames go by + let id = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").map(|p| p.id.0).unwrap(); + assert!(h.app.run("library.select", json!({"ids": [id]})).is_ok()); + assert!(h.app.run("photo.rate", json!({"rating": 3})).is_ok()); + for _ in 0..5 { + h.step(); + } + assert!(h.app.sync.is_some() && h.app.sync_ready.is_none(), "still scanning"); + + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.step_until(T, |h| h.app.sync.is_none()), "the scan ends"); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none(), "nothing pops up: {:?}", h.app.ui.dialog); + let ready = h.app.sync_ready.clone().expect("a notice waits"); + assert_eq!((ready.counts.new, ready.counts.missing, ready.changes()), (1, 1, 2), "{ready:?}"); + assert!(has_widget(&h, "activity:scanReady"), "shown in the activity stack"); + assert!(h.app.session.activity.list().is_empty(), "the scan's own row is gone"); + // it stays until it is looked at + for _ in 0..20 { + h.step(); + } + assert!(h.app.sync_ready.is_some() && h.app.session.folder_changes.is_some()); + + click_widget(&mut h, "activity:scanReady:review", |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))); + let c = counts(&h); + assert_eq!((c.new, c.missing), (1, 1), "the dialog shows what the scan found: {c:?}"); + assert!(h.app.sync_ready.is_none() && h.app.sync.is_none(), "without scanning again"); + // (with the rating undone the folder's photos are as scanned: the scan is still current) + h.app.session.undo_step().unwrap(); + synchronize(&mut h); + assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"]); +} + +#[test] +fn dismissing_the_notice_lets_go_of_what_the_scan_found() { + let dir = Scratch::new("dismiss"); + let mut h = changed_folder(&dir); + let gate = gated(&mut h); + scan_in_background(&mut h, &dir); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.step_until(T, |h| h.app.sync_ready.is_some()), "a notice waits"); + + click_widget(&mut h, "activity:scanReady:dismiss", |h| h.app.sync_ready.is_none()); + + assert!(h.app.session.folder_changes.is_none(), "the scan is let go of"); + assert!(h.app.ui.dialog.is_none()); + h.step(); + assert!(!has_widget(&h, "activity:scanReady")); + assert_eq!(in_library(&h), vec!["a.png", "b.png"], "nothing was changed"); +} + +#[test] +fn a_background_scan_that_found_nothing_only_says_so() { + let dir = Scratch::new("background-none"); + let mut h = changed_folder(&dir); + // bring the library up to date first + open_and_scan(&mut h, &dir.path("trip")); + tick_remove_missing(&mut h); + synchronize(&mut h); + h.settle(SETTLE); + + crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap(); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + // (pressed before the scan can have ended: no frame has run since it started) + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_ok(), "{r:?}"); + assert!(h.step_until(T, |h| h.app.sync.is_none()), "the scan ends"); + assert!(h.app.sync_ready.is_none(), "no notice: there is nothing to look at"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("up to date"), "{toast:?}"); + assert!(h.app.session.folder_changes.is_none()); +} + +#[test] +fn the_cross_on_its_row_stops_a_background_scan_without_a_notice() { + let dir = Scratch::new("background-stop"); + let mut h = changed_folder(&dir); + let gate = gated(&mut h); + scan_in_background(&mut h, &dir); + // another dialog is open meanwhile: it is not closed by the scan ending + h.app.ui.dialog = Some(Dialog::About); + let rows = h.app.session.activity.list(); + h.app.session.activity.cancel(rows[0].id).unwrap(); + h.step(); + assert!(h.app.sync.is_none(), "✕ stops the scan"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::About)), "{:?}", h.app.ui.dialog); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + h.app.ui.dialog = None; + h.settle(SETTLE); + assert!(h.app.sync_ready.is_none() && h.app.ui.dialog.is_none(), "a stopped scan says nothing later"); +} + +#[test] +fn opening_the_dialog_again_replaces_a_waiting_notice() { + let dir = Scratch::new("background-again"); + let mut h = changed_folder(&dir); + let gate = gated(&mut h); + scan_in_background(&mut h, &dir); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.step_until(T, |h| h.app.sync_ready.is_some())); + // Synchronize Folder… from the menu once more: a fresh scan, not two answers + open_and_scan(&mut h, &dir.path("trip")); + assert!(h.app.sync_ready.is_none(), "the old notice is gone"); + let c = counts(&h); + assert_eq!((c.new, c.missing), (1, 1), "{c:?}"); +} + +#[test] +fn a_result_looked_at_after_the_folder_changed_is_scanned_again_not_acted_on() { + let dir = Scratch::new("background-stale"); + let mut h = changed_folder(&dir); + let gate = gated(&mut h); + scan_in_background(&mut h, &dir); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.step_until(T, |h| h.app.sync_ready.is_some())); + // the person went on working in the folder before looking + let id = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").map(|p| p.id.0).unwrap(); + h.app.run("library.select", json!({"ids": [id]})).unwrap(); + h.app.run("photo.rate", json!({"rating": 3})).unwrap(); + crate::sync::review(&mut h.app); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + let before = in_library(&h); + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_err() && h.app.sync_run.is_none(), "what was shown is no longer so: {r:?}"); + assert_eq!(in_library(&h), before); + assert!(h.app.sync.is_some(), "it is scanned again"); +} + +#[test] +fn another_folder_is_not_scanned_over_a_background_scan_or_its_notice() { + let dir = Scratch::new("background-other"); + write_png(&dir.path("other/x.png"), 9); + let mut h = changed_folder(&dir); + h.app.session.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap(); + let gate = gated(&mut h); + scan_in_background(&mut h, &dir); + + let e = crate::sync::open(&mut h.app, &dir.path("other"), "other", false).unwrap_err(); + assert!(e.contains("still being scanned") && e.contains("trip"), "{e}"); + assert!(h.app.sync.is_some() && h.app.ui.dialog.is_none(), "the scan goes on"); + + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.step_until(T, |h| h.app.sync_ready.is_some())); + let e = crate::sync::open(&mut h.app, &dir.path("other"), "other", false).unwrap_err(); + assert!(e.contains("changes found in") && e.contains("trip"), "{e}"); + // and a folder that can't be synchronized at all takes nothing away either + assert!(crate::sync::open(&mut h.app, &dir.path("nowhere"), "nowhere", false).is_err()); + assert!(h.app.sync_ready.is_some() && h.app.session.folder_changes.is_some(), "the notice still waits"); + + // once it is dismissed the other folder can be scanned + crate::sync::dismiss(&mut h.app); + assert!(crate::sync::open(&mut h.app, &dir.path("other"), "other", false).is_ok()); +} + +#[test] +fn what_was_ticked_comes_back_with_the_result() { + let dir = Scratch::new("background-ticks"); + let mut h = changed_folder(&dir); + let gate = gated(&mut h); + crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap(); + // a dialog that had scanned before and scans again still has its ticks + let mut dlg = h.app.ui.dialog.take().expect("the dialog is open"); + if let Dialog::SynchronizeFolder { import_new, remove_missing, .. } = &mut dlg { + (*import_new, *remove_missing) = (false, true); + } + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.step_until(T, |h| h.app.sync_ready.is_some())); + // another dialog is open: the notice waits rather than take it away + h.app.ui.dialog = Some(Dialog::About); + crate::sync::review(&mut h.app); + assert!(matches!(h.app.ui.dialog, Some(Dialog::About)) && h.app.sync_ready.is_some()); + h.app.ui.dialog = None; + crate::sync::review(&mut h.app); + assert!( + matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { import_new: false, remove_missing: true, counts: Some(_), .. })), + "{:?}", + h.app.ui.dialog + ); + // closing the dialog lets go of the scan, as for one that was never in the background + h.app.ui.dialog = None; + h.step(); + assert!(h.app.session.folder_changes.is_none()); +} + +#[test] +fn a_folder_that_isnt_there_is_a_notice_too_and_another_library_gets_none() { + let dir = Scratch::new("background-offline"); + let mut h = changed_folder(&dir); + std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap(); + crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap(); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); + assert!(h.step_until(T, |h| h.app.sync_ready.is_some()), "a notice waits"); + assert!(h.app.sync_ready.as_ref().is_some_and(|r| r.counts.offline)); + + // File ▸ Open Library: the notice was about the library before + crate::sync::library_changed(&mut h.app); + assert!(h.app.sync_ready.is_none() && h.app.session.folder_changes.is_none()); +} + +#[test] +fn synchronize_waits_a_moment_after_its_button_stopped_being_continue_in_background() { + let dir = Scratch::new("settle"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + // a click meant for Continue in Background, landing as the scan ends + let before = in_library(&h); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.sync_run.is_none() && matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "nothing was started"); + assert_eq!(in_library(&h), before); + assert!(!crate::sync::settled(&h.app, &h.view.ctx)); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..add8c14ff 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LRC-LIB-LIBFOLDERS: Synchronize Folder's scan can continue in the background; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| **Total** | 408 | 51 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 172. ## Top gaps and shortcut notes @@ -693,7 +693,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Continue in Background closes the dialog while the scan goes on under its activity row, and its end is a notice in the activity stack (changes found, or folder not found) that opens the dialog with the result on a click and is let go of with ✕ — nothing pops up, a scan with nothing to do only says so in a toast, one folder at a time (another folder's Synchronize is refused while a background scan runs or its notice waits), another library opened lets go of it, and Synchronize is unavailable for 0.6 s after the scan ends so a click meant for Continue in Background can't start it; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..65ecd4055 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-11 +### Synchronize Folder: no need to wait for the scan +- While Synchronize Folder… scans a folder (a large one on a network share can take minutes), its button is + **Continue in Background**: the dialog closes, the scan goes on under its row in the activity stack, and you can + go on working. When it is done nothing pops up: a notice in the activity stack says how many changes were found. + Click it to open the dialog with the result, or ✕ to let go of it. A scan that found nothing to do only says so. + Cancel and the row's ✕ stop the scan as before. One folder is scanned at a time. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a