diff --git a/locales/de.json b/locales/de.json index d60d7252..d4e5c30e 100644 --- a/locales/de.json +++ b/locales/de.json @@ -216,9 +216,6 @@ "label": "Rezepte lokalen Modellen neu zuordnen", "loading": "Rezepte werden lokalen Modellen neu zugeordnet...", "success": "{entries} Einträge in {recipes} Rezepten zugeordnet", - "successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen", - "allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten", - "noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden", "cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)", "error": "Zuordnung der Rezepte fehlgeschlagen: {message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "Neu zuordnen" }, "rematchResults": { - "title": "Ergebnisse der neuen Zuordnung — Übereinstimmungen per Dateiname", - "message": "Diese Einträge wurden per Dateiname neu verbunden und verknüpfen möglicherweise eine andere Version eines Modells. Überprüfen Sie sie und machen Sie falsche Zuordnungen rückgängig.", "undo": "Rückgängig", "undone": "Rückgängig gemacht", "undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "Lokale Beispielbilder", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}", "batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}", "noRecipesSelected": "Keine Rezepte ausgewählt", - "rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet", - "rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen", - "rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten", - "rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden", "rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich", "rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}", "reimporting": "Rezept wird aus Quelle neu importiert...", diff --git a/locales/en.json b/locales/en.json index a11412e2..b6c9e93b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -216,9 +216,6 @@ "label": "Rematch recipes to local models", "loading": "Rematching recipes to local models...", "success": "Matched {entries} entries across {recipes} recipes", - "successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed", - "allFailed": "Rematch failed for {failures} of {total} recipes", - "noMatch": "No local match found for {entries} entries in {recipes} recipes", "cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).", "error": "Recipe rematch failed: {message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "Rematch" }, "rematchResults": { - "title": "Rematch Results — Filename Matches", - "message": "These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", "undo": "Undo", "undone": "Undone", "undoFailed": "Failed to undo rematch: {message}" }, + "rematchSummary": { + "title": "Rematch Summary", + "successMessage": "Matched {entries} entries", + "failed": "Rematch failed", + "completedWithWarnings": "Rematch completed — review recommended", + "cancelledNote": "Run cancelled before completion — counts are partial.", + "statMatched": "Matched entries", + "statReview": "Needs review", + "statUnresolved": "Unresolved", + "statErrors": "Errors", + "reviewSection": "Filename matches to review ({count})", + "columnRecipe": "Recipe", + "columnEntry": "Entry", + "columnFile": "Matched file", + "columnUndo": "Undo", + "copyReport": "Copy Report", + "close": "Close", + "scope_global": "All recipes", + "scope_bulk": "Selected recipes", + "scope_single": "Single recipe" + }, "exampleAccess": { "title": "Local Example Images", "message": "No local example images found for this model. View options:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "No recipes selected", - "rematchComplete": "Matched {entries} entries across {recipes} recipes", - "rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed", - "rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes", - "rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes", "rematchSkipped": "No rematch needed for any of the {total} selected recipes", "rematchFailed": "Failed to rematch selected recipes: {message}", "reimporting": "Re-importing recipe from source...", diff --git a/locales/es.json b/locales/es.json index 2cf32a98..003f83d9 100644 --- a/locales/es.json +++ b/locales/es.json @@ -216,9 +216,6 @@ "label": "Reasociar recetas con modelos locales", "loading": "Reasociando recetas con modelos locales...", "success": "{entries} entradas asociadas en {recipes} recetas", - "successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas", - "allFailed": "Falló la reasociación de {failures} de {total} recetas", - "noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas", "cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)", "error": "Falló la reasociación de recetas: {message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "Reasociar" }, "rematchResults": { - "title": "Resultados de la reasociación — Coincidencias por nombre de archivo", - "message": "Estas entradas se reconectaron por nombre de archivo y pueden enlazar una versión diferente de un modelo. Revísalas y deshaz las que sean incorrectas.", "undo": "Deshacer", "undone": "Deshecho", "undoFailed": "No se pudo deshacer la reasociación: {message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "Imágenes de ejemplo locales", "message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "No se pudo examinar el directorio: {message}", "batchImportDirectorySelected": "Directorio seleccionado: {path}", "noRecipesSelected": "No se han seleccionado recetas", - "rematchComplete": "{entries} entradas asociadas en {recipes} recetas", - "rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas", - "rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas", - "rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas", "rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación", "rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}", "reimporting": "Reimportando receta desde origen...", diff --git a/locales/fr.json b/locales/fr.json index 9d1fd5dd..7aceeb6d 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -216,9 +216,6 @@ "label": "Réassocier les Recipes aux modèles locaux", "loading": "Réassociation des Recipes aux modèles locaux...", "success": "{entries} entrées associées dans {recipes} Recipes", - "successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs", - "allFailed": "Échec de la réassociation de {failures} Recipes sur {total}", - "noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes", "cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)", "error": "Échec de la réassociation des Recipes : {message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "Réassocier" }, "rematchResults": { - "title": "Résultats de la réassociation — Correspondances par nom de fichier", - "message": "Ces entrées ont été reconnectées par nom de fichier et peuvent associer une version différente d'un modèle. Vérifiez-les et annulez celles qui sont incorrectes.", "undo": "Annuler", "undone": "Annulé", "undoFailed": "Échec de l'annulation de la réassociation : {message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "Images d'exemple locales", "message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}", "batchImportDirectorySelected": "Dossier sélectionné : {path}", "noRecipesSelected": "Aucune Recipe sélectionnée", - "rematchComplete": "{entries} entrées associées dans {recipes} Recipes", - "rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs", - "rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}", - "rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes", "rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation", "rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}", "reimporting": "Ré-import de la Recipe depuis la source...", diff --git a/locales/he.json b/locales/he.json index f990f61e..b33d12e4 100644 --- a/locales/he.json +++ b/locales/he.json @@ -216,9 +216,6 @@ "label": "התאמה מחדש של מתכונים למודלים מקומיים", "loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...", "success": "הותאמו {entries} פריטים ב־{recipes} מתכונים", - "successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו", - "allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים", - "noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים", "cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)", "error": "ההתאמה מחדש של המתכונים נכשלה: {message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "התאם מחדש" }, "rematchResults": { - "title": "תוצאות ההתאמה מחדש — התאמות לפי שם קובץ", - "message": "הפריטים האלה חוברו מחדש לפי שם קובץ וייתכן שהם מקושרים לגרסה אחרת של מודל. סקור אותם ובטל כל אחד שאינו נכון.", "undo": "בטל", "undone": "בוטל", "undoFailed": "ביטול ההתאמה מחדש נכשל: {message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "תמונות דוגמה מקומיות", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}", "batchImportDirectorySelected": "נבחרה תיקייה: {path}", "noRecipesSelected": "לא נבחרו מתכונים", - "rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים", - "rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו", - "rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו", - "rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים", "rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו", "rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}", "reimporting": "מייבא מתכון מחדש מהמקור...", diff --git a/locales/ja.json b/locales/ja.json index 75bd6e66..8b47cbf3 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -216,9 +216,6 @@ "label": "レシピをローカルモデルに再マッチング", "loading": "レシピをローカルモデルに再マッチングしています...", "success": "{recipes} 件のレシピで {entries} エントリをマッチングしました", - "successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)", - "allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました", - "noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした", "cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)", "error": "レシピの再マッチングに失敗しました:{message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "再マッチング" }, "rematchResults": { - "title": "再マッチング結果 — ファイル名によるマッチ", - "message": "これらのエントリはファイル名で再接続されたため、モデルの別バージョンに関連付けられている可能性があります。内容を確認し、誤っているものは元に戻してください。", "undo": "元に戻す", "undone": "元に戻しました", "undoFailed": "再マッチングを元に戻せませんでした:{message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "ローカル例画像", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "フォルダを参照できませんでした: {message}", "batchImportDirectorySelected": "選択されたフォルダ: {path}", "noRecipesSelected": "レシピが選択されていません", - "rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました", - "rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)", - "rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました", - "rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした", "rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした", "rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}", "reimporting": "ソースからレシピを再インポート中...", diff --git a/locales/ko.json b/locales/ko.json index 8d5fa2ed..584ec950 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -216,9 +216,6 @@ "label": "레시피를 로컬 모델에 다시 매칭", "loading": "레시피를 로컬 모델에 다시 매칭하는 중...", "success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다", - "successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패", - "allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패", - "noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다", "cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)", "error": "레시피 재매칭 실패: {message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "재매칭" }, "rematchResults": { - "title": "재매칭 결과 — 파일 이름 매칭", - "message": "이 항목들은 파일 이름으로 다시 연결되었으며 모델의 다른 버전에 연결되었을 수 있습니다. 검토 후 잘못된 항목은 실행 취소하세요.", "undo": "실행 취소", "undone": "실행 취소됨", "undoFailed": "재매칭 실행 취소 실패: {message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "로컬 예시 이미지", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}", "batchImportDirectorySelected": "선택한 폴더: {path}", "noRecipesSelected": "선택한 레시피가 없습니다", - "rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다", - "rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패", - "rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패", - "rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다", "rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다", "rematchFailed": "선택한 레시피 재매칭 실패: {message}", "reimporting": "소스에서 레시피를 다시 가져오는 중...", diff --git a/locales/ru.json b/locales/ru.json index 99bcd654..dc48da9d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -216,9 +216,6 @@ "label": "Повторное сопоставление рецептов с локальными моделями", "loading": "Повторное сопоставление рецептов с локальными моделями...", "success": "Сопоставлено записей: {entries} в рецептах: {recipes}", - "successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}", - "allFailed": "Не удалось сопоставить: {failures} из {total} рецептов", - "noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах", "cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})", "error": "Не удалось выполнить сопоставление рецептов: {message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "Сопоставить" }, "rematchResults": { - "title": "Результаты сопоставления — совпадения по имени файла", - "message": "Эти записи были переподключены по имени файла и могут ссылаться на другую версию модели. Проверьте их и отмените неверные.", "undo": "Отменить", "undone": "Отменено", "undoFailed": "Не удалось отменить сопоставление: {message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "Локальные примеры изображений", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "Не удалось открыть папку: {message}", "batchImportDirectorySelected": "Выбрана папка: {path}", "noRecipesSelected": "Рецепты не выбраны", - "rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}", - "rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}", - "rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов", - "rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах", "rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления", "rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}", "reimporting": "Переимпорт рецепта из источника...", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9939be78..a3926e2b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -216,9 +216,6 @@ "label": "将配方重新匹配到本地模型", "loading": "正在将配方重新匹配到本地模型...", "success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方", - "successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败", - "allFailed": "{failures}/{total} 个配方重新匹配失败", - "noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配", "cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。", "error": "配方重新匹配失败:{message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "重新匹配" }, "rematchResults": { - "title": "重新匹配结果——文件名匹配", - "message": "这些条目已按文件名重新关联,可能链接到了模型的其他版本。请检查它们,并撤销任何错误的关联。", "undo": "撤销", "undone": "已撤销", "undoFailed": "撤销重新匹配失败:{message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "本地示例图片", "message": "未找到此模型的本地示例图片。可选操作:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "浏览目录失败:{message}", "batchImportDirectorySelected": "已选择目录:{path}", "noRecipesSelected": "未选择任何配方", - "rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方", - "rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败", - "rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败", - "rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配", "rematchSkipped": "{total} 个所选配方均无需重新匹配", "rematchFailed": "重新匹配所选配方失败:{message}", "reimporting": "正在从源重新导入配方...", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index fc4ca9d5..2a31a0a2 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -216,9 +216,6 @@ "label": "將配方重新匹配到本地模型", "loading": "正在將配方重新匹配到本地模型...", "success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方", - "successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗", - "allFailed": "{failures}/{total} 個配方重新匹配失敗", - "noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配", "cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。", "error": "配方重新匹配失敗:{message}" }, @@ -1511,12 +1508,31 @@ "confirmButton": "重新匹配" }, "rematchResults": { - "title": "重新匹配結果——檔案名稱匹配", - "message": "這些條目已依檔案名稱重新關聯,可能連結到模型的不同版本。請檢閱這些條目,並撤銷任何錯誤的項目。", "undo": "撤銷", "undone": "已撤銷", "undoFailed": "撤銷重新匹配失敗:{message}" }, + "rematchSummary": { + "title": "[TODO: Translate] Rematch Summary", + "successMessage": "[TODO: Translate] Matched {entries} entries", + "failed": "[TODO: Translate] Rematch failed", + "completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended", + "cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.", + "statMatched": "[TODO: Translate] Matched entries", + "statReview": "[TODO: Translate] Needs review", + "statUnresolved": "[TODO: Translate] Unresolved", + "statErrors": "[TODO: Translate] Errors", + "reviewSection": "[TODO: Translate] Filename matches to review ({count})", + "columnRecipe": "[TODO: Translate] Recipe", + "columnEntry": "[TODO: Translate] Entry", + "columnFile": "[TODO: Translate] Matched file", + "columnUndo": "[TODO: Translate] Undo", + "copyReport": "[TODO: Translate] Copy Report", + "close": "[TODO: Translate] Close", + "scope_global": "[TODO: Translate] All recipes", + "scope_bulk": "[TODO: Translate] Selected recipes", + "scope_single": "[TODO: Translate] Single recipe" + }, "exampleAccess": { "title": "本機範例圖片", "message": "此模型未找到本機範例圖片。可選擇:", @@ -2239,10 +2255,6 @@ "batchImportBrowseFailed": "瀏覽目錄失敗:{message}", "batchImportDirectorySelected": "已選擇目錄:{path}", "noRecipesSelected": "未選取任何配方", - "rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方", - "rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗", - "rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗", - "rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配", "rematchSkipped": "{total} 個所選配方均無需重新匹配", "rematchFailed": "重新匹配所選配方失敗:{message}", "reimporting": "正在從來源重新匯入配方...", diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index 941ab4b1..bd37b52b 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -697,78 +697,40 @@ button:disabled, flex-shrink: 0; } -/* Recipe Rematch L4 Results Modal */ -#rematchResultsModal .modal-body { - padding: var(--space-3); +/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js; + stat cards / failure table / summary header come from + metadata-refresh-result.css and download-batch-summary.css). */ +.rematch-summary-modal { + max-width: 700px; } -#rematchResultsModal .confirmation-message { - color: var(--text-color); - margin-bottom: var(--space-3); - font-size: 1em; - line-height: 1.5; -} - -#rematchResultsModal .rematch-results-preview { - background: var(--surface-subtle); - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2) var(--space-3); -} - -#rematchResultsModal .rematch-results-list { - list-style: none; - padding: 0; - margin: 0; - max-height: 320px; - overflow-y: auto; -} - -#rematchResultsModal .rematch-results-row { +.rematch-cancelled-note { display: flex; - align-items: center; - justify-content: space-between; + align-items: flex-start; gap: var(--space-2); - padding: var(--space-2) 0; - border-bottom: 1px solid var(--border-color); - font-size: 0.9em; + margin: 0 0 var(--space-2) 0; + font-size: var(--text-sm); + color: var(--color-warning); } -#rematchResultsModal .rematch-results-row:last-child { - border-bottom: none; +.rematch-cancelled-note i { + margin-top: 2px; + flex-shrink: 0; } -#rematchResultsModal .rematch-results-info { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - flex: 1; +/* Review section heading uses the accent (review, not failure) instead of + the failure-section error color. */ +.rematch-review-section h4 { + color: var(--lora-accent); } -#rematchResultsModal .rematch-results-entry { - font-weight: 500; - color: var(--text-color); - overflow-wrap: anywhere; +#rematchSummaryModal .rematch-undo-btn { + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + white-space: nowrap; } -#rematchResultsModal .rematch-results-file { - color: var(--text-muted); - overflow-wrap: anywhere; -} - -#rematchResultsModal .rematch-results-recipe { - font-size: 0.85em; - opacity: 0.7; - color: var(--text-muted); - overflow-wrap: anywhere; -} - -#rematchResultsModal .rematch-results-row.undone .rematch-results-info { +#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) { text-decoration: line-through; opacity: 0.6; } - -#rematchResultsModal .rematch-results-undo { - flex-shrink: 0; -} diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index 130f70d5..36e5fad4 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -5,6 +5,7 @@ import { state } from '../../state/index.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { rematchModalManager } from '../../managers/RematchModalManager.js'; +import { showRematchSummary } from '../RematchSummaryModal.js'; export class GlobalContextMenu extends BaseContextMenu { constructor() { @@ -425,58 +426,37 @@ export class GlobalContextMenu extends BaseContextMenu { const recipes = p.matched_recipes ?? p.rematched ?? 0; const failures = p.errors || 0; const unresolved = p.unresolved_entries ?? 0; - if (entries > 0) { - const successKey = failures > 0 - ? 'globalContextMenu.rematchRecipes.successErrors' - : 'globalContextMenu.rematchRecipes.success'; - const successText = failures > 0 - ? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.` - : `Matched ${entries} entries across ${recipes} recipes.`; - progressUI?.complete(translate( - successKey, - { count: recipes, recipes, entries, failures }, - successText - )); - showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success'); - } else if (failures > 0) { - // Nothing matched and at least one recipe - // errored — "no rematch needed" would be - // actively misleading here. - progressUI?.complete(translate( - 'globalContextMenu.rematchRecipes.allFailed', - { total: p.total, recipes, entries, failures }, - `Rematch failed for ${failures} of ${p.total} recipes.` - )); - showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error'); - } else if (unresolved > 0) { - // Entries existed but have no local model — - // expected for models deleted from Civitai; - // informational, not an error. - const unresolvedRecipes = p.unresolved_recipes ?? 0; - progressUI?.complete(translate( - 'globalContextMenu.rematchRecipes.noMatch', - { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, - `No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.` - )); - showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info'); - } else { - // Everything was skipped (nothing to do). + const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : []; + // Complete no-op (nothing matched, nothing + // unresolved, no errors) keeps the lightweight + // toast; anything else opens the post-run summary + // modal. + const isNoop = entries === 0 && unresolved === 0 && failures === 0; + if (isNoop) { progressUI?.complete(translate( 'globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, `Matched ${entries} entries across ${recipes} recipes.` )); showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success'); + } else { + progressUI?.complete(); + showRematchSummary({ + scope: 'global', + total: p.total || 0, + matchedRecipes: recipes, + matchedEntries: entries, + unresolvedRecipes: p.unresolved_recipes ?? 0, + unresolvedEntries: unresolved, + skipped: p.skipped || 0, + errors: failures, + l4Matches, + }); } // Refresh recipes page if active if (window.recipesPage) { window.recipesPage.refresh(); } - // Filename-level (L4) matches are imprecise — - // always surface them for review/undo. - if (Array.isArray(p.l4_matches) && p.l4_matches.length > 0) { - rematchModalManager.showResultsModal(p.l4_matches); - } } else if (p.status === 'error') { throw new Error(p.error || 'Rematch failed'); } else if (p.status === 'cancelled') { @@ -488,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu { { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, `Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).` )); - showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info'); + // A cancelled run still reports partial results + // via the summary modal (marked as cancelled). + showRematchSummary({ + scope: 'global', + cancelled: true, + total: p.total || 0, + matchedRecipes: cancelledRecipes, + matchedEntries: cancelledEntries, + unresolvedRecipes: p.unresolved_recipes ?? 0, + unresolvedEntries: p.unresolved_entries ?? 0, + skipped: p.skipped || 0, + errors: p.errors || 0, + l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [], + }); + if (window.recipesPage) { + window.recipesPage.refresh(); + } } } else if (progressResponse.status === 404) { // Progress might have finished quickly and been cleaned up diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 87353652..c11e5ef5 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -7,6 +7,7 @@ import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { state } from '../../state/index.js'; import { moveManager } from '../../managers/MoveManager.js'; import { rematchModalManager } from '../../managers/RematchModalManager.js'; +import { showRematchSummary } from '../RematchSummaryModal.js'; import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js'; export class RecipeContextMenu extends BaseContextMenu { @@ -326,15 +327,14 @@ export class RecipeContextMenu extends BaseContextMenu { if (result.success) { const matchedEntries = result.matched_entries || result.rematched || 0; const failures = result.errors || 0; + const unresolvedEntries = result.unresolved_entries || 0; + const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : []; + // Complete no-op (nothing matched, nothing unresolved, no + // errors) keeps the lightweight toast; anything else opens + // the post-run summary modal. + const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0; + if (matchedEntries > 0) { - const toastKey = failures > 0 - ? 'toast.recipes.rematchCompleteErrors' - : 'toast.recipes.rematchComplete'; - showToast( - toastKey, - { rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures }, - failures > 0 ? 'warning' : 'success' - ); const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`); if (detailResponse.ok) { const updatedRecipe = await detailResponse.json(); @@ -342,21 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu { state.virtualScroller.updateSingleItem(filePath, updatedRecipe); } } - // Filename-level (L4) matches are imprecise — always - // surface them for review/undo. - if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) { - rematchModalManager.showResultsModal(result.l4_matches); - } - } else if (result.unresolved_entries > 0) { - // Entries existed but have no local model — expected for - // models deleted from Civitai; informational, not an error. - showToast( - 'toast.recipes.rematchUnmatched', - { entries: result.unresolved_entries, recipes: 1, total: 1 }, - 'info' - ); - } else { + } + + if (isNoop) { showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info'); + } else { + showRematchSummary({ + scope: 'single', + total: 1, + matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0), + matchedEntries, + unresolvedRecipes: result.unresolved_recipes || 0, + unresolvedEntries, + skipped: result.skipped || 0, + errors: failures, + l4Matches, + }); } } else { throw new Error(result.error || 'Rematch failed'); diff --git a/static/js/components/RematchSummaryModal.js b/static/js/components/RematchSummaryModal.js new file mode 100644 index 00000000..16752586 --- /dev/null +++ b/static/js/components/RematchSummaryModal.js @@ -0,0 +1,338 @@ +import { translate } from '../utils/i18nHelpers.js'; +import { showToast } from '../utils/uiHelpers.js'; + +/** + * Escape HTML entities in a string to prevent injection when interpolating + * into innerHTML (same approach as DownloadBatchSummaryModal). + * @param {string} str - The string to escape + * @returns {string} - The escaped string + */ +function _escapeHtml(str) { + if (str === null || str === undefined) return ''; + const div = document.createElement('div'); + div.textContent = String(str); + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); +} + +/** + * Resolve the 3-state summary header (mirrors the batch download/import + * summary semantics). + * + * - error: nothing matched and at least one recipe errored + * - warning: errors, unresolved entries, filename-level (L4) matches to + * review, or a cancelled run + * - success: otherwise + */ +function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) { + if (matchedEntries === 0 && errors > 0) { + return { + state: 'error', + icon: 'fa-times-circle', + text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'), + }; + } + if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) { + return { + state: 'warning', + icon: 'fa-exclamation-circle', + text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'), + }; + } + return { + state: 'success', + icon: 'fa-check-circle', + text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`), + }; +} + +/** + * Build a plain-text report of the rematch run. `undoneIndexes` carries the + * L4 rows undone so far, so the report reflects the undo status at copy time. + */ +function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) { + const scopeFallbacks = { + global: 'All recipes', + bulk: 'Selected recipes', + single: 'Single recipe', + }; + const scopeLabel = translate( + `modals.rematchSummary.scope_${scope}`, + {}, + scopeFallbacks[scope] || scope + ); + const lines = [ + '=== Recipe Rematch Report ===', + `Date: ${new Date().toLocaleString()}`, + `Scope: ${scopeLabel}`, + `Cancelled: ${cancelled ? 'yes' : 'no'}`, + `Total recipes: ${total}`, + `Matched recipes: ${matchedRecipes}`, + `Matched entries: ${matchedEntries}`, + `Needs review (filename matches): ${l4Matches.length}`, + `Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`, + `Skipped: ${skipped}`, + `Errors: ${errors}`, + '', + ]; + if (l4Matches.length > 0) { + lines.push('--- Filename matches (L4) ---'); + l4Matches.forEach((match, i) => { + const undone = undoneIndexes.has(i) ? ' [undone]' : ''; + lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`); + }); + lines.push(''); + } + lines.push('===================='); + return lines.join('\n'); +} + +/** + * Handle a successful clipboard write: confirm via toast and briefly swap the + * trigger button to a "Copied!" state (mirrors the batch summary modal). + */ +function _onCopyReportSuccess(btn) { + showToast('toast.api.copiedToClipboard', {}, 'success'); + if (btn) { + const origHTML = btn.innerHTML; + btn.innerHTML = ' Copied!'; + setTimeout(() => { btn.innerHTML = origHTML; }, 2000); + } +} + +/** + * Fallback for environments without the async Clipboard API (e.g. insecure + * contexts over LAN http): copy via a hidden textarea and execCommand. + */ +function _copyReportWithExecCommand(text) { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + showToast('toast.api.copiedToClipboard', {}, 'success'); +} + +function _copyReport(btn, reportArgs) { + const text = _buildReportText(reportArgs); + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard.writeText(text) + .then(() => _onCopyReportSuccess(btn)) + .catch(() => _copyReportWithExecCommand(text)); + } else { + _copyReportWithExecCommand(text); + } +} + +/** + * Undo a single L4 match via the existing restore endpoints (moved from + * RematchModalManager). Checkpoint restore needs only recipe_id; lora + * restore additionally takes lora_index. + */ +async function _undoMatch(match) { + const isCheckpoint = match.type === 'checkpoint'; + const body = isCheckpoint + ? { recipe_id: match.recipe_id } + : { recipe_id: match.recipe_id, lora_index: match.lora_index }; + const response = await fetch( + isCheckpoint + ? '/api/lm/recipe/checkpoint/restore' + : '/api/lm/recipe/lora/restore', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ); + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(result.error || 'Restore failed'); + } +} + +/** + * Show the post-run rematch summary modal. Mirrors the batch download + * summary lifecycle: the modal element is appended directly to + * document.body and removed on close; it is not registered with + * ModalManager. + * + * @param {Object} options + * @param {'global'|'bulk'|'single'} options.scope - Which entry point ran + * @param {boolean} options.cancelled - Whether the run was cancelled + * @param {number} options.total - Recipes scanned + * @param {number} options.matchedRecipes - Recipes updated + * @param {number} options.matchedEntries - Entries reconnected + * @param {number} options.unresolvedRecipes - Recipes with unresolved entries + * @param {number} options.unresolvedEntries - Candidate entries with no local match + * @param {number} options.skipped - Recipes left untouched + * @param {number} options.errors - Per-recipe errors + * @param {Array} options.l4Matches - Filename-level matches for review/undo + * ({ recipe_id, type, entry, file_name, lora_index? }) + */ +export function showRematchSummary({ + scope = 'global', + cancelled = false, + total = 0, + matchedRecipes = 0, + matchedEntries = 0, + unresolvedRecipes = 0, + unresolvedEntries = 0, + skipped = 0, + errors = 0, + l4Matches = [], +} = {}) { + const matches = Array.isArray(l4Matches) ? l4Matches : []; + const undoneIndexes = new Set(); + const header = _resolveHeader({ + matchedEntries, + errors, + unresolvedEntries, + l4Count: matches.length, + cancelled, + }); + + const matchRows = matches.map((match, i) => ` + + ${i + 1} + ${_escapeHtml(match.recipe_id)} + ${_escapeHtml(match.entry)} + ${_escapeHtml(match.file_name)} + + + + `).join(''); + + const modalHtml = ` + + `; + + const existing = document.getElementById('rematchSummaryModal'); + if (existing) existing.remove(); + + const container = document.createElement('div'); + container.innerHTML = modalHtml; + const modal = container.firstElementChild; + document.body.appendChild(modal); + + const reportArgs = { + scope, + cancelled, + total, + matchedRecipes, + matchedEntries, + unresolvedRecipes, + unresolvedEntries, + skipped, + errors, + l4Matches: matches, + undoneIndexes, + }; + + modal.addEventListener('click', async (e) => { + const actionEl = e.target.closest('[data-action]'); + const action = actionEl?.dataset.action; + if (!action) return; + e.preventDefault(); + + switch (action) { + case 'close-modal': + modal.remove(); + break; + case 'copy-report': + _copyReport(actionEl, reportArgs); + break; + case 'undo-match': { + const index = Number(actionEl.dataset.index); + const match = matches[index]; + if (!match || actionEl.disabled) break; + const row = modal.querySelector(`tr[data-l4-index="${index}"]`); + try { + await _undoMatch(match); + undoneIndexes.add(index); + row?.classList.add('undone'); + actionEl.disabled = true; + actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone'); + } catch (error) { + console.error('Failed to undo rematch match:', error); + showToast( + 'modals.rematchResults.undoFailed', + { message: error.message }, + 'error' + ); + } + break; + } + } + }); +} diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 6507e60d..c7bee250 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -4,6 +4,7 @@ import { handleUndoDelete } from '../utils/undoHelpers.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; import { rematchModalManager } from './RematchModalManager.js'; +import { showRematchSummary } from '../components/RematchSummaryModal.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; @@ -1025,47 +1026,32 @@ export class BulkManager { } } - if (matchedEntries > 0) { - const hasFailures = failures > 0; - const toastKey = hasFailures - ? 'toast.recipes.rematchCompleteErrors' - : 'toast.recipes.rematchComplete'; - showToast( - toastKey, - { rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures }, - hasFailures ? 'warning' : 'success' - ); - } else if (failures > 0) { - // Nothing matched and at least one recipe errored — - // "no rematch needed" would be actively misleading here. - showToast( - 'toast.recipes.rematchAllFailed', - { total, failures }, - 'error' - ); - } else if (unresolvedEntries > 0) { - // Entries existed but have no local model — expected for - // models deleted from Civitai; informational, not an error. - showToast( - 'toast.recipes.rematchUnmatched', - { entries: unresolvedEntries, recipes: unresolvedRecipes, total }, - 'info' - ); - } else { + // Complete no-op (nothing matched, nothing unresolved, no + // errors) keeps the lightweight toast; anything else opens + // the post-run summary modal. + const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : []; + const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0; + if (isNoop) { showToast( 'toast.recipes.rematchSkipped', { total }, 'info' ); + } else { + showRematchSummary({ + scope: 'bulk', + total, + matchedRecipes, + matchedEntries, + unresolvedRecipes, + unresolvedEntries, + skipped, + errors: failures, + l4Matches, + }); } if (state.bulkMode) this.toggleBulkMode(); - - // Filename-level (L4) matches are imprecise — always surface - // them for review/undo. - if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) { - rematchModalManager.showResultsModal(result.l4_matches); - } } else { throw new Error(result.error || 'Bulk rematch failed'); } diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index e91efa83..45ad2cc3 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -360,19 +360,6 @@ export class ModalManager { }); } - // Register rematchResultsModal - const rematchResultsModal = document.getElementById('rematchResultsModal'); - if (rematchResultsModal) { - this.registerModal('rematchResultsModal', { - element: rematchResultsModal, - onClose: () => { - this.getModal('rematchResultsModal').element.style.display = 'none'; - document.body.classList.remove('modal-open'); - }, - closeOnOutsideClick: true - }); - } - document.addEventListener('keydown', this.boundHandleEscape); this.initialized = true; } diff --git a/static/js/managers/RematchModalManager.js b/static/js/managers/RematchModalManager.js index 269b8881..64f9b62e 100644 --- a/static/js/managers/RematchModalManager.js +++ b/static/js/managers/RematchModalManager.js @@ -1,21 +1,15 @@ import { modalManager } from './ModalManager.js'; import { translate } from '../utils/i18nHelpers.js'; -import { showToast } from '../utils/uiHelpers.js'; /** - * Owns the two recipe-rematch modals: - * - * - rematchOptionsModal — shown BEFORE a global/bulk/single rematch run; - * collects the "relaxed matching" opt-in and only then invokes the run - * callback. - * - rematchResultsModal — shown AFTER a run that produced L4 (filename - * level) matches; lists them for review with a per-row Undo that calls - * the existing restore endpoints. + * Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE + * a global/bulk/single rematch run; collects the "relaxed matching" opt-in + * and only then invokes the run callback. Post-run reporting lives in + * static/js/components/RematchSummaryModal.js. */ export class RematchModalManager { constructor() { this._optionsConfirmCallback = null; - this._resultsMatches = []; } /** @@ -73,99 +67,6 @@ export class RematchModalManager { this._optionsConfirmCallback = null; modalManager.closeModal('rematchOptionsModal'); } - - /** - * Open the results modal listing L4 (filename-level) matches. - * - * @param {Array<{recipe_id: string, type: string, entry: string, file_name: string, lora_index?: number}>} l4Matches - */ - showResultsModal(l4Matches) { - if (!Array.isArray(l4Matches) || l4Matches.length === 0) { - return; - } - const list = document.getElementById('rematchResultsList'); - if (!list) { - return; - } - this._resultsMatches = l4Matches; - list.innerHTML = ''; - - l4Matches.forEach((match, index) => { - const row = document.createElement('li'); - row.className = 'rematch-results-row'; - - const info = document.createElement('div'); - info.className = 'rematch-results-info'; - - const entryName = document.createElement('span'); - entryName.className = 'rematch-results-entry'; - entryName.textContent = match.entry || ''; - - const matchedFile = document.createElement('span'); - matchedFile.className = 'rematch-results-file'; - matchedFile.textContent = `→ ${match.file_name || ''}`; - - const recipeRef = document.createElement('span'); - recipeRef.className = 'rematch-results-recipe'; - recipeRef.textContent = match.recipe_id || ''; - - info.appendChild(entryName); - info.appendChild(matchedFile); - info.appendChild(recipeRef); - - const undoButton = document.createElement('button'); - undoButton.className = 'secondary-btn rematch-results-undo'; - undoButton.textContent = translate('modals.rematchResults.undo', {}, 'Undo'); - undoButton.addEventListener('click', () => this.undoMatch(index, row, undoButton)); - - row.appendChild(info); - row.appendChild(undoButton); - list.appendChild(row); - }); - - modalManager.showModal('rematchResultsModal'); - } - - /** - * Undo a single L4 match via the existing restore endpoints. On success - * the row is struck through and its button disabled. - */ - async undoMatch(index, row, button) { - const match = this._resultsMatches[index]; - if (!match || button.disabled) { - return; - } - try { - const isCheckpoint = match.type === 'checkpoint'; - const body = isCheckpoint - ? { recipe_id: match.recipe_id } - : { recipe_id: match.recipe_id, lora_index: match.lora_index }; - const response = await fetch( - isCheckpoint - ? '/api/lm/recipe/checkpoint/restore' - : '/api/lm/recipe/lora/restore', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - } - ); - const result = await response.json(); - if (!response.ok || !result.success) { - throw new Error(result.error || 'Restore failed'); - } - row.classList.add('undone'); - button.disabled = true; - button.textContent = translate('modals.rematchResults.undone', {}, 'Undone'); - } catch (error) { - console.error('Failed to undo rematch match:', error); - showToast( - 'modals.rematchResults.undoFailed', - { message: error.message }, - 'error' - ); - } - } } export const rematchModalManager = new RematchModalManager(); diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index 0385e2bc..d613fffd 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -157,22 +157,3 @@ - - - \ No newline at end of file diff --git a/tests/frontend/components/RematchSummaryModal.test.js b/tests/frontend/components/RematchSummaryModal.test.js new file mode 100644 index 00000000..b0cd1cb9 --- /dev/null +++ b/tests/frontend/components/RematchSummaryModal.test.js @@ -0,0 +1,233 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => { + if (typeof fallback === 'string') { + // Apply {param} interpolation so counts remain assertable. + return Object.entries(params || {}).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return key; +}); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, +})); + +async function getShowRematchSummary() { + const { showRematchSummary } = await import( + '../../../static/js/components/RematchSummaryModal.js' + ); + return showRematchSummary; +} + +const L4_LORA = { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 }; +const L4_CHECKPOINT = { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' }; + +describe('RematchSummaryModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + global.fetch = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete global.fetch; + delete navigator.clipboard; + vi.restoreAllMocks(); + }); + + it('renders a success header when everything matched cleanly', async () => { + const showRematchSummary = await getShowRematchSummary(); + showRematchSummary({ scope: 'global', total: 10, matchedRecipes: 2, matchedEntries: 3 }); + + const modal = document.getElementById('rematchSummaryModal'); + expect(modal).not.toBeNull(); + expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true); + expect(modal.querySelector('.summary-title').textContent).toBe('Matched 3 entries'); + expect(modal.querySelector('.failure-table')).toBeNull(); + }); + + it('renders an error header when nothing matched and errors occurred', async () => { + const showRematchSummary = await getShowRematchSummary(); + showRematchSummary({ scope: 'global', total: 3, errors: 3 }); + + const modal = document.getElementById('rematchSummaryModal'); + expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true); + expect(modal.querySelector('.summary-title').textContent).toBe('Rematch failed'); + }); + + it('renders a warning header for unresolved entries, L4 matches, or cancellations', async () => { + const showRematchSummary = await getShowRematchSummary(); + + showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 1, unresolvedEntries: 1, unresolvedRecipes: 1 }); + expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true); + + showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA] }); + expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true); + + showRematchSummary({ scope: 'global', total: 5, matchedEntries: 2, cancelled: true }); + const modal = document.getElementById('rematchSummaryModal'); + expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(modal.querySelector('.rematch-cancelled-note')).not.toBeNull(); + }); + + it('renders the four stat cards in order', async () => { + const showRematchSummary = await getShowRematchSummary(); + showRematchSummary({ + scope: 'bulk', + total: 4, + matchedRecipes: 1, + matchedEntries: 2, + unresolvedEntries: 3, + errors: 1, + l4Matches: [L4_LORA], + }); + + const modal = document.getElementById('rematchSummaryModal'); + const values = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent); + expect(values).toEqual(['2', '1', '3', '1']); + const labels = Array.from(modal.querySelectorAll('.stat-card-label')).map(el => el.textContent); + expect(labels).toEqual(['Matched entries', 'Needs review', 'Unresolved', 'Errors']); + }); + + it('renders the L4 review table only when matches exist', async () => { + const showRematchSummary = await getShowRematchSummary(); + showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA, L4_CHECKPOINT] }); + + const modal = document.getElementById('rematchSummaryModal'); + const rows = modal.querySelectorAll('.failure-table tbody tr'); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain('r1'); + expect(rows[0].textContent).toContain('old.safetensors'); + expect(rows[0].textContent).toContain('new.safetensors'); + expect(rows[1].textContent).toContain('cp-new.safetensors'); + expect(modal.querySelectorAll('.rematch-undo-btn')).toHaveLength(2); + }); + + it('undo posts to the lora restore endpoint, then strikes and disables the row', async () => { + const showRematchSummary = await getShowRematchSummary(); + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + + showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] }); + + const modal = document.getElementById('rematchSummaryModal'); + const row = modal.querySelector('tr[data-l4-index="0"]'); + const button = row.querySelector('.rematch-undo-btn'); + button.click(); + await vi.waitFor(() => expect(button.disabled).toBe(true)); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }), + }); + expect(row.classList.contains('undone')).toBe(true); + expect(button.textContent).toBe('Undone'); + }); + + it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => { + const showRematchSummary = await getShowRematchSummary(); + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + + showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_CHECKPOINT] }); + + const button = document.querySelector('.rematch-undo-btn'); + button.click(); + await vi.waitFor(() => expect(button.disabled).toBe(true)); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipe_id: 'r2' }), + }); + }); + + it('keeps the row actionable and toasts when undo fails', async () => { + const showRematchSummary = await getShowRematchSummary(); + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: false, error: 'no snapshot' }) }); + + showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] }); + + const row = document.querySelector('tr[data-l4-index="0"]'); + const button = row.querySelector('.rematch-undo-btn'); + button.click(); + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled()); + + expect(button.disabled).toBe(false); + expect(row.classList.contains('undone')).toBe(false); + expect(showToastMock).toHaveBeenCalledWith( + 'modals.rematchResults.undoFailed', + { message: 'no snapshot' }, + 'error' + ); + }); + + it('copy report includes scope, counts and the L4 list with undo status', async () => { + const showRematchSummary = await getShowRematchSummary(); + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + const writeText = vi.fn().mockResolvedValue(undefined); + navigator.clipboard = { writeText }; + + showRematchSummary({ + scope: 'bulk', + total: 2, + matchedRecipes: 1, + matchedEntries: 2, + unresolvedEntries: 1, + unresolvedRecipes: 1, + skipped: 0, + errors: 0, + l4Matches: [L4_LORA, L4_CHECKPOINT], + }); + + // Undo the first row before copying so the report carries its status. + const undoButton = document.querySelector('tr[data-l4-index="0"] .rematch-undo-btn'); + undoButton.click(); + await vi.waitFor(() => expect(undoButton.disabled).toBe(true)); + + document.querySelector('[data-action="copy-report"]').click(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalled()); + + const report = writeText.mock.calls[0][0]; + expect(report).toContain('Scope: Selected recipes'); + expect(report).toContain('Total recipes: 2'); + expect(report).toContain('Matched entries: 2'); + expect(report).toContain('Needs review (filename matches): 2'); + expect(report).toContain('Unresolved entries: 1 (in 1 recipes)'); + expect(report).toContain('[r1] old.safetensors -> new.safetensors [undone]'); + expect(report).toContain('[r2] cp-old -> cp-new.safetensors'); + // The success toast fires in the writeText .then() microtask. + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success')); + }); + + it('close removes the modal from the DOM', async () => { + const showRematchSummary = await getShowRematchSummary(); + showRematchSummary({ scope: 'single', total: 1, matchedEntries: 1 }); + + expect(document.getElementById('rematchSummaryModal')).not.toBeNull(); + document.querySelector('[data-action="close-modal"].cancel-btn').click(); + expect(document.getElementById('rematchSummaryModal')).toBeNull(); + }); + + it('escapes HTML in L4 row fields', async () => { + const showRematchSummary = await getShowRematchSummary(); + showRematchSummary({ + scope: 'bulk', + total: 1, + matchedEntries: 1, + l4Matches: [{ recipe_id: 'r', type: 'lora', entry: '', file_name: 'f.safetensors', lora_index: 0 }], + }); + + const modal = document.getElementById('rematchSummaryModal'); + expect(modal.querySelector('.failure-table img')).toBeNull(); + expect(modal.querySelector('.failure-table tbody tr').textContent).toContain(''); + }); +}); diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index 61c33ace..3dba66e4 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -2233,7 +2233,7 @@ describe('Interaction-level regression coverage', () => { expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true }); }); - it('runs global recipe rematch with polling and toasts the rematched count', async () => { + it('runs global recipe rematch with polling and opens the summary modal', async () => { document.body.innerHTML = `
@@ -2298,13 +2298,22 @@ describe('Interaction-level regression coverage', () => { expect(global.fetch).toHaveBeenCalledTimes(2); expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1); - expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes.'); - // Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined - expect(showToastMock).toHaveBeenCalledWith( + // A non-noop run opens the summary modal instead of toasting; the + // progress overlay completes without a message. + expect(progressUI.complete).toHaveBeenCalledWith(); + expect(showToastMock).not.toHaveBeenCalledWith( 'globalContextMenu.rematchRecipes.success', - { count: 2, recipes: 2, entries: 5, failures: 0 }, - 'success' + expect.anything(), + expect.anything() ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + // unresolved_entries > 0 forces the warning header + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('5'); + expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('0'); + expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1'); + expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0'); expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1); expect(rematchItem.classList.contains('disabled')).toBe(false); expect(menu._rematchInProgress).toBe(false); @@ -2313,7 +2322,7 @@ describe('Interaction-level regression coverage', () => { delete stateStub.currentPageType; }); - it('uses the warning toast variant when a global rematch completes with failures', async () => { + it('opens the summary modal with a warning header when a global rematch completes with failures', async () => { document.body.innerHTML = `
@@ -2359,19 +2368,18 @@ describe('Interaction-level regression coverage', () => { } await runPromise; - expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.'); - expect(showToastMock).toHaveBeenCalledWith( - 'globalContextMenu.rematchRecipes.successErrors', - { count: 2, recipes: 2, entries: 5, failures: 2 }, - 'warning' - ); + expect(progressUI.complete).toHaveBeenCalledWith(); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2'); expect(menu._rematchInProgress).toBe(false); delete window.recipesPage; delete stateStub.currentPageType; }); - it('toasts an error when every recipe in a global rematch failed', async () => { + it('opens the summary modal with an error header when every recipe in a global rematch failed', async () => { document.body.innerHTML = `
@@ -2417,19 +2425,18 @@ describe('Interaction-level regression coverage', () => { } await runPromise; - expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.'); - expect(showToastMock).toHaveBeenCalledWith( - 'globalContextMenu.rematchRecipes.allFailed', - { total: 3, recipes: 0, entries: 0, failures: 3 }, - 'error' - ); + expect(progressUI.complete).toHaveBeenCalledWith(); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true); + expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('3'); expect(menu._rematchInProgress).toBe(false); delete window.recipesPage; delete stateStub.currentPageType; }); - it('toasts an info message when a global rematch found no local matches', async () => { + it('opens the summary modal listing unresolved entries when a global rematch found no local matches', async () => { document.body.innerHTML = `
@@ -2475,19 +2482,18 @@ describe('Interaction-level regression coverage', () => { } await runPromise; - expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.'); - expect(showToastMock).toHaveBeenCalledWith( - 'globalContextMenu.rematchRecipes.noMatch', - { entries: 2, recipes: 1, total: 3, failures: 0 }, - 'info' - ); + expect(progressUI.complete).toHaveBeenCalledWith(); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2'); expect(menu._rematchInProgress).toBe(false); delete window.recipesPage; delete stateStub.currentPageType; }); - it('toasts the rematched count when a global rematch is cancelled', async () => { + it('opens the summary modal marked as cancelled when a global rematch is cancelled', async () => { document.body.innerHTML = `
@@ -2533,11 +2539,11 @@ describe('Interaction-level regression coverage', () => { await runPromise; expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).'); - expect(showToastMock).toHaveBeenCalledWith( - 'globalContextMenu.rematchRecipes.cancelled', - { count: 1, recipes: 1, entries: 2 }, - 'info' - ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.rematch-cancelled-note')).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2'); expect(menu._rematchInProgress).toBe(false); delete stateStub.currentPageType; diff --git a/tests/frontend/components/recipeContextMenu.rematch.test.js b/tests/frontend/components/recipeContextMenu.rematch.test.js index 0aea586b..909155be 100644 --- a/tests/frontend/components/recipeContextMenu.rematch.test.js +++ b/tests/frontend/components/recipeContextMenu.rematch.test.js @@ -60,13 +60,6 @@ async function cancelRematchOptions() { rematchModalManager.cancelOptions(); } -async function getRematchModalManager() { - const { rematchModalManager } = await import( - '../../../static/js/managers/RematchModalManager.js' - ); - return rematchModalManager; -} - describe('RecipeContextMenu.rematchRecipe', () => { beforeEach(() => { vi.clearAllMocks(); @@ -92,8 +85,8 @@ describe('RecipeContextMenu.rematchRecipe', () => { } // Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired` - // mirror would fire the skipped toast here. - it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => { + // mirror would render 0 matched entries in the summary modal here. + it('posts to the per-recipe rematch endpoint and opens the summary modal', async () => { const menu = await createMenu(); const card = document.getElementById('card'); menu.showMenu(100, 100, card); @@ -125,16 +118,22 @@ describe('RecipeContextMenu.rematchRecipe', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ relaxed: false }), }); - expect(showToastMock).toHaveBeenCalledWith( + // Non-noop runs open the summary modal instead of toasting. + expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.rematchComplete', - { rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 }, - 'success' + expect.anything(), + expect.anything() ); expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.rematchSkipped', expect.anything(), expect.anything() ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('success')).toBe(true); + expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2'); + expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0'); expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1'); expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', { id: 'recipe-1', @@ -142,7 +141,7 @@ describe('RecipeContextMenu.rematchRecipe', () => { }); }); - it('toasts an info message when the entries had no local match', async () => { + it('opens the summary modal when the entries had no local match', async () => { const menu = await createMenu(); const card = document.getElementById('card'); menu.showMenu(100, 100, card); @@ -160,11 +159,11 @@ describe('RecipeContextMenu.rematchRecipe', () => { await confirmRematchOptions(); await flushAsyncTasks(); - expect(showToastMock).toHaveBeenCalledWith( - 'toast.recipes.rematchUnmatched', - { entries: 2, recipes: 1, total: 1 }, - 'info' - ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0'); + expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2'); expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.rematchSkipped', expect.anything(), @@ -303,7 +302,7 @@ describe('RecipeContextMenu.rematchRecipe', () => { expect(global.fetch).not.toHaveBeenCalled(); }); - it('shows the results modal when the result carries l4_matches', async () => { + it('lists L4 filename matches in the summary modal with undo buttons', async () => { const menu = await createMenu(); const card = document.getElementById('card'); menu.showMenu(100, 100, card); @@ -321,11 +320,6 @@ describe('RecipeContextMenu.rematchRecipe', () => { json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }), }); - const rematchModalManager = await getRematchModalManager(); - const showResultsSpy = vi - .spyOn(rematchModalManager, 'showResultsModal') - .mockImplementation(() => {}); - document .querySelector('[data-action="rematch"]') .dispatchEvent(new Event('click', { bubbles: true })); @@ -334,7 +328,15 @@ describe('RecipeContextMenu.rematchRecipe', () => { await confirmRematchOptions(); await flushAsyncTasks(); - expect(showResultsSpy).toHaveBeenCalledWith(l4Matches); - showResultsSpy.mockRestore(); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + // L4 matches to review force the warning header + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1'); + const rows = summaryModal.querySelectorAll('tr[data-l4-index]'); + expect(rows).toHaveLength(1); + expect(rows[0].textContent).toContain('old.safetensors'); + expect(rows[0].textContent).toContain('new.safetensors'); + expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull(); }); }); diff --git a/tests/frontend/managers/BulkManager.rematch.test.js b/tests/frontend/managers/BulkManager.rematch.test.js index f33480f4..147dbade 100644 --- a/tests/frontend/managers/BulkManager.rematch.test.js +++ b/tests/frontend/managers/BulkManager.rematch.test.js @@ -95,6 +95,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { stateStub.currentPageType = 'recipes'; stateStub.bulkMode = false; stateStub.selectedModels.clear(); + document.body.innerHTML = ''; }); async function createBulkManager() { @@ -107,9 +108,9 @@ describe('BulkManager.rematchSelectedRecipes', () => { expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true); }); - // Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind - // `repaired` mirror would fire the skipped toast with count 0 here. - it('toasts the rematched count when the bulk rematch succeeds', async () => { + // Oracle R4-F1 pin: the summary modal must branch on `matched_entries` — a + // blind `repaired` mirror would render 0 matched entries here. + it('opens the summary modal when the bulk rematch succeeds', async () => { const bulk = await createBulkManager(); stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/b.webp'); @@ -140,23 +141,31 @@ describe('BulkManager.rematchSelectedRecipes', () => { ], { relaxed: false } ); - expect(showToastMock).toHaveBeenCalledWith( + // Non-noop runs open the summary modal instead of toasting. + expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.rematchComplete', - { rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 }, - 'success' + expect.anything(), + expect.anything() ); expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.rematchSkipped', expect.anything(), expect.anything() ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + // unresolved_entries > 0 forces the warning header + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('4'); + expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1'); + expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0'); expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe); expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled(); expect(loadingManagerStub.hide).toHaveBeenCalled(); expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled(); }); - it('uses the errors toast variant when the bulk rematch has failures', async () => { + it('opens the summary modal with a warning header when the bulk rematch has failures', async () => { const bulk = await createBulkManager(); stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/b.webp'); @@ -177,14 +186,14 @@ describe('BulkManager.rematchSelectedRecipes', () => { await bulk.rematchSelectedRecipes(); await confirmRematchOptions(); - expect(showToastMock).toHaveBeenCalledWith( - 'toast.recipes.rematchCompleteErrors', - { rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 }, - 'warning' - ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('3'); + expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2'); }); - it('toasts an error when every selected recipe failed to rematch', async () => { + it('opens the summary modal with an error header when every selected recipe failed to rematch', async () => { const bulk = await createBulkManager(); stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/b.webp'); @@ -205,11 +214,10 @@ describe('BulkManager.rematchSelectedRecipes', () => { await bulk.rematchSelectedRecipes(); await confirmRematchOptions(); - expect(showToastMock).toHaveBeenCalledWith( - 'toast.recipes.rematchAllFailed', - { total: 2, failures: 2 }, - 'error' - ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true); + expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2'); expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.rematchSkipped', expect.anything(), @@ -217,7 +225,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { ); }); - it('toasts an info message when entries had no local match', async () => { + it('opens the summary modal when entries had no local match', async () => { const bulk = await createBulkManager(); stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/b.webp'); @@ -239,11 +247,11 @@ describe('BulkManager.rematchSelectedRecipes', () => { await bulk.rematchSelectedRecipes(); await confirmRematchOptions(); - expect(showToastMock).toHaveBeenCalledWith( - 'toast.recipes.rematchUnmatched', - { entries: 2, recipes: 1, total: 3 }, - 'info' - ); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0'); + expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2'); expect(showToastMock).not.toHaveBeenCalledWith( 'toast.recipes.rematchSkipped', expect.anything(), @@ -409,7 +417,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { ); }); - it('shows the L4 results modal when the bulk result carries l4_matches', async () => { + it('lists L4 filename matches in the summary modal with undo buttons', async () => { const bulk = await createBulkManager(); stateStub.selectedModels.add('/recipes/a.webp'); @@ -428,17 +436,18 @@ describe('BulkManager.rematchSelectedRecipes', () => { l4_matches: l4Matches, }); - const { rematchModalManager } = await import( - '../../../static/js/managers/RematchModalManager.js' - ); - const showResultsSpy = vi - .spyOn(rematchModalManager, 'showResultsModal') - .mockImplementation(() => {}); - await bulk.rematchSelectedRecipes(); await confirmRematchOptions(); - expect(showResultsSpy).toHaveBeenCalledWith(l4Matches); - showResultsSpy.mockRestore(); + const summaryModal = document.getElementById('rematchSummaryModal'); + expect(summaryModal).not.toBeNull(); + // L4 matches to review force the warning header + expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true); + expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1'); + const rows = summaryModal.querySelectorAll('tr[data-l4-index]'); + expect(rows).toHaveLength(1); + expect(rows[0].textContent).toContain('old.safetensors'); + expect(rows[0].textContent).toContain('new.safetensors'); + expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull(); }); }); diff --git a/tests/frontend/managers/RematchModalManager.test.js b/tests/frontend/managers/RematchModalManager.test.js index 6b3292eb..e0e2ca0e 100644 --- a/tests/frontend/managers/RematchModalManager.test.js +++ b/tests/frontend/managers/RematchModalManager.test.js @@ -116,111 +116,3 @@ describe('RematchModalManager options dialog', () => { }); }); -describe('RematchModalManager results modal', () => { - beforeEach(() => { - vi.clearAllMocks(); - document.body.innerHTML = '
    '; - global.fetch = vi.fn(); - }); - - afterEach(() => { - document.body.innerHTML = ''; - delete global.fetch; - }); - - it('does nothing for an empty match list', async () => { - const manager = await getManager(); - - manager.showResultsModal([]); - - expect(modalManagerMock.showModal).not.toHaveBeenCalled(); - }); - - it('renders one row per L4 match with entry, file name and recipe', async () => { - const manager = await getManager(); - - manager.showResultsModal([ - { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 }, - { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' }, - ]); - - const rows = document.querySelectorAll('#rematchResultsList .rematch-results-row'); - expect(rows).toHaveLength(2); - expect(rows[0].textContent).toContain('old.safetensors'); - expect(rows[0].textContent).toContain('new.safetensors'); - expect(rows[0].textContent).toContain('r1'); - expect(rows[1].textContent).toContain('cp-new.safetensors'); - expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchResultsModal'); - }); - - it('undo posts to the lora restore endpoint and disables the row', async () => { - const manager = await getManager(); - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - }); - - manager.showResultsModal([ - { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 }, - ]); - - const row = document.querySelector('.rematch-results-row'); - const button = row.querySelector('.rematch-results-undo'); - button.click(); - await vi.waitFor(() => expect(button.disabled).toBe(true)); - - expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }), - }); - expect(row.classList.contains('undone')).toBe(true); - }); - - it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => { - const manager = await getManager(); - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - }); - - manager.showResultsModal([ - { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' }, - ]); - - const button = document.querySelector('.rematch-results-undo'); - button.click(); - await vi.waitFor(() => expect(button.disabled).toBe(true)); - - expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ recipe_id: 'r2' }), - }); - }); - - it('keeps the row actionable and toasts when undo fails', async () => { - const manager = await getManager(); - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: false, error: 'no snapshot' }), - }); - - manager.showResultsModal([ - { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 }, - ]); - - const row = document.querySelector('.rematch-results-row'); - const button = row.querySelector('.rematch-results-undo'); - button.click(); - await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled()); - - expect(button.disabled).toBe(false); - expect(row.classList.contains('undone')).toBe(false); - expect(showToastMock).toHaveBeenCalledWith( - 'modals.rematchResults.undoFailed', - { message: 'no snapshot' }, - 'error' - ); - }); -});