diff --git a/locales/de.json b/locales/de.json index 5008d46f..c47186c7 100644 --- a/locales/de.json +++ b/locales/de.json @@ -187,11 +187,14 @@ "error": "Recipe-Reparatur fehlgeschlagen: {message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "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}" }, "manageExcludedModels": { "label": "Ausgeschlossene Modelle verwalten" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})", "repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich", "repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "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...", "reimportSuccess": "Rezept erfolgreich neu importiert", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", diff --git a/locales/en.json b/locales/en.json index 770b4dfe..e54eaa12 100644 --- a/locales/en.json +++ b/locales/en.json @@ -189,8 +189,11 @@ "rematchRecipes": { "label": "Rematch recipes to local models", "loading": "Rematching recipes to local models...", - "success": "Successfully rematched {count} recipes.", - "cancelled": "Rematch cancelled. {count} recipes were rematched.", + "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}" }, "manageExcludedModels": { @@ -1960,7 +1963,10 @@ "repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})", "repairBulkSkipped": "No repair needed for any of the {total} selected recipes", "repairBulkFailed": "Failed to repair selected recipes: {message}", - "rematchComplete": "Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", + "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 4418fbb1..c9b234a0 100644 --- a/locales/es.json +++ b/locales/es.json @@ -187,11 +187,14 @@ "error": "Error al reparar recetas: {message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "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}" }, "manageExcludedModels": { "label": "Gestionar modelos excluidos" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})", "repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas", "repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "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...", "reimportSuccess": "Receta reimportada exitosamente", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", diff --git a/locales/fr.json b/locales/fr.json index ec7cf847..1e62f9d7 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -187,11 +187,14 @@ "error": "Échec de la réparation des recettes : {message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "label": "Réassocier les recettes aux modèles locaux", + "loading": "Réassociation des recettes aux modèles locaux...", + "success": "{entries} entrées associées dans {recipes} recettes", + "successErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs", + "allFailed": "Échec de la réassociation de {failures} recettes sur {total}", + "noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes", + "cancelled": "Réassociation annulée. {recipes} recettes mises à jour ({entries} entrées)", + "error": "Échec de la réassociation des recettes : {message}" }, "manageExcludedModels": { "label": "Gérer les modèles exclus" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})", "repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées", "repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "rematchComplete": "{entries} entrées associées dans {recipes} recettes", + "rematchCompleteErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs", + "rematchAllFailed": "Échec de la réassociation de {failures} recettes sélectionnées sur {total}", + "rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes", + "rematchSkipped": "Aucune des {total} recettes sélectionnées ne nécessite de réassociation", + "rematchFailed": "Échec de la réassociation des recettes sélectionnées : {message}", "reimporting": "Ré-import de la recette depuis la source...", "reimportSuccess": "Recette ré-importée avec succès", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", diff --git a/locales/he.json b/locales/he.json index 61c0de7a..03a57f73 100644 --- a/locales/he.json +++ b/locales/he.json @@ -187,11 +187,14 @@ "error": "תיקון המתכונים נכשל: {message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "label": "התאמה מחדש של מתכונים למודלים מקומיים", + "loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...", + "success": "הותאמו {entries} פריטים ב־{recipes} מתכונים", + "successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו", + "allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים", + "noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים", + "cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)", + "error": "ההתאמה מחדש של המתכונים נכשלה: {message}" }, "manageExcludedModels": { "label": "ניהול מודלים מוחרגים" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})", "repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים", "repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים", + "rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו", + "rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו", + "rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים", + "rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו", + "rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}", "reimporting": "מייבא מתכון מחדש מהמקור...", "reimportSuccess": "המתכון יובא מחדש בהצלחה", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", diff --git a/locales/ja.json b/locales/ja.json index adff9e83..020a2dbc 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -187,11 +187,14 @@ "error": "レシピの修復に失敗しました: {message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "label": "レシピをローカルモデルに再マッチング", + "loading": "レシピをローカルモデルに再マッチングしています...", + "success": "{recipes} 件のレシピで {entries} エントリをマッチングしました", + "successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)", + "allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました", + "noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした", + "cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)", + "error": "レシピの再マッチングに失敗しました:{message}" }, "manageExcludedModels": { "label": "除外モデルを管理" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)", "repairBulkSkipped": "選択した {total} 件のレシピは修復不要です", "repairBulkFailed": "選択したレシピの修復に失敗しました:{message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました", + "rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)", + "rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました", + "rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした", + "rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした", + "rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}", "reimporting": "ソースからレシピを再インポート中...", "reimportSuccess": "レシピの再インポートが完了しました", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", diff --git a/locales/ko.json b/locales/ko.json index 2c0d7bf8..1c9a8166 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -187,11 +187,14 @@ "error": "레시피 복구 실패: {message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "label": "레시피를 로컬 모델에 다시 매칭", + "loading": "레시피를 로컬 모델에 다시 매칭하는 중...", + "success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다", + "successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패", + "allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패", + "noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다", + "cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)", + "error": "레시피 재매칭 실패: {message}" }, "manageExcludedModels": { "label": "제외된 모델 관리" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)", "repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다", "repairBulkFailed": "선택한 레시피 복구 실패: {message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다", + "rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패", + "rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패", + "rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다", + "rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다", + "rematchFailed": "선택한 레시피 재매칭 실패: {message}", "reimporting": "소스에서 레시피를 다시 가져오는 중...", "reimportSuccess": "레시피를 다시 가져왔습니다", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", diff --git a/locales/ru.json b/locales/ru.json index faf2c7fc..16b94d20 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -187,11 +187,14 @@ "error": "Ошибка восстановления рецептов: {message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "label": "Повторное сопоставление рецептов с локальными моделями", + "loading": "Повторное сопоставление рецептов с локальными моделями...", + "success": "Сопоставлено записей: {entries} в рецептах: {recipes}", + "successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}", + "allFailed": "Не удалось сопоставить: {failures} из {total} рецептов", + "noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах", + "cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})", + "error": "Не удалось выполнить сопоставление рецептов: {message}" }, "manageExcludedModels": { "label": "Управление исключёнными моделями" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})", "repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления", "repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}", + "rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}", + "rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов", + "rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах", + "rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления", + "rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}", "reimporting": "Переимпорт рецепта из источника...", "reimportSuccess": "Рецепт успешно переимпортирован", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 875dbd38..1526ba7c 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -187,11 +187,14 @@ "error": "配方修复失败:{message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "label": "将食谱重新匹配到本地模型", + "loading": "正在将食谱重新匹配到本地模型...", + "success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱", + "successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败", + "allFailed": "{failures}/{total} 个食谱重新匹配失败", + "noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配", + "cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。", + "error": "食谱重新匹配失败:{message}" }, "manageExcludedModels": { "label": "管理已排除的模型" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)", "repairBulkSkipped": "所选 {total} 个配方无需修复", "repairBulkFailed": "修复所选配方失败:{message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱", + "rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败", + "rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败", + "rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配", + "rematchSkipped": "{total} 个所选食谱均无需重新匹配", + "rematchFailed": "重新匹配所选食谱失败:{message}", "reimporting": "正在从源重新导入配方...", "reimportSuccess": "配方已从源重新导入成功", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 33bce12f..fbaa818b 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -187,11 +187,14 @@ "error": "配方修復失敗:{message}" }, "rematchRecipes": { - "label": "[TODO: Translate] Rematch recipes to local models", - "loading": "[TODO: Translate] Rematching recipes to local models...", - "success": "[TODO: Translate] Successfully rematched {count} recipes.", - "cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", - "error": "[TODO: Translate] Recipe rematch failed: {message}" + "label": "將食譜重新匹配到本地模型", + "loading": "正在將食譜重新匹配到本地模型...", + "success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜", + "successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗", + "allFailed": "{failures}/{total} 個食譜重新匹配失敗", + "noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配", + "cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。", + "error": "食譜重新匹配失敗:{message}" }, "manageExcludedModels": { "label": "管理已排除的模型" @@ -1960,9 +1963,12 @@ "repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)", "repairBulkSkipped": "所選 {total} 個配方無需修復", "repairBulkFailed": "修復所選配方失敗:{message}", - "rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", - "rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", - "rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", + "rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜", + "rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗", + "rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗", + "rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配", + "rematchSkipped": "{total} 個所選食譜均無需重新匹配", + "rematchFailed": "重新匹配所選食譜失敗:{message}", "reimporting": "正在從來源重新匯入配方...", "reimportSuccess": "配方已從來源重新匯入成功", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 3595276d..4b53a9f4 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -227,16 +227,42 @@ class RecipeScanner: *, is_checkpoint: bool, ) -> Optional[dict[str, Any]]: + """Match a recipe entry against local models (see + ``_match_rematch_entry_with_level`` for the level-aware variant). + + Kept as a thin wrapper so callers that only need the matched item + (and the direct tests of this method) keep a stable contract. + """ + item, _level = await self._match_rematch_entry_with_level( + entry, local_cache, autov3_cache, is_checkpoint=is_checkpoint + ) + return item + + async def _match_rematch_entry_with_level( + self, + entry: dict[str, Any], + local_cache: dict[str, Any], + autov3_cache: dict[str, Any], + *, + is_checkpoint: bool, + ) -> Tuple[Optional[dict[str, Any]], Optional[str]]: """Match a recipe entry against local models across three levels. L1 looks the stored hash up in the type-blind local hash cache; L2 falls back to the version index via ``modelVersionId`` or ``id``; L3 resolves 12-char hashes through the computed AutoV3 cache. Matched items are type-verified against the entry kind before being returned. + + Returns: + Tuple of (matched item, match level) where level is "L1", "L2" or + "L3" — or ``(None, None)`` when no usable match exists. A missing + local match is an expected outcome (the model may simply not be + present locally), not an error. """ entry_hash = (entry.get("hash") or "").lower() item = local_cache.get(entry_hash) + level = "L1" if item is not None else None if item is None: version_id = entry.get("modelVersionId") or entry.get("id") @@ -245,12 +271,14 @@ class RecipeScanner: item = self._get_checkpoint_from_version_index(str(version_id)) else: item = self._get_lora_from_version_index(str(version_id)) + level = "L2" if item is not None else None if item is None and len(entry_hash) == 12: item = autov3_cache.get(entry_hash) + level = "L3" if item is not None else None if item is None: - return None + return (None, None) # Type gate: the L1 cache merges lora and checkpoint items and is # type-blind, so a match must be verified against the entry kind. @@ -260,7 +288,7 @@ class RecipeScanner: VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES ) if sub_type not in valid: - return None + return (None, None) else: civitai_type = ( (item.get("civitai") or {}).get("model", {}) or {} @@ -275,9 +303,22 @@ class RecipeScanner: else: valid = VALID_LORA_TYPES if normalized not in valid: - return None + return (None, None) - return item + return (item, level) + + @staticmethod + def _entry_identifier(entry: dict[str, Any]) -> str: + """Best-effort human-readable identifier for a recipe entry. + + Used for rematch reports and debug logs; falls back through the keys + that carry the most recognisable information first. + """ + for key in ("modelName", "name", "file_name", "hash", "modelVersionId"): + value = entry.get(key) + if value: + return str(value) + return "unknown" def on_library_changed(self) -> None: """Reset cached state when the active library changes.""" @@ -548,6 +589,32 @@ class RecipeScanner: async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: """Rematch a single recipe's deleted lora/checkpoint entries locally. + Logs one INFO summary line for this run and delegates the per-recipe + work to ``_rematch_recipe_by_id`` (shared with the bulk entry point). + + Args: + recipe_id: ID of the recipe to rematch + + Returns: + Dict summary of the rematch result (see ``_rematch_recipe_by_id``). + Raises RecipeNotFoundError when the recipe is missing. + """ + result = await self._rematch_recipe_by_id(recipe_id) + recipe_name = (result.get("recipe") or {}).get("name") or recipe_id + logger.info( + "Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors", + recipe_id, + recipe_name, + result.get("success"), + result.get("matched_entries", 0), + result.get("unresolved_entries", 0), + result.get("errors", 0), + ) + return result + + async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: + """Rematch a single recipe's deleted lora/checkpoint entries locally. + Match snapshots (local hash cache + computed autov3 cache) are built BEFORE acquiring the mutation lock — both are read-only snapshots and the version-cached hash dict would otherwise rebuild mid-run if a scan @@ -557,8 +624,13 @@ class RecipeScanner: recipe_id: ID of the recipe to rematch Returns: - Dict summary of the rematch result (success/rematched/skipped). - Raises RecipeNotFoundError when the recipe is missing. + Dict summary of the rematch result with unified counters + (matched_recipes, matched_entries, unresolved_recipes, + unresolved_entries plus the legacy rematched/skipped/errors + fields) and a per-entry ``details`` report. The legacy ``skipped`` + field means "recipe not updated" and overlaps + ``unresolved_recipes`` (a recipe with unmatched candidates counts + as both). Raises RecipeNotFoundError when the recipe is missing. """ local_cache = await self.build_local_hash_cache() autov3_cache = await self._build_rematch_autov3_cache() @@ -574,24 +646,43 @@ class RecipeScanner: raise RecipeNotFoundError(f"Recipe {recipe_id} not found") try: - rematched, _errors = await self._rematch_single_recipe( + rematched, _errors, details = await self._rematch_single_recipe( recipe, local_cache, autov3_cache ) except RecipePersistenceError as exc: + logger.error( + "Recipe rematch %s (%s) failed to persist: %s", + recipe_id, + recipe.get("name") or recipe.get("file_path"), + exc, + ) return { "success": False, "errors": 1, "rematched": 0, "skipped": 0, + "matched_recipes": 0, + "matched_entries": 0, + "unresolved_recipes": 0, + "unresolved_entries": 0, + "details": {"matched": [], "unresolved": []}, "recipe": recipe, "error": str(exc), } + unresolved_entries = len(details["unresolved"]) + unresolved_recipes = 1 if unresolved_entries > 0 else 0 + if rematched == 0: return { "success": True, "rematched": 0, "skipped": 1, + "matched_recipes": 0, + "matched_entries": 0, + "unresolved_recipes": unresolved_recipes, + "unresolved_entries": unresolved_entries, + "details": details, "recipe": recipe, } @@ -600,6 +691,11 @@ class RecipeScanner: "success": True, "rematched": rematched, "skipped": 0, + "matched_recipes": 1, + "matched_entries": rematched, + "unresolved_recipes": unresolved_recipes, + "unresolved_entries": unresolved_entries, + "details": details, "recipe": await self.get_recipe_by_id(recipe_id), } @@ -608,7 +704,7 @@ class RecipeScanner: recipe: Dict[str, Any], local_cache: dict[str, dict[str, Any]], autov3_cache: dict[str, dict[str, Any]], - ) -> Tuple[int, int]: + ) -> Tuple[int, int, Dict[str, Any]]: """Rematch a single recipe's lora/checkpoint entries against local models. Shared per-recipe helper used by ``rematch_recipe_by_id`` and the bulk @@ -623,15 +719,21 @@ class RecipeScanner: autov3_cache: L3 computed-autov3 cache snapshot Returns: - Tuple of (rematched_entries, errors). The errors element is always - 0 on a normal return — a persistence failure RAISES - ``RecipePersistenceError`` so callers can count it. + Tuple of (rematched_entries, errors, details). The errors element + is always 0 on a normal return — a persistence failure RAISES + ``RecipePersistenceError`` so callers can count it. ``details`` + carries the per-entry outcome: + ``{"matched": [{type, entry, file_name, match_level}], + "unresolved": [{type, entry}]}`` where an unresolved entry is a + rematch candidate that found no local match — an expected outcome + (the model may simply not exist locally), not an error. Raises: RecipePersistenceError: when the recipe changed but ``_save_recipe_persistently`` returned False. """ rematched = 0 + details: Dict[str, Any] = {"matched": [], "unresolved": []} # Lora entries loras = recipe.get("loras", []) @@ -639,11 +741,24 @@ class RecipeScanner: for entry in loras: if not self._is_rematch_candidate(entry): continue - item = await self._match_rematch_entry( + item, level = await self._match_rematch_entry_with_level( entry, local_cache, autov3_cache, is_checkpoint=False ) if item is None: + details["unresolved"].append( + {"type": "lora", "entry": self._entry_identifier(entry)} + ) continue + # Capture the identifier before the write-back mutates the + # entry (file_name/isDeleted are rewritten in place). + details["matched"].append( + { + "type": "lora", + "entry": self._entry_identifier(entry), + "file_name": item.get("file_name") or "", + "match_level": level, + } + ) self._write_rematch_lora_entry(entry, item) rematched += 1 @@ -652,15 +767,50 @@ class RecipeScanner: checkpoint = recipe.get("checkpoint") if isinstance(checkpoint, dict): if self._is_rematch_candidate(checkpoint): - item = await self._match_rematch_entry( + item, level = await self._match_rematch_entry_with_level( checkpoint, local_cache, autov3_cache, is_checkpoint=True ) - if item is not None: + if item is None: + details["unresolved"].append( + { + "type": "checkpoint", + "entry": self._entry_identifier(checkpoint), + } + ) + else: + details["matched"].append( + { + "type": "checkpoint", + "entry": self._entry_identifier(checkpoint), + "file_name": item.get("file_name") or "", + "match_level": level, + } + ) self._write_rematch_checkpoint_entry(checkpoint, item) rematched += 1 + # Per-recipe detail is DEBUG only: one INFO line per recipe would + # flood the log for large libraries, and unresolved entries are a + # normal outcome rather than something to warn about. + if details["matched"] or details["unresolved"]: + matched_desc = ", ".join( + f"{m['entry']} -> {m['file_name']} ({m['match_level']})" + for m in details["matched"] + ) or "-" + unresolved_desc = ", ".join( + u["entry"] for u in details["unresolved"] + ) or "-" + logger.debug( + "Recipe rematch %s: matched %d entries [%s]; unresolved %d [%s]", + recipe.get("id") or recipe.get("file_path"), + len(details["matched"]), + matched_desc, + len(details["unresolved"]), + unresolved_desc, + ) + if rematched == 0: - return (0, 0) + return (0, 0, details) from ..utils.utils import calculate_recipe_fingerprint @@ -673,7 +823,7 @@ class RecipeScanner: ) self._update_fts_index_for_recipe(recipe, "update") - return (rematched, 0) + return (rematched, 0, details) async def rematch_all_recipes( self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None @@ -692,9 +842,14 @@ class RecipeScanner: (started/processing/cancelled/completed events). Returns: - Dict summary of the rematch run - (success/status/rematched/skipped/errors/total). + Dict summary of the rematch run with unified counters + (matched_recipes/matched_entries/unresolved_recipes/unresolved_ + entries plus the legacy success/status/rematched/skipped/errors/ + total fields). ``rematched`` (legacy) counts updated recipes — + use ``matched_entries`` for the entry-level total. """ + start_time = time.perf_counter() + if progress_callback: await progress_callback({"status": "started"}) @@ -706,31 +861,53 @@ class RecipeScanner: cache = await self.get_cached_data() all_recipes = list(cache.raw_data) total = len(all_recipes) - rematched_count = 0 + matched_recipes = 0 + matched_entries = 0 + unresolved_recipes = 0 + unresolved_entries = 0 skipped_count = 0 errors_count = 0 for i, recipe in enumerate(all_recipes): if self.is_cancelled(): - logger.info("Recipe rematch cancelled by user") + logger.info( + "Recipe rematch cancelled by user after %d/%d recipes: " + "%d updated (%d entries matched), %d unresolved entries " + "in %d recipes, %d errors", + i, + total, + matched_recipes, + matched_entries, + unresolved_entries, + unresolved_recipes, + errors_count, + ) if progress_callback: await progress_callback( { "status": "cancelled", "current": i, "total": total, - "rematched": rematched_count, + "rematched": matched_recipes, "skipped": skipped_count, "errors": errors_count, + "matched_recipes": matched_recipes, + "matched_entries": matched_entries, + "unresolved_recipes": unresolved_recipes, + "unresolved_entries": unresolved_entries, } ) return { "success": False, "status": "cancelled", - "rematched": rematched_count, + "rematched": matched_recipes, "skipped": skipped_count, "errors": errors_count, "total": total, + "matched_recipes": matched_recipes, + "matched_entries": matched_entries, + "unresolved_recipes": unresolved_recipes, + "unresolved_entries": unresolved_entries, } try: @@ -745,14 +922,20 @@ class RecipeScanner: } ) - rematched, _ = await self._rematch_single_recipe( + rematched, _errors, details = await self._rematch_single_recipe( recipe, local_cache, autov3_cache ) if rematched > 0: - rematched_count += 1 + matched_recipes += 1 + matched_entries += rematched else: skipped_count += 1 + recipe_unresolved = len(details["unresolved"]) + if recipe_unresolved > 0: + unresolved_recipes += 1 + unresolved_entries += recipe_unresolved + except Exception as exc: logger.error( f"Error rematching recipe {recipe.get('file_path')}: {exc}" @@ -763,30 +946,52 @@ class RecipeScanner: # per call, so per-recipe calls would race 5k resort tasks. self._schedule_resort() + logger.info( + "Recipe rematch complete: %d/%d recipes updated (%d entries " + "matched), %d unresolved entries in %d recipes, %d skipped, " + "%d errors in %.2fs", + matched_recipes, + total, + matched_entries, + unresolved_entries, + unresolved_recipes, + skipped_count, + errors_count, + time.perf_counter() - start_time, + ) + # Final progress update if progress_callback: await progress_callback( { "status": "completed", - "rematched": rematched_count, + "rematched": matched_recipes, "skipped": skipped_count, "errors": errors_count, "total": total, + "matched_recipes": matched_recipes, + "matched_entries": matched_entries, + "unresolved_recipes": unresolved_recipes, + "unresolved_entries": unresolved_entries, } ) return { "success": True, - "rematched": rematched_count, + "rematched": matched_recipes, "skipped": skipped_count, "errors": errors_count, "total": total, + "matched_recipes": matched_recipes, + "matched_entries": matched_entries, + "unresolved_recipes": unresolved_recipes, + "unresolved_entries": unresolved_entries, } async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]: """Rematch a set of recipes by their IDs. - Iterates ``rematch_recipe_by_id`` over each id: not-found ids are + Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are counted as skipped, and unexpected per-recipe exceptions are counted as errors with the loop continuing so partial results are never lost. Persist failures are already converted to the by_id return shape and @@ -796,23 +1001,38 @@ class RecipeScanner: recipe_ids: List of recipe ids to rematch. Returns: - Dict summary of the bulk run - (success/total/rematched/skipped/errors/recipes). + Dict summary of the bulk run with unified counters + (matched_recipes, matched_entries, unresolved_recipes, + unresolved_entries plus the legacy total/rematched/skipped/errors + fields) and a per-recipe ``details`` list. The legacy ``rematched`` + field is the total entry count (same as ``matched_entries``) — + unlike ``rematch_all_recipes`` where it counts updated recipes. """ total = len(recipe_ids) - rematched = 0 + matched_recipes = 0 + matched_entries = 0 + unresolved_recipes = 0 + unresolved_entries = 0 skipped = 0 errors = 0 recipes: List[Dict[str, Any]] = [] + details_list: List[Dict[str, Any]] = [] for recipe_id in recipe_ids: try: - result = await self.rematch_recipe_by_id(recipe_id) + result = await self._rematch_recipe_by_id(recipe_id) if result.get("success"): - rematched += result.get("rematched", 0) + matched_recipes += result.get("matched_recipes", 0) + matched_entries += result.get("matched_entries", 0) + unresolved_recipes += result.get("unresolved_recipes", 0) + unresolved_entries += result.get("unresolved_entries", 0) skipped += result.get("skipped", 0) if result.get("recipe"): recipes.append(result["recipe"]) + if result.get("details"): + details_list.append( + {"recipe_id": recipe_id, **result["details"]} + ) else: errors += result.get("errors", 0) except RecipeNotFoundError: @@ -823,13 +1043,30 @@ class RecipeScanner: self._schedule_resort() + logger.info( + "Recipe bulk rematch: %d/%d recipes updated (%d entries matched), " + "%d unresolved entries in %d recipes, %d skipped, %d errors", + matched_recipes, + total, + matched_entries, + unresolved_entries, + unresolved_recipes, + skipped, + errors, + ) + return { "success": True, "total": total, - "rematched": rematched, + "rematched": matched_entries, "skipped": skipped, "errors": errors, + "matched_recipes": matched_recipes, + "matched_entries": matched_entries, + "unresolved_recipes": unresolved_recipes, + "unresolved_entries": unresolved_entries, "recipes": recipes, + "details": details_list, } def _write_rematch_lora_entry( diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index 70a16e68..75e4806b 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -489,12 +489,56 @@ export class GlobalContextMenu extends BaseContextMenu { progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`); } else if (p.status === 'completed') { isComplete = true; - progressUI?.complete(translate( - 'globalContextMenu.rematchRecipes.success', - { count: p.rematched }, - `Rematched ${p.rematched} recipes.` - )); - showToast('globalContextMenu.rematchRecipes.success', { count: p.rematched }, 'success'); + // Newer backends report unified matched_entries / + // matched_recipes; fall back to legacy `rematched` + // (recipe count) for older ones. + const entries = p.matched_entries ?? p.rematched ?? 0; + 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). + 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'); + } // Refresh recipes page if active if (window.recipesPage) { window.recipesPage.refresh(); @@ -503,12 +547,14 @@ export class GlobalContextMenu extends BaseContextMenu { throw new Error(p.error || 'Rematch failed'); } else if (p.status === 'cancelled') { isComplete = true; + const cancelledEntries = p.matched_entries ?? p.rematched ?? 0; + const cancelledRecipes = p.matched_recipes ?? p.rematched ?? 0; progressUI?.complete(translate( 'globalContextMenu.rematchRecipes.cancelled', - { count: p.rematched }, - `Rematch cancelled. ${p.rematched} recipes were rematched.` + { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, + `Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).` )); - showToast('globalContextMenu.rematchRecipes.cancelled', { count: p.rematched }, 'info'); + showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info'); } } 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 f9162f1b..ac181572 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -352,12 +352,16 @@ export class RecipeContextMenu extends BaseContextMenu { const result = await response.json(); if (result.success) { - // The rematch backend reports `rematched` (not `repaired`) - if (result.rematched > 0) { + const matchedEntries = result.matched_entries || result.rematched || 0; + const failures = result.errors || 0; + if (matchedEntries > 0) { + const toastKey = failures > 0 + ? 'toast.recipes.rematchCompleteErrors' + : 'toast.recipes.rematchComplete'; showToast( - 'toast.recipes.rematchComplete', - { rematched: result.rematched, skipped: result.skipped || 0, total: 1 }, - 'success' + 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) { @@ -366,6 +370,14 @@ export class RecipeContextMenu extends BaseContextMenu { state.virtualScroller.updateSingleItem(filePath, updatedRecipe); } } + } 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 { showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info'); } diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 81c145ee..12e2dc8f 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -898,9 +898,17 @@ export class BulkManager { if (result.success) { const total = result.total || filePaths.length; - // The rematch backend reports `rematched` (not `repaired`) + // Unified counters from the backend; legacy fields fall back + // for older backends: `rematched` (entry count) for + // matched_entries, `total` (selection size) for + // matched_recipes. const rematched = result.rematched || 0; const skipped = result.skipped || 0; + const matchedRecipes = result.matched_recipes || result.total || 0; + const matchedEntries = result.matched_entries || rematched; + const failures = result.errors || 0; + const unresolvedEntries = result.unresolved_entries || 0; + const unresolvedRecipes = result.unresolved_recipes || 0; const recipes = result.recipes || []; for (const recipe of recipes) { @@ -912,11 +920,31 @@ export class BulkManager { } } - if (rematched > 0) { + if (matchedEntries > 0) { + const hasFailures = failures > 0; + const toastKey = hasFailures + ? 'toast.recipes.rematchCompleteErrors' + : 'toast.recipes.rematchComplete'; showToast( - 'toast.recipes.rematchComplete', - { rematched, skipped, total }, - 'success' + 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 { showToast( diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index e9b6562e..a817f816 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -2225,7 +2225,7 @@ describe('Interaction-level regression coverage', () => { ok: true, json: async () => ({ success: true, - progress: { status: 'completed', rematched: 2, skipped: 1, total: 3 }, + progress: { status: 'completed', rematched: 2, skipped: 1, errors: 0, total: 3, matched_recipes: 2, matched_entries: 5, unresolved_recipes: 1, unresolved_entries: 1 }, }), }); @@ -2244,11 +2244,11 @@ describe('Interaction-level regression coverage', () => { expect(global.fetch).toHaveBeenCalledTimes(2); expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1); - expect(progressUI.complete).toHaveBeenCalledWith('Rematched 2 recipes.'); + 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( 'globalContextMenu.rematchRecipes.success', - { count: 2 }, + { count: 2, recipes: 2, entries: 5, failures: 0 }, 'success' ); expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1); @@ -2259,6 +2259,165 @@ describe('Interaction-level regression coverage', () => { delete stateStub.currentPageType; }); + it('uses the warning toast variant when a global rematch completes with failures', async () => { + document.body.innerHTML = ` +
+
+
+ `; + + const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js'); + const menu = new GlobalContextMenu(); + const rematchItem = document.querySelector('[data-action="rematch-recipes"]'); + + const progressUI = { + updateProgress: vi.fn(), + showCancelButton: vi.fn(), + complete: vi.fn().mockResolvedValue(undefined), + }; + loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI); + window.recipesPage = { refresh: vi.fn() }; + + stateStub.currentPageType = 'recipes'; + menu.showMenu(100, 200); + + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + progress: { status: 'completed', rematched: 2, skipped: 0, errors: 2, total: 3, matched_recipes: 2, matched_entries: 5, unresolved_recipes: 0, unresolved_entries: 0 }, + }), + }); + + rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + + for (let i = 0; i < 5; i++) { + await flushAsyncTasks(); + } + + 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(menu._rematchInProgress).toBe(false); + + delete window.recipesPage; + delete stateStub.currentPageType; + }); + + it('toasts an error when every recipe in a global rematch failed', async () => { + document.body.innerHTML = ` +
+
+
+ `; + + const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js'); + const menu = new GlobalContextMenu(); + const rematchItem = document.querySelector('[data-action="rematch-recipes"]'); + + const progressUI = { + updateProgress: vi.fn(), + showCancelButton: vi.fn(), + complete: vi.fn().mockResolvedValue(undefined), + }; + loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI); + window.recipesPage = { refresh: vi.fn() }; + + stateStub.currentPageType = 'recipes'; + menu.showMenu(100, 200); + + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + progress: { status: 'completed', rematched: 0, skipped: 0, errors: 3, total: 3, matched_recipes: 0, matched_entries: 0, unresolved_recipes: 0, unresolved_entries: 0 }, + }), + }); + + rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + + for (let i = 0; i < 5; i++) { + await flushAsyncTasks(); + } + + 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(menu._rematchInProgress).toBe(false); + + delete window.recipesPage; + delete stateStub.currentPageType; + }); + + it('toasts an info message when a global rematch found no local matches', async () => { + document.body.innerHTML = ` +
+
+
+ `; + + const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js'); + const menu = new GlobalContextMenu(); + const rematchItem = document.querySelector('[data-action="rematch-recipes"]'); + + const progressUI = { + updateProgress: vi.fn(), + showCancelButton: vi.fn(), + complete: vi.fn().mockResolvedValue(undefined), + }; + loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI); + window.recipesPage = { refresh: vi.fn() }; + + stateStub.currentPageType = 'recipes'; + menu.showMenu(100, 200); + + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + progress: { status: 'completed', rematched: 0, skipped: 2, errors: 0, total: 3, matched_recipes: 0, matched_entries: 0, unresolved_recipes: 1, unresolved_entries: 2 }, + }), + }); + + rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + + for (let i = 0; i < 5; i++) { + await flushAsyncTasks(); + } + + 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(menu._rematchInProgress).toBe(false); + + delete window.recipesPage; + delete stateStub.currentPageType; + }); + it('toasts the rematched count when a global rematch is cancelled', async () => { document.body.innerHTML = `
@@ -2289,7 +2448,7 @@ describe('Interaction-level regression coverage', () => { ok: true, json: async () => ({ success: true, - progress: { status: 'cancelled', rematched: 1, skipped: 0, total: 3 }, + progress: { status: 'cancelled', rematched: 1, skipped: 0, errors: 0, total: 3, matched_recipes: 1, matched_entries: 2, unresolved_recipes: 0, unresolved_entries: 0 }, }), }); @@ -2299,10 +2458,10 @@ describe('Interaction-level regression coverage', () => { await flushAsyncTasks(); } - expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes were rematched.'); + expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).'); expect(showToastMock).toHaveBeenCalledWith( 'globalContextMenu.rematchRecipes.cancelled', - { count: 1 }, + { count: 1, recipes: 1, entries: 2 }, 'info' ); expect(menu._rematchInProgress).toBe(false); diff --git a/tests/frontend/components/recipeContextMenu.rematch.test.js b/tests/frontend/components/recipeContextMenu.rematch.test.js index b650f25b..d7cba36b 100644 --- a/tests/frontend/components/recipeContextMenu.rematch.test.js +++ b/tests/frontend/components/recipeContextMenu.rematch.test.js @@ -78,7 +78,7 @@ describe('RecipeContextMenu.rematchRecipe', () => { global.fetch .mockResolvedValueOnce({ ok: true, - json: async () => ({ success: true, rematched: 2, skipped: 0 }), + json: async () => ({ success: true, rematched: 2, skipped: 0, matched_recipes: 1, matched_entries: 2 }), }) .mockResolvedValueOnce({ ok: true, @@ -96,7 +96,7 @@ describe('RecipeContextMenu.rematchRecipe', () => { }); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchComplete', - { rematched: 2, skipped: 0, total: 1 }, + { rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 }, 'success' ); expect(showToastMock).not.toHaveBeenCalledWith( @@ -111,6 +111,35 @@ describe('RecipeContextMenu.rematchRecipe', () => { }); }); + it('toasts an info message when the entries had no local match', async () => { + const menu = await createMenu(); + const card = document.getElementById('card'); + menu.showMenu(100, 100, card); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, rematched: 0, skipped: 0, unresolved_recipes: 1, unresolved_entries: 2 }), + }); + + document + .querySelector('[data-action="rematch"]') + .dispatchEvent(new Event('click', { bubbles: true })); + + await flushAsyncTasks(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchUnmatched', + { entries: 2, recipes: 1, total: 1 }, + 'info' + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchSkipped', + expect.anything(), + expect.anything() + ); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + it('toasts the skipped message when nothing was rematched', async () => { const menu = await createMenu(); const card = document.getElementById('card'); diff --git a/tests/frontend/managers/BulkManager.rematch.test.js b/tests/frontend/managers/BulkManager.rematch.test.js index 94369902..6e7ffb38 100644 --- a/tests/frontend/managers/BulkManager.rematch.test.js +++ b/tests/frontend/managers/BulkManager.rematch.test.js @@ -103,9 +103,13 @@ describe('BulkManager.rematchSelectedRecipes', () => { rematchBulkModelsMock.mockResolvedValue({ success: true, total: 3, - rematched: 2, + rematched: 4, skipped: 1, errors: 0, + matched_recipes: 2, + matched_entries: 4, + unresolved_recipes: 1, + unresolved_entries: 1, recipes: [rematchedRecipe], }); @@ -118,7 +122,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { ]); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchComplete', - { rematched: 2, skipped: 1, total: 3 }, + { rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 }, 'success' ); expect(showToastMock).not.toHaveBeenCalledWith( @@ -132,6 +136,98 @@ describe('BulkManager.rematchSelectedRecipes', () => { expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled(); }); + it('uses the errors toast variant when the bulk rematch has failures', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + stateStub.selectedModels.add('/recipes/b.webp'); + + rematchBulkModelsMock.mockResolvedValue({ + success: true, + total: 2, + rematched: 3, + skipped: 0, + errors: 2, + matched_recipes: 1, + matched_entries: 3, + unresolved_recipes: 0, + unresolved_entries: 0, + recipes: [], + }); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchCompleteErrors', + { rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 }, + 'warning' + ); + }); + + it('toasts an error when every selected recipe failed to rematch', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + stateStub.selectedModels.add('/recipes/b.webp'); + + rematchBulkModelsMock.mockResolvedValue({ + success: true, + total: 2, + rematched: 0, + skipped: 0, + errors: 2, + matched_recipes: 0, + matched_entries: 0, + unresolved_recipes: 0, + unresolved_entries: 0, + recipes: [], + }); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchAllFailed', + { total: 2, failures: 2 }, + 'error' + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchSkipped', + expect.anything(), + expect.anything() + ); + }); + + it('toasts an info message when entries had no local match', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + stateStub.selectedModels.add('/recipes/b.webp'); + stateStub.selectedModels.add('/recipes/c.webp'); + + rematchBulkModelsMock.mockResolvedValue({ + success: true, + total: 3, + rematched: 0, + skipped: 2, + errors: 0, + matched_recipes: 0, + matched_entries: 0, + unresolved_recipes: 1, + unresolved_entries: 2, + recipes: [], + }); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchUnmatched', + { entries: 2, recipes: 1, total: 3 }, + 'info' + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchSkipped', + expect.anything(), + expect.anything() + ); + }); + it('toasts the skipped message when nothing was rematched', async () => { const bulk = await createBulkManager(); stateStub.selectedModels.add('/recipes/a.webp'); diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 7e204177..84b77fda 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -2317,6 +2317,18 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat assert result["success"] is True assert result["rematched"] == 1 assert result["skipped"] == 0 + assert result["matched_recipes"] == 1 + assert result["matched_entries"] == 1 + assert result["unresolved_recipes"] == 0 + assert result["unresolved_entries"] == 0 + assert result["details"]["matched"] == [ + { + "type": "lora", + "entry": "old.safetensors", + "file_name": "m.safetensors", + "match_level": "L1", + } + ] assert result["recipe"] is enriched assert result["recipe"]["file_url"] == "/loras_static/preview/enriched.png" @@ -2874,12 +2886,20 @@ async def test_rematch_all_recipes_progress_sequence(tmp_path: Path, monkeypatch assert events[3]["skipped"] == 1 assert events[3]["errors"] == 0 assert events[3]["total"] == 2 + assert events[3]["matched_recipes"] == 1 + assert events[3]["matched_entries"] == 1 + assert events[3]["unresolved_recipes"] == 1 + assert events[3]["unresolved_entries"] == 1 assert result["success"] is True assert result["rematched"] == 1 assert result["skipped"] == 1 assert result["errors"] == 0 assert result["total"] == 2 + assert result["matched_recipes"] == 1 + assert result["matched_entries"] == 1 + assert result["unresolved_recipes"] == 1 + assert result["unresolved_entries"] == 1 assert "status" not in result assert resort_calls == [True] # Metis F1 — exactly once per run @@ -2953,10 +2973,10 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop( recipe: Dict[str, Any], local_cache: dict[str, Any], autov3_cache: dict[str, Any], - ) -> tuple[int, int]: + ) -> tuple[int, int, dict[str, Any]]: if recipe.get("id") == "boom": raise RuntimeError("kaboom") - return (0, 0) + return (0, 0, {"matched": [], "unresolved": []}) monkeypatch.setattr(scanner, "_rematch_single_recipe", fake_single) @@ -3140,9 +3160,9 @@ async def test_rematch_bulk_generic_exception_continues(tmp_path: Path, monkeypa calls += 1 if calls == 1: raise RuntimeError("match boom") - return None + return (None, None) - monkeypatch.setattr(scanner, "_match_rematch_entry", fake_match) + monkeypatch.setattr(scanner, "_match_rematch_entry_with_level", fake_match) result = await scanner.rematch_recipes_bulk(["r0", "r1"])