mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 15:30:16 -03:00
feat(recipes): report rematch results with aggregate logs and toast feedback
This commit is contained in:
@@ -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})",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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} 件)",
|
||||
|
||||
@@ -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}개)",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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} 个)",
|
||||
|
||||
@@ -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} 個)",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
// 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: p.rematched },
|
||||
`Rematched ${p.rematched} recipes.`
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
`Matched ${entries} entries across ${recipes} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.success', { count: p.rematched }, 'success');
|
||||
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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = `
|
||||
<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 () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user