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}"
|
"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})",
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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})",
|
||||||
|
|||||||
@@ -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})",
|
||||||
|
|||||||
@@ -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})",
|
||||||
|
|||||||
@@ -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} 件)",
|
||||||
|
|||||||
@@ -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}개)",
|
||||||
|
|||||||
@@ -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})",
|
||||||
|
|||||||
@@ -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} 个)",
|
||||||
|
|||||||
@@ -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} 個)",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user