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

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

View File

@@ -187,11 +187,14 @@
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
},
"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})",

View File

@@ -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...",

View File

@@ -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})",

View File

@@ -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})",

View File

@@ -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})",

View File

@@ -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} 件)",

View File

@@ -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}개)",

View File

@@ -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})",

View File

@@ -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} 个)",

View File

@@ -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} 個)",

View File

@@ -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(

View File

@@ -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

View File

@@ -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');
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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');

View File

@@ -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');

View File

@@ -2317,6 +2317,18 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
assert result["success"] is True
assert result["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"])