feat(recipes): report rematch results with aggregate logs and toast feedback

This commit is contained in:
Will Miao
2026-08-09 14:37:23 +08:00
parent d9d362c9c9
commit 68fa0f29c7
18 changed files with 828 additions and 141 deletions

View File

@@ -187,11 +187,14 @@
"error": "Recipe-Reparatur fehlgeschlagen: {message}" "error": "Recipe-Reparatur fehlgeschlagen: {message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "Rezepte lokalen Modellen neu zuordnen",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "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": { "manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten" "label": "Ausgeschlossene Modelle verwalten"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})", "repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich", "repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}", "repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "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...", "reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert", "reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",

View File

@@ -189,8 +189,11 @@
"rematchRecipes": { "rematchRecipes": {
"label": "Rematch recipes to local models", "label": "Rematch recipes to local models",
"loading": "Rematching recipes to local models...", "loading": "Rematching recipes to local models...",
"success": "Successfully rematched {count} recipes.", "success": "Matched {entries} entries across {recipes} recipes",
"cancelled": "Rematch cancelled. {count} recipes were rematched.", "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}" "error": "Recipe rematch failed: {message}"
}, },
"manageExcludedModels": { "manageExcludedModels": {
@@ -1960,7 +1963,10 @@
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})", "repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes", "repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}", "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", "rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}", "rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...", "reimporting": "Re-importing recipe from source...",

View File

@@ -187,11 +187,14 @@
"error": "Error al reparar recetas: {message}" "error": "Error al reparar recetas: {message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "Reasociar recetas con modelos locales",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "Reasociando recetas con modelos locales...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "{entries} entradas asociadas en {recipes} recetas",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "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": { "manageExcludedModels": {
"label": "Gestionar modelos excluidos" "label": "Gestionar modelos excluidos"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})", "repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas", "repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}", "repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "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...", "reimporting": "Reimportando receta desde origen...",
"reimportSuccess": "Receta reimportada exitosamente", "reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",

View File

@@ -187,11 +187,14 @@
"error": "Échec de la réparation des recettes : {message}" "error": "Échec de la réparation des recettes : {message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "Réassocier les recettes aux modèles locaux",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "Réassociation des recettes aux modèles locaux...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "{entries} entrées associées dans {recipes} recettes",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "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": { "manageExcludedModels": {
"label": "Gérer les modèles exclus" "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})", "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", "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}", "repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "{entries} entrées associées dans {recipes} recettes",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "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...", "reimporting": "Ré-import de la recette depuis la source...",
"reimportSuccess": "Recette ré-importée avec succès", "reimportSuccess": "Recette ré-importée avec succès",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",

View File

@@ -187,11 +187,14 @@
"error": "תיקון המתכונים נכשל: {message}" "error": "תיקון המתכונים נכשל: {message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "ניהול מודלים מוחרגים" "label": "ניהול מודלים מוחרגים"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})", "repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים", "repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}", "repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...", "reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה", "reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",

View File

@@ -187,11 +187,14 @@
"error": "レシピの修復に失敗しました: {message}" "error": "レシピの修復に失敗しました: {message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "レシピをローカルモデルに再マッチング",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "レシピをローカルモデルに再マッチングしています...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
"error": "レシピの再マッチングに失敗しました:{message}"
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "除外モデルを管理" "label": "除外モデルを管理"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)", "repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です", "repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}", "repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...", "reimporting": "ソースからレシピを再インポート中...",
"reimportSuccess": "レシピの再インポートが完了しました", "reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",

View File

@@ -187,11 +187,14 @@
"error": "레시피 복구 실패: {message}" "error": "레시피 복구 실패: {message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "레시피를 로컬 모델에 다시 매칭",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
"error": "레시피 재매칭 실패: {message}"
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "제외된 모델 관리" "label": "제외된 모델 관리"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)", "repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다", "repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}", "repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...", "reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportSuccess": "레시피를 다시 가져왔습니다", "reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",

View File

@@ -187,11 +187,14 @@
"error": "Ошибка восстановления рецептов: {message}" "error": "Ошибка восстановления рецептов: {message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "Повторное сопоставление рецептов с локальными моделями...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
"error": "Не удалось выполнить сопоставление рецептов: {message}"
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "Управление исключёнными моделями" "label": "Управление исключёнными моделями"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})", "repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления", "repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}", "repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...", "reimporting": "Переимпорт рецепта из источника...",
"reimportSuccess": "Рецепт успешно переимпортирован", "reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",

View File

@@ -187,11 +187,14 @@
"error": "配方修复失败:{message}" "error": "配方修复失败:{message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "将食谱重新匹配到本地模型",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "正在将食谱重新匹配到本地模型...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "allFailed": "{failures}/{total} 个食谱重新匹配失败",
"noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。",
"error": "食谱重新匹配失败:{message}"
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)", "repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复", "repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}", "repairBulkFailed": "修复所选配方失败:{message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败",
"rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选食谱均无需重新匹配",
"rematchFailed": "重新匹配所选食谱失败:{message}",
"reimporting": "正在从源重新导入配方...", "reimporting": "正在从源重新导入配方...",
"reimportSuccess": "配方已从源重新导入成功", "reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",

View File

@@ -187,11 +187,14 @@
"error": "配方修復失敗:{message}" "error": "配方修復失敗:{message}"
}, },
"rematchRecipes": { "rematchRecipes": {
"label": "[TODO: Translate] Rematch recipes to local models", "label": "將食譜重新匹配到本地模型",
"loading": "[TODO: Translate] Rematching recipes to local models...", "loading": "正在將食譜重新匹配到本地模型...",
"success": "[TODO: Translate] Successfully rematched {count} recipes.", "success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"cancelled": "[TODO: Translate] Rematch cancelled. {count} recipes were rematched.", "successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
"error": "[TODO: Translate] Recipe rematch failed: {message}" "allFailed": "{failures}/{total} 個食譜重新匹配失敗",
"noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。",
"error": "食譜重新匹配失敗:{message}"
}, },
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
@@ -1960,9 +1963,12 @@
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)", "repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復", "repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}", "repairBulkFailed": "修復所選配方失敗:{message}",
"rematchComplete": "[TODO: Translate] Rematch complete: {rematched} rematched, {skipped} skipped (of {total})", "rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"rematchSkipped": "[TODO: Translate] No rematch needed for any of the {total} selected recipes", "rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
"rematchFailed": "[TODO: Translate] Failed to rematch selected recipes: {message}", "rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選食譜均無需重新匹配",
"rematchFailed": "重新匹配所選食譜失敗:{message}",
"reimporting": "正在從來源重新匯入配方...", "reimporting": "正在從來源重新匯入配方...",
"reimportSuccess": "配方已從來源重新匯入成功", "reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",

View File

@@ -227,16 +227,42 @@ class RecipeScanner:
*, *,
is_checkpoint: bool, is_checkpoint: bool,
) -> Optional[dict[str, Any]]: ) -> 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. """Match a recipe entry against local models across three levels.
L1 looks the stored hash up in the type-blind local hash cache; L2 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 falls back to the version index via ``modelVersionId`` or ``id``; L3
resolves 12-char hashes through the computed AutoV3 cache. Matched resolves 12-char hashes through the computed AutoV3 cache. Matched
items are type-verified against the entry kind before being returned. 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() entry_hash = (entry.get("hash") or "").lower()
item = local_cache.get(entry_hash) item = local_cache.get(entry_hash)
level = "L1" if item is not None else None
if item is None: if item is None:
version_id = entry.get("modelVersionId") or entry.get("id") 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)) item = self._get_checkpoint_from_version_index(str(version_id))
else: else:
item = self._get_lora_from_version_index(str(version_id)) 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: if item is None and len(entry_hash) == 12:
item = autov3_cache.get(entry_hash) item = autov3_cache.get(entry_hash)
level = "L3" if item is not None else None
if item is None: if item is None:
return None return (None, None)
# Type gate: the L1 cache merges lora and checkpoint items and is # Type gate: the L1 cache merges lora and checkpoint items and is
# type-blind, so a match must be verified against the entry kind. # 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 VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
) )
if sub_type not in valid: if sub_type not in valid:
return None return (None, None)
else: else:
civitai_type = ( civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {} (item.get("civitai") or {}).get("model", {}) or {}
@@ -275,9 +303,22 @@ class RecipeScanner:
else: else:
valid = VALID_LORA_TYPES valid = VALID_LORA_TYPES
if normalized not in valid: 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: def on_library_changed(self) -> None:
"""Reset cached state when the active library changes.""" """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]: async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally. """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 Match snapshots (local hash cache + computed autov3 cache) are built
BEFORE acquiring the mutation lock — both are read-only snapshots and BEFORE acquiring the mutation lock — both are read-only snapshots and
the version-cached hash dict would otherwise rebuild mid-run if a scan 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 recipe_id: ID of the recipe to rematch
Returns: Returns:
Dict summary of the rematch result (success/rematched/skipped). Dict summary of the rematch result with unified counters
Raises RecipeNotFoundError when the recipe is missing. (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() local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache() autov3_cache = await self._build_rematch_autov3_cache()
@@ -574,24 +646,43 @@ class RecipeScanner:
raise RecipeNotFoundError(f"Recipe {recipe_id} not found") raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
try: try:
rematched, _errors = await self._rematch_single_recipe( rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache recipe, local_cache, autov3_cache
) )
except RecipePersistenceError as exc: 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 { return {
"success": False, "success": False,
"errors": 1, "errors": 1,
"rematched": 0, "rematched": 0,
"skipped": 0, "skipped": 0,
"matched_recipes": 0,
"matched_entries": 0,
"unresolved_recipes": 0,
"unresolved_entries": 0,
"details": {"matched": [], "unresolved": []},
"recipe": recipe, "recipe": recipe,
"error": str(exc), "error": str(exc),
} }
unresolved_entries = len(details["unresolved"])
unresolved_recipes = 1 if unresolved_entries > 0 else 0
if rematched == 0: if rematched == 0:
return { return {
"success": True, "success": True,
"rematched": 0, "rematched": 0,
"skipped": 1, "skipped": 1,
"matched_recipes": 0,
"matched_entries": 0,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"details": details,
"recipe": recipe, "recipe": recipe,
} }
@@ -600,6 +691,11 @@ class RecipeScanner:
"success": True, "success": True,
"rematched": rematched, "rematched": rematched,
"skipped": 0, "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), "recipe": await self.get_recipe_by_id(recipe_id),
} }
@@ -608,7 +704,7 @@ class RecipeScanner:
recipe: Dict[str, Any], recipe: Dict[str, Any],
local_cache: dict[str, dict[str, Any]], local_cache: dict[str, dict[str, Any]],
autov3_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. """Rematch a single recipe's lora/checkpoint entries against local models.
Shared per-recipe helper used by ``rematch_recipe_by_id`` and the bulk 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 autov3_cache: L3 computed-autov3 cache snapshot
Returns: Returns:
Tuple of (rematched_entries, errors). The errors element is always Tuple of (rematched_entries, errors, details). The errors element
0 on a normal return — a persistence failure RAISES is always 0 on a normal return — a persistence failure RAISES
``RecipePersistenceError`` so callers can count it. ``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: Raises:
RecipePersistenceError: when the recipe changed but RecipePersistenceError: when the recipe changed but
``_save_recipe_persistently`` returned False. ``_save_recipe_persistently`` returned False.
""" """
rematched = 0 rematched = 0
details: Dict[str, Any] = {"matched": [], "unresolved": []}
# Lora entries # Lora entries
loras = recipe.get("loras", []) loras = recipe.get("loras", [])
@@ -639,11 +741,24 @@ class RecipeScanner:
for entry in loras: for entry in loras:
if not self._is_rematch_candidate(entry): if not self._is_rematch_candidate(entry):
continue continue
item = await self._match_rematch_entry( item, level = await self._match_rematch_entry_with_level(
entry, local_cache, autov3_cache, is_checkpoint=False entry, local_cache, autov3_cache, is_checkpoint=False
) )
if item is None: if item is None:
details["unresolved"].append(
{"type": "lora", "entry": self._entry_identifier(entry)}
)
continue 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) self._write_rematch_lora_entry(entry, item)
rematched += 1 rematched += 1
@@ -652,15 +767,50 @@ class RecipeScanner:
checkpoint = recipe.get("checkpoint") checkpoint = recipe.get("checkpoint")
if isinstance(checkpoint, dict): if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint): 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 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) self._write_rematch_checkpoint_entry(checkpoint, item)
rematched += 1 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: if rematched == 0:
return (0, 0) return (0, 0, details)
from ..utils.utils import calculate_recipe_fingerprint from ..utils.utils import calculate_recipe_fingerprint
@@ -673,7 +823,7 @@ class RecipeScanner:
) )
self._update_fts_index_for_recipe(recipe, "update") self._update_fts_index_for_recipe(recipe, "update")
return (rematched, 0) return (rematched, 0, details)
async def rematch_all_recipes( async def rematch_all_recipes(
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
@@ -692,9 +842,14 @@ class RecipeScanner:
(started/processing/cancelled/completed events). (started/processing/cancelled/completed events).
Returns: Returns:
Dict summary of the rematch run Dict summary of the rematch run with unified counters
(success/status/rematched/skipped/errors/total). (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: if progress_callback:
await progress_callback({"status": "started"}) await progress_callback({"status": "started"})
@@ -706,31 +861,53 @@ class RecipeScanner:
cache = await self.get_cached_data() cache = await self.get_cached_data()
all_recipes = list(cache.raw_data) all_recipes = list(cache.raw_data)
total = len(all_recipes) total = len(all_recipes)
rematched_count = 0 matched_recipes = 0
matched_entries = 0
unresolved_recipes = 0
unresolved_entries = 0
skipped_count = 0 skipped_count = 0
errors_count = 0 errors_count = 0
for i, recipe in enumerate(all_recipes): for i, recipe in enumerate(all_recipes):
if self.is_cancelled(): 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: if progress_callback:
await progress_callback( await progress_callback(
{ {
"status": "cancelled", "status": "cancelled",
"current": i, "current": i,
"total": total, "total": total,
"rematched": rematched_count, "rematched": matched_recipes,
"skipped": skipped_count, "skipped": skipped_count,
"errors": errors_count, "errors": errors_count,
"matched_recipes": matched_recipes,
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
} }
) )
return { return {
"success": False, "success": False,
"status": "cancelled", "status": "cancelled",
"rematched": rematched_count, "rematched": matched_recipes,
"skipped": skipped_count, "skipped": skipped_count,
"errors": errors_count, "errors": errors_count,
"total": total, "total": total,
"matched_recipes": matched_recipes,
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
} }
try: 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 recipe, local_cache, autov3_cache
) )
if rematched > 0: if rematched > 0:
rematched_count += 1 matched_recipes += 1
matched_entries += rematched
else: else:
skipped_count += 1 skipped_count += 1
recipe_unresolved = len(details["unresolved"])
if recipe_unresolved > 0:
unresolved_recipes += 1
unresolved_entries += recipe_unresolved
except Exception as exc: except Exception as exc:
logger.error( logger.error(
f"Error rematching recipe {recipe.get('file_path')}: {exc}" 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. # per call, so per-recipe calls would race 5k resort tasks.
self._schedule_resort() 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 # Final progress update
if progress_callback: if progress_callback:
await progress_callback( await progress_callback(
{ {
"status": "completed", "status": "completed",
"rematched": rematched_count, "rematched": matched_recipes,
"skipped": skipped_count, "skipped": skipped_count,
"errors": errors_count, "errors": errors_count,
"total": total, "total": total,
"matched_recipes": matched_recipes,
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
} }
) )
return { return {
"success": True, "success": True,
"rematched": rematched_count, "rematched": matched_recipes,
"skipped": skipped_count, "skipped": skipped_count,
"errors": errors_count, "errors": errors_count,
"total": total, "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]: async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
"""Rematch a set of recipes by their IDs. """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 counted as skipped, and unexpected per-recipe exceptions are counted as
errors with the loop continuing so partial results are never lost. errors with the loop continuing so partial results are never lost.
Persist failures are already converted to the by_id return shape and 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. recipe_ids: List of recipe ids to rematch.
Returns: Returns:
Dict summary of the bulk run Dict summary of the bulk run with unified counters
(success/total/rematched/skipped/errors/recipes). (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) total = len(recipe_ids)
rematched = 0 matched_recipes = 0
matched_entries = 0
unresolved_recipes = 0
unresolved_entries = 0
skipped = 0 skipped = 0
errors = 0 errors = 0
recipes: List[Dict[str, Any]] = [] recipes: List[Dict[str, Any]] = []
details_list: List[Dict[str, Any]] = []
for recipe_id in recipe_ids: for recipe_id in recipe_ids:
try: try:
result = await self.rematch_recipe_by_id(recipe_id) result = await self._rematch_recipe_by_id(recipe_id)
if result.get("success"): 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) skipped += result.get("skipped", 0)
if result.get("recipe"): if result.get("recipe"):
recipes.append(result["recipe"]) recipes.append(result["recipe"])
if result.get("details"):
details_list.append(
{"recipe_id": recipe_id, **result["details"]}
)
else: else:
errors += result.get("errors", 0) errors += result.get("errors", 0)
except RecipeNotFoundError: except RecipeNotFoundError:
@@ -823,13 +1043,30 @@ class RecipeScanner:
self._schedule_resort() 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 { return {
"success": True, "success": True,
"total": total, "total": total,
"rematched": rematched, "rematched": matched_entries,
"skipped": skipped, "skipped": skipped,
"errors": errors, "errors": errors,
"matched_recipes": matched_recipes,
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"recipes": recipes, "recipes": recipes,
"details": details_list,
} }
def _write_rematch_lora_entry( def _write_rematch_lora_entry(

View File

@@ -489,12 +489,56 @@ export class GlobalContextMenu extends BaseContextMenu {
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`); progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
} else if (p.status === 'completed') { } else if (p.status === 'completed') {
isComplete = true; isComplete = true;
progressUI?.complete(translate( // Newer backends report unified matched_entries /
'globalContextMenu.rematchRecipes.success', // matched_recipes; fall back to legacy `rematched`
{ count: p.rematched }, // (recipe count) for older ones.
`Rematched ${p.rematched} recipes.` const entries = p.matched_entries ?? p.rematched ?? 0;
)); const recipes = p.matched_recipes ?? p.rematched ?? 0;
showToast('globalContextMenu.rematchRecipes.success', { count: p.rematched }, 'success'); 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 // Refresh recipes page if active
if (window.recipesPage) { if (window.recipesPage) {
window.recipesPage.refresh(); window.recipesPage.refresh();
@@ -503,12 +547,14 @@ export class GlobalContextMenu extends BaseContextMenu {
throw new Error(p.error || 'Rematch failed'); throw new Error(p.error || 'Rematch failed');
} else if (p.status === 'cancelled') { } else if (p.status === 'cancelled') {
isComplete = true; isComplete = true;
const cancelledEntries = p.matched_entries ?? p.rematched ?? 0;
const cancelledRecipes = p.matched_recipes ?? p.rematched ?? 0;
progressUI?.complete(translate( progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.cancelled', 'globalContextMenu.rematchRecipes.cancelled',
{ count: p.rematched }, { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
`Rematch cancelled. ${p.rematched} recipes were rematched.` `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) { } else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up // Progress might have finished quickly and been cleaned up

View File

@@ -352,12 +352,16 @@ export class RecipeContextMenu extends BaseContextMenu {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// The rematch backend reports `rematched` (not `repaired`) const matchedEntries = result.matched_entries || result.rematched || 0;
if (result.rematched > 0) { const failures = result.errors || 0;
if (matchedEntries > 0) {
const toastKey = failures > 0
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast( showToast(
'toast.recipes.rematchComplete', toastKey,
{ rematched: result.rematched, skipped: result.skipped || 0, total: 1 }, { rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
'success' failures > 0 ? 'warning' : 'success'
); );
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`); const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) { if (detailResponse.ok) {
@@ -366,6 +370,14 @@ export class RecipeContextMenu extends BaseContextMenu {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe); 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 { } else {
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info'); showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
} }

View File

@@ -898,9 +898,17 @@ export class BulkManager {
if (result.success) { if (result.success) {
const total = result.total || filePaths.length; 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 rematched = result.rematched || 0;
const skipped = result.skipped || 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 || []; const recipes = result.recipes || [];
for (const recipe of 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( showToast(
'toast.recipes.rematchComplete', toastKey,
{ rematched, skipped, total }, { rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
'success' 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 { } else {
showToast( showToast(

View File

@@ -2225,7 +2225,7 @@ describe('Interaction-level regression coverage', () => {
ok: true, ok: true,
json: async () => ({ json: async () => ({
success: true, 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(global.fetch).toHaveBeenCalledTimes(2);
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1); 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 // Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.success', 'globalContextMenu.rematchRecipes.success',
{ count: 2 }, { count: 2, recipes: 2, entries: 5, failures: 0 },
'success' 'success'
); );
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1); expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
@@ -2259,6 +2259,165 @@ describe('Interaction-level regression coverage', () => {
delete stateStub.currentPageType; delete stateStub.currentPageType;
}); });
it('uses the warning toast variant when a global rematch completes with failures', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
</div>
`;
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 = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
</div>
`;
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 = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rematch-recipes"></div>
</div>
`;
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 () => { it('toasts the rematched count when a global rematch is cancelled', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">
@@ -2289,7 +2448,7 @@ describe('Interaction-level regression coverage', () => {
ok: true, ok: true,
json: async () => ({ json: async () => ({
success: true, 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(); 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( expect(showToastMock).toHaveBeenCalledWith(
'globalContextMenu.rematchRecipes.cancelled', 'globalContextMenu.rematchRecipes.cancelled',
{ count: 1 }, { count: 1, recipes: 1, entries: 2 },
'info' 'info'
); );
expect(menu._rematchInProgress).toBe(false); expect(menu._rematchInProgress).toBe(false);

View File

@@ -78,7 +78,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
global.fetch global.fetch
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, 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({ .mockResolvedValueOnce({
ok: true, ok: true,
@@ -96,7 +96,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
}); });
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete', 'toast.recipes.rematchComplete',
{ rematched: 2, skipped: 0, total: 1 }, { rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 },
'success' 'success'
); );
expect(showToastMock).not.toHaveBeenCalledWith( 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 () => { it('toasts the skipped message when nothing was rematched', async () => {
const menu = await createMenu(); const menu = await createMenu();
const card = document.getElementById('card'); const card = document.getElementById('card');

View File

@@ -103,9 +103,13 @@ describe('BulkManager.rematchSelectedRecipes', () => {
rematchBulkModelsMock.mockResolvedValue({ rematchBulkModelsMock.mockResolvedValue({
success: true, success: true,
total: 3, total: 3,
rematched: 2, rematched: 4,
skipped: 1, skipped: 1,
errors: 0, errors: 0,
matched_recipes: 2,
matched_entries: 4,
unresolved_recipes: 1,
unresolved_entries: 1,
recipes: [rematchedRecipe], recipes: [rematchedRecipe],
}); });
@@ -118,7 +122,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
]); ]);
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete', 'toast.recipes.rematchComplete',
{ rematched: 2, skipped: 1, total: 3 }, { rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
'success' 'success'
); );
expect(showToastMock).not.toHaveBeenCalledWith( expect(showToastMock).not.toHaveBeenCalledWith(
@@ -132,6 +136,98 @@ describe('BulkManager.rematchSelectedRecipes', () => {
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled(); 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 () => { it('toasts the skipped message when nothing was rematched', async () => {
const bulk = await createBulkManager(); const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp'); stateStub.selectedModels.add('/recipes/a.webp');

View File

@@ -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["success"] is True
assert result["rematched"] == 1 assert result["rematched"] == 1
assert result["skipped"] == 0 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"] is enriched
assert result["recipe"]["file_url"] == "/loras_static/preview/enriched.png" 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]["skipped"] == 1
assert events[3]["errors"] == 0 assert events[3]["errors"] == 0
assert events[3]["total"] == 2 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["success"] is True
assert result["rematched"] == 1 assert result["rematched"] == 1
assert result["skipped"] == 1 assert result["skipped"] == 1
assert result["errors"] == 0 assert result["errors"] == 0
assert result["total"] == 2 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 "status" not in result
assert resort_calls == [True] # Metis F1 — exactly once per run 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], recipe: Dict[str, Any],
local_cache: dict[str, Any], local_cache: dict[str, Any],
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
) -> tuple[int, int]: ) -> tuple[int, int, dict[str, Any]]:
if recipe.get("id") == "boom": if recipe.get("id") == "boom":
raise RuntimeError("kaboom") raise RuntimeError("kaboom")
return (0, 0) return (0, 0, {"matched": [], "unresolved": []})
monkeypatch.setattr(scanner, "_rematch_single_recipe", fake_single) 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 calls += 1
if calls == 1: if calls == 1:
raise RuntimeError("match boom") 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"]) result = await scanner.rematch_recipes_bulk(["r0", "r1"])