From 420530f532240df749e7077be043ccef5e7a6936 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 9 Aug 2026 11:31:00 +0800 Subject: [PATCH] feat(ui): add global, bulk and per-recipe rematch actions --- locales/de.json | 12 + locales/en.json | 12 + locales/es.json | 12 + locales/fr.json | 12 + locales/he.json | 12 + locales/ja.json | 12 + locales/ko.json | 12 + locales/ru.json | 12 + locales/zh-CN.json | 12 + locales/zh-TW.json | 12 + static/js/api/recipeApi.js | 34 +++ .../components/ContextMenu/BulkContextMenu.js | 7 + .../ContextMenu/GlobalContextMenu.js | 101 ++++++++ .../ContextMenu/RecipeContextMenu.js | 48 ++++ static/js/managers/BulkManager.js | 74 +++++- templates/components/context_menu.html | 6 + templates/recipes.html | 3 + tests/frontend/api/recipeApi.bulk.test.js | 62 +++++ .../contextMenu.interactions.test.js | 123 ++++++++++ .../recipeContextMenu.rematch.test.js | 188 +++++++++++++++ .../managers/BulkManager.rematch.test.js | 226 ++++++++++++++++++ 21 files changed, 991 insertions(+), 1 deletion(-) create mode 100644 tests/frontend/components/recipeContextMenu.rematch.test.js create mode 100644 tests/frontend/managers/BulkManager.rematch.test.js diff --git a/locales/de.json b/locales/de.json index 0c6782e6..5008d46f 100644 --- a/locales/de.json +++ b/locales/de.json @@ -186,6 +186,13 @@ "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "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}" + }, "manageExcludedModels": { "label": "Ausgeschlossene Modelle verwalten" }, @@ -768,6 +775,7 @@ "copyAll": "Alle Syntax kopieren", "refreshAll": "Alle Metadaten aktualisieren", "repairMetadata": "Metadaten der Auswahl reparieren", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "Aus Quelle neu importieren", "checkUpdates": "Auswahl auf Updates prüfen", "moveAll": "Alle in Ordner verschieben", @@ -823,6 +831,7 @@ "setContentRating": "Inhaltsbewertung festlegen", "moveToFolder": "In Ordner verschieben", "repairMetadata": "Metadaten reparieren", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "Aus Quelle neu importieren", "excludeModel": "Modell ausschließen", "restoreModel": "Modell wiederherstellen", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "Rezept wird aus Quelle neu importiert...", "reimportSuccess": "Rezept erfolgreich neu importiert", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", diff --git a/locales/en.json b/locales/en.json index d74cce8a..770b4dfe 100644 --- a/locales/en.json +++ b/locales/en.json @@ -186,6 +186,13 @@ "cancelled": "Repair cancelled. {count} recipes were repaired.", "error": "Recipe repair failed: {message}" }, + "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.", + "error": "Recipe rematch failed: {message}" + }, "manageExcludedModels": { "label": "Manage Excluded Models" }, @@ -768,6 +775,7 @@ "copyAll": "Copy Selected Syntax", "refreshAll": "Refresh Selected Metadata", "repairMetadata": "Repair Metadata for Selected", + "rematchMetadata": "Rematch Selected to Local Models", "reimportMetadata": "Re-import from Source", "checkUpdates": "Check Updates for Selected", "moveAll": "Move Selected to Folder", @@ -823,6 +831,7 @@ "setContentRating": "Set Content Rating", "moveToFolder": "Move to Folder", "repairMetadata": "Repair metadata", + "rematchMetadata": "Rematch to local models", "reimportMetadata": "Re-import from Source", "excludeModel": "Exclude Model", "restoreModel": "Restore Model", @@ -1951,6 +1960,9 @@ "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})", + "rematchSkipped": "No rematch needed for any of the {total} selected recipes", + "rematchFailed": "Failed to rematch selected recipes: {message}", "reimporting": "Re-importing recipe from source...", "reimportSuccess": "Recipe re-imported successfully", "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", diff --git a/locales/es.json b/locales/es.json index 43045d9f..4418fbb1 100644 --- a/locales/es.json +++ b/locales/es.json @@ -186,6 +186,13 @@ "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "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}" + }, "manageExcludedModels": { "label": "Gestionar modelos excluidos" }, @@ -768,6 +775,7 @@ "copyAll": "Copiar toda la sintaxis", "refreshAll": "Actualizar todos los metadatos", "repairMetadata": "Reparar metadatos de la selección", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "Reimportar desde origen", "checkUpdates": "Comprobar actualizaciones para la selección", "moveAll": "Mover todos a carpeta", @@ -823,6 +831,7 @@ "setContentRating": "Establecer clasificación de contenido", "moveToFolder": "Mover a carpeta", "repairMetadata": "Reparar metadatos", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "Reimportar desde origen", "excludeModel": "Excluir modelo", "restoreModel": "Restaurar modelo", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "Reimportando receta desde origen...", "reimportSuccess": "Receta reimportada exitosamente", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", diff --git a/locales/fr.json b/locales/fr.json index 1299c565..ec7cf847 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -186,6 +186,13 @@ "cancelled": "Réparation annulée. {count} recettes ont été réparées.", "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}" + }, "manageExcludedModels": { "label": "Gérer les modèles exclus" }, @@ -768,6 +775,7 @@ "copyAll": "Copier toute la syntaxe", "refreshAll": "Actualiser toutes les métadonnées", "repairMetadata": "Réparer les métadonnées de la sélection", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "Ré-importer depuis la source", "checkUpdates": "Vérifier les mises à jour pour la sélection", "moveAll": "Déplacer tout vers un dossier", @@ -823,6 +831,7 @@ "setContentRating": "Définir la classification du contenu", "moveToFolder": "Déplacer vers un dossier", "repairMetadata": "Réparer les métadonnées", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "Ré-importer depuis la source", "excludeModel": "Exclure le modèle", "restoreModel": "Restaurer le modèle", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "Ré-import de la recette depuis la source...", "reimportSuccess": "Recette ré-importée avec succès", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", diff --git a/locales/he.json b/locales/he.json index 39ade311..61c0de7a 100644 --- a/locales/he.json +++ b/locales/he.json @@ -186,6 +186,13 @@ "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "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}" + }, "manageExcludedModels": { "label": "ניהול מודלים מוחרגים" }, @@ -768,6 +775,7 @@ "copyAll": "העתק את כל התחבירים", "refreshAll": "רענן את כל המטא-דאטה", "repairMetadata": "תקן מטא-דאטה עבור הנבחרים", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "ייבא מחדש ממקור", "checkUpdates": "בדוק עדכונים לבחירה", "moveAll": "העבר הכל לתיקייה", @@ -823,6 +831,7 @@ "setContentRating": "הגדר דירוג תוכן", "moveToFolder": "העבר לתיקייה", "repairMetadata": "תיקון מטא-דאטה", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "ייבא מחדש ממקור", "excludeModel": "החרג מודל", "restoreModel": "שחזור מודל", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "מייבא מתכון מחדש מהמקור...", "reimportSuccess": "המתכון יובא מחדש בהצלחה", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", diff --git a/locales/ja.json b/locales/ja.json index cc687a63..adff9e83 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -186,6 +186,13 @@ "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "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}" + }, "manageExcludedModels": { "label": "除外モデルを管理" }, @@ -768,6 +775,7 @@ "copyAll": "すべての構文をコピー", "refreshAll": "すべてのメタデータを更新", "repairMetadata": "選択したレシピのメタデータを修復", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "ソースから再インポート", "checkUpdates": "選択項目の更新を確認", "moveAll": "すべてをフォルダに移動", @@ -823,6 +831,7 @@ "setContentRating": "コンテンツレーティングを設定", "moveToFolder": "フォルダに移動", "repairMetadata": "メタデータを修復", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "ソースから再インポート", "excludeModel": "モデルを除外", "restoreModel": "モデルを復元", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "ソースからレシピを再インポート中...", "reimportSuccess": "レシピの再インポートが完了しました", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", diff --git a/locales/ko.json b/locales/ko.json index 72597ee9..2c0d7bf8 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -186,6 +186,13 @@ "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "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}" + }, "manageExcludedModels": { "label": "제외된 모델 관리" }, @@ -768,6 +775,7 @@ "copyAll": "모든 문법 복사", "refreshAll": "모든 메타데이터 새로고침", "repairMetadata": "선택한 레시피 메타데이터 복구", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "소스에서 다시 가져오기", "checkUpdates": "선택 항목 업데이트 확인", "moveAll": "모두 폴더로 이동", @@ -823,6 +831,7 @@ "setContentRating": "콘텐츠 등급 설정", "moveToFolder": "폴더로 이동", "repairMetadata": "메타데이터 복구", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "소스에서 다시 가져오기", "excludeModel": "모델 제외", "restoreModel": "모델 복원", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "소스에서 레시피를 다시 가져오는 중...", "reimportSuccess": "레시피를 다시 가져왔습니다", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", diff --git a/locales/ru.json b/locales/ru.json index cbe2da5e..faf2c7fc 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -186,6 +186,13 @@ "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "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}" + }, "manageExcludedModels": { "label": "Управление исключёнными моделями" }, @@ -768,6 +775,7 @@ "copyAll": "Копировать весь синтаксис", "refreshAll": "Обновить все метаданные", "repairMetadata": "Восстановить метаданные для выбранных", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "Переимпортировать из источника", "checkUpdates": "Проверить обновления для выбранных", "moveAll": "Переместить все в папку", @@ -823,6 +831,7 @@ "setContentRating": "Установить рейтинг контента", "moveToFolder": "Переместить в папку", "repairMetadata": "Восстановить метаданные", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "Переимпортировать из источника", "excludeModel": "Исключить модель", "restoreModel": "Восстановить модель", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "Переимпорт рецепта из источника...", "reimportSuccess": "Рецепт успешно переимпортирован", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b6ddc84e..875dbd38 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -186,6 +186,13 @@ "cancelled": "修复已取消。已修复 {count} 个配方。", "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}" + }, "manageExcludedModels": { "label": "管理已排除的模型" }, @@ -768,6 +775,7 @@ "copyAll": "复制所选中语法", "refreshAll": "刷新所选中元数据", "repairMetadata": "修复所选中元数据", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "从源重新导入", "checkUpdates": "检查所选更新", "moveAll": "移动所选中到文件夹", @@ -823,6 +831,7 @@ "setContentRating": "设置内容评级", "moveToFolder": "移动到文件夹", "repairMetadata": "修复元数据", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "从源重新导入", "excludeModel": "排除模型", "restoreModel": "恢复模型", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "正在从源重新导入配方...", "reimportSuccess": "配方已从源重新导入成功", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 9a1a4ff9..33bce12f 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -186,6 +186,13 @@ "cancelled": "修復已取消。已修復 {count} 個配方。", "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}" + }, "manageExcludedModels": { "label": "管理已排除的模型" }, @@ -768,6 +775,7 @@ "copyAll": "複製全部語法", "refreshAll": "刷新全部 metadata", "repairMetadata": "修復所選中元數據", + "rematchMetadata": "[TODO: Translate] Rematch Selected to Local Models", "reimportMetadata": "從來源重新匯入", "checkUpdates": "檢查所選更新", "moveAll": "全部移動到資料夾", @@ -823,6 +831,7 @@ "setContentRating": "設定內容分級", "moveToFolder": "移動到資料夾", "repairMetadata": "修復元數據", + "rematchMetadata": "[TODO: Translate] Rematch to local models", "reimportMetadata": "從來源重新匯入", "excludeModel": "排除模型", "restoreModel": "還原模型", @@ -1951,6 +1960,9 @@ "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}", "reimporting": "正在從來源重新匯入配方...", "reimportSuccess": "配方已從來源重新匯入成功", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index f0e09d45..70a2951e 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -16,6 +16,8 @@ const RECIPE_ENDPOINTS = { moveBulk: '/api/lm/recipes/move-bulk', bulkDelete: '/api/lm/recipes/bulk-delete', repairBulk: '/api/lm/recipes/repair-bulk', + rematchBulk: '/api/lm/recipes/rematch-bulk', + rematchSingle: '/api/lm/recipe/{recipe_id}/rematch', }; const RECIPE_SIDEBAR_CONFIG = { @@ -586,6 +588,38 @@ export class RecipeSidebarApiClient { return result; } + async rematchBulkModels(filePaths) { + if (!filePaths || filePaths.length === 0) { + throw new Error('No file paths provided'); + } + + const recipeIds = filePaths + .map((path) => extractRecipeId(path)) + .filter((id) => !!id); + + if (recipeIds.length === 0) { + throw new Error('No recipe IDs could be derived from file paths'); + } + + const response = await fetch(this.apiConfig.endpoints.rematchBulk, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_ids: recipeIds, + }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to rematch recipes'); + } + + return result; + } + async bulkDeleteModels(filePaths) { if (!filePaths || filePaths.length === 0) { throw new Error('No file paths provided'); diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index 1750389a..72a13a76 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -43,6 +43,7 @@ export class BulkContextMenu extends BaseContextMenu { const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]'); const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]'); const reimportMetadataItem = this.menu.querySelector('[data-action="reimport-metadata"]'); + const rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]'); if (repairMetadataItem) { repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none'; @@ -50,6 +51,9 @@ export class BulkContextMenu extends BaseContextMenu { if (reimportMetadataItem) { reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none'; } + if (rematchMetadataItem) { + rematchMetadataItem.style.display = config.rematchMetadata ? 'flex' : 'none'; + } const isEmbeddings = currentModelType === 'embeddings'; if (sendToWorkflowAppendItem) { @@ -282,6 +286,9 @@ export class BulkContextMenu extends BaseContextMenu { case 'repair-metadata': bulkManager.repairSelectedRecipes(); break; + case 'rematch-metadata': + bulkManager.rematchSelectedRecipes(); + break; case 'reimport-metadata': bulkManager.reimportSelectedRecipes(); break; diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index be834c7b..70a16e68 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -24,6 +24,7 @@ export class GlobalContextMenu extends BaseContextMenu { const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]'); const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]'); + const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]'); const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]'); const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator'); @@ -41,6 +42,7 @@ export class GlobalContextMenu extends BaseContextMenu { excludedModelsItem?.classList.add('hidden'); groupByModelItem?.classList.add('hidden'); repairRecipesItem?.classList.remove('hidden'); + rematchRecipesItem?.classList.remove('hidden'); } else { modelUpdateItem?.classList.remove('hidden'); licenseRefreshItem?.classList.remove('hidden'); @@ -49,6 +51,7 @@ export class GlobalContextMenu extends BaseContextMenu { excludedModelsItem?.classList.remove('hidden'); groupByModelItem?.classList.remove('hidden'); repairRecipesItem?.classList.add('hidden'); + rematchRecipesItem?.classList.add('hidden'); } super.showMenu(x, y, contextOrigin); @@ -81,6 +84,11 @@ export class GlobalContextMenu extends BaseContextMenu { console.error('Failed to repair recipes:', error); }); break; + case 'rematch-recipes': + this.rematchRecipes(menuItem).catch((error) => { + console.error('Failed to rematch recipes:', error); + }); + break; case 'manage-excluded-models': this.manageExcludedModels(); break; @@ -439,4 +447,97 @@ export class GlobalContextMenu extends BaseContextMenu { console.error('Failed to cancel recipe repair:', error); } } + + async rematchRecipes(menuItem) { + if (this._rematchInProgress) { + return; + } + + this._rematchInProgress = true; + menuItem?.classList.add('disabled'); + + const loadingMessage = translate( + 'globalContextMenu.rematchRecipes.loading', + {}, + 'Rematching recipes to local models...' + ); + + const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage); + progressUI?.showCancelButton(() => this.cancelRematch()); + + try { + const response = await fetch('/api/lm/recipes/rematch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to start rematch'); + } + + // Poll for progress (mirrors the repair flow; the backend reports `rematched` counts) + let isComplete = false; + while (!isComplete && this._rematchInProgress) { + const progressResponse = await fetch('/api/lm/recipes/rematch-progress'); + if (progressResponse.ok) { + const progressResult = await progressResponse.json(); + if (progressResult.success && progressResult.progress) { + const p = progressResult.progress; + if (p.status === 'processing') { + const percent = (p.current / p.total) * 100; + progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`); + } else if (p.status === 'completed') { + isComplete = true; + progressUI?.complete(translate( + 'globalContextMenu.rematchRecipes.success', + { count: p.rematched }, + `Rematched ${p.rematched} recipes.` + )); + showToast('globalContextMenu.rematchRecipes.success', { count: p.rematched }, 'success'); + // Refresh recipes page if active + if (window.recipesPage) { + window.recipesPage.refresh(); + } + } else if (p.status === 'error') { + throw new Error(p.error || 'Rematch failed'); + } else if (p.status === 'cancelled') { + isComplete = true; + progressUI?.complete(translate( + 'globalContextMenu.rematchRecipes.cancelled', + { count: p.rematched }, + `Rematch cancelled. ${p.rematched} recipes were rematched.` + )); + showToast('globalContextMenu.rematchRecipes.cancelled', { count: p.rematched }, 'info'); + } + } else if (progressResponse.status === 404) { + // Progress might have finished quickly and been cleaned up + isComplete = true; + progressUI?.complete(); + } + } + + if (!isComplete) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + } catch (error) { + console.error('Recipe rematch failed:', error); + progressUI?.complete(translate('globalContextMenu.rematchRecipes.error', { message: error.message }, 'Rematch failed: {message}')); + showToast('globalContextMenu.rematchRecipes.error', { message: error.message }, 'error'); + } finally { + this._rematchInProgress = false; + menuItem?.classList.remove('disabled'); + } + } + + async cancelRematch() { + try { + await fetch('/api/lm/recipes/cancel-rematch', { + method: 'POST', + }); + } catch (error) { + console.error('Failed to cancel recipe rematch:', error); + } + } } diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 968767c8..f9162f1b 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -97,6 +97,10 @@ export class RecipeContextMenu extends BaseContextMenu { // Repair recipe metadata this.repairRecipe(recipeId); break; + case 'rematch': + // Rematch recipe resources to local models + this.rematchRecipe(recipeId); + break; case 'reimport': this.reimportRecipe(recipeId); break; @@ -330,6 +334,50 @@ export class RecipeContextMenu extends BaseContextMenu { } } + async rematchRecipe(recipeId) { + if (!recipeId) { + showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error'); + return; + } + + // Capture before any await: the menu's click handler nulls currentCard + const filePath = this.currentCard?.dataset?.filepath; + + try { + showToast('Rematching recipe to local models...', {}, 'info'); + + const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.success) { + // The rematch backend reports `rematched` (not `repaired`) + if (result.rematched > 0) { + showToast( + 'toast.recipes.rematchComplete', + { rematched: result.rematched, skipped: result.skipped || 0, total: 1 }, + 'success' + ); + const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`); + if (detailResponse.ok) { + const updatedRecipe = await detailResponse.json(); + if (filePath && state.virtualScroller) { + state.virtualScroller.updateSingleItem(filePath, updatedRecipe); + } + } + } else { + showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info'); + } + } else { + throw new Error(result.error || 'Rematch failed'); + } + } catch (error) { + console.error('Error rematching recipe:', error); + showToast('toast.recipes.rematchFailed', { message: error.message }, 'error'); + } + } + async reimportRecipe(recipeId) { if (!recipeId) { showToast('recipes.contextMenu.reimport.missingId', {}, 'error'); diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 73aae9f3..81c145ee 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -95,7 +95,8 @@ export class BulkManager { setFavorite: true, unfavorite: true, repairMetadata: true, - reimportMetadata: true + reimportMetadata: true, + rematchMetadata: true } }; @@ -871,6 +872,77 @@ export class BulkManager { } } + async rematchSelectedRecipes() { + if (state.selectedModels.size === 0) { + showToast('toast.recipes.noRecipesSelected', {}, 'warning'); + return; + } + + if (state.currentPageType !== 'recipes') { + showToast('This operation is only available for recipes', {}, 'warning'); + return; + } + + try { + const apiClient = this.getActiveApiClient(); + const filePaths = Array.from(state.selectedModels); + + if (typeof apiClient.rematchBulkModels !== 'function') { + showToast('Bulk rematch is not supported for this model type', {}, 'error'); + return; + } + + state.loadingManager.showSimpleLoading('Rematching recipes to local models...'); + + const result = await apiClient.rematchBulkModels(filePaths); + + if (result.success) { + const total = result.total || filePaths.length; + // The rematch backend reports `rematched` (not `repaired`) + const rematched = result.rematched || 0; + const skipped = result.skipped || 0; + + const recipes = result.recipes || []; + for (const recipe of recipes) { + if (recipe.file_path) { + state.virtualScroller.updateSingleItem( + recipe.file_path, + recipe + ); + } + } + + if (rematched > 0) { + showToast( + 'toast.recipes.rematchComplete', + { rematched, skipped, total }, + 'success' + ); + } else { + showToast( + 'toast.recipes.rematchSkipped', + { total }, + 'info' + ); + } + + if (state.bulkMode) this.toggleBulkMode(); + } else { + throw new Error(result.error || 'Bulk rematch failed'); + } + } catch (error) { + console.error('Error during bulk recipe rematch:', error); + showToast('toast.recipes.rematchFailed', { message: error.message }, 'error'); + } finally { + if (state.loadingManager?.hide) { + state.loadingManager.hide(); + } + if (typeof state.loadingManager?.restoreProgressBar === 'function') { + state.loadingManager.restoreProgressBar(); + } + } + } + async refreshAllMetadata() { if (state.selectedModels.size === 0) { showToast('toast.models.noModelsSelected', {}, 'warning'); diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index a6c0c8d5..bc8c18ae 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -97,6 +97,9 @@
{{ t('loras.bulkOperations.repairMetadata') }}
+
+ {{ t('loras.bulkOperations.rematchMetadata') }} +
{{ t('loras.bulkOperations.reimportMetadata') }}
@@ -202,6 +205,9 @@
{{ t('globalContextMenu.repairRecipes.label') }}
+
+ {{ t('globalContextMenu.rematchRecipes.label') }} +
diff --git a/templates/recipes.html b/templates/recipes.html index 26d6b485..625599ce 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -21,6 +21,9 @@
{{ t('loras.contextMenu.repairMetadata') }}
+
+ {{ t('loras.contextMenu.rematchMetadata') }} +
{{ t('loras.contextMenu.reimportMetadata') }}
diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js index 780fa1fa..55a58487 100644 --- a/tests/frontend/api/recipeApi.bulk.test.js +++ b/tests/frontend/api/recipeApi.bulk.test.js @@ -216,4 +216,66 @@ describe('RecipeSidebarApiClient bulk operations', () => { expect(restoreScrollPositionMock).not.toHaveBeenCalled(); expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1); }); + + it('posts exactly recipe_ids when rematching in bulk', async () => { + const api = new RecipeSidebarApiClient(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + total: 2, + rematched: 2, + skipped: 0, + errors: 0, + recipes: [], + }), + }); + + const result = await api.rematchBulkModels(['/recipes/a.webp', '/recipes/b.webp']); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/recipes/rematch-bulk', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + ); + + // Exact-body assertion: no extra fields beyond recipe_ids + expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ + recipe_ids: ['a', 'b'], + }); + expect(result).toMatchObject({ success: true, rematched: 2 }); + }); + + it('derives recipe IDs via extractRecipeId and skips empty paths', async () => { + const api = new RecipeSidebarApiClient(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, total: 1, rematched: 0, skipped: 1, errors: 0 }), + }); + + await api.rematchBulkModels(['', '/recipes/sub folder/recipe-1.webp']); + + expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ + recipe_ids: ['recipe-1'], + }); + }); + + it('rejects bulk rematch without file paths', async () => { + const api = new RecipeSidebarApiClient(); + + await expect(api.rematchBulkModels([])).rejects.toThrow('No file paths provided'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('throws the backend error when bulk rematch fails', async () => { + const api = new RecipeSidebarApiClient(); + global.fetch.mockResolvedValue({ + ok: false, + json: async () => ({ success: false, error: 'Rematch already running' }), + }); + + await expect(api.rematchBulkModels(['/recipes/a.webp'])).rejects.toThrow('Rematch already running'); + }); }); diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index fd172496..e9b6562e 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -2186,4 +2186,127 @@ describe('Interaction-level regression coverage', () => { document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true })); expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true }); }); + + it('runs global recipe rematch with polling and toasts the rematched count', async () => { + document.body.innerHTML = ` +
+
+
+ `; + + const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js'); + const menu = new GlobalContextMenu(); + const rematchItem = document.querySelector('[data-action="rematch-recipes"]'); + + const progressUI = { + updateProgress: vi.fn(), + showCancelButton: vi.fn(), + complete: vi.fn().mockResolvedValue(undefined), + }; + loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI); + window.recipesPage = { refresh: vi.fn() }; + + // Menu item is recipes-page only + stateStub.currentPageType = 'recipes'; + menu.showMenu(100, 200); + expect(rematchItem.classList.contains('hidden')).toBe(false); + stateStub.currentPageType = 'loras'; + menu.showMenu(100, 200); + expect(rematchItem.classList.contains('hidden')).toBe(true); + 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: 1, total: 3 }, + }), + }); + + rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + expect(rematchItem.classList.contains('disabled')).toBe(true); + + for (let i = 0; i < 5; i++) { + await flushAsyncTasks(); + } + + expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress'); + expect(global.fetch).toHaveBeenCalledTimes(2); + + expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1); + expect(progressUI.complete).toHaveBeenCalledWith('Rematched 2 recipes.'); + // Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined + expect(showToastMock).toHaveBeenCalledWith( + 'globalContextMenu.rematchRecipes.success', + { count: 2 }, + 'success' + ); + expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1); + expect(rematchItem.classList.contains('disabled')).toBe(false); + 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 = ` +
+
+
+ `; + + 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); + + 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: 'cancelled', rematched: 1, skipped: 0, total: 3 }, + }), + }); + + rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + + for (let i = 0; i < 5; i++) { + await flushAsyncTasks(); + } + + expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes were rematched.'); + expect(showToastMock).toHaveBeenCalledWith( + 'globalContextMenu.rematchRecipes.cancelled', + { count: 1 }, + 'info' + ); + expect(menu._rematchInProgress).toBe(false); + + delete stateStub.currentPageType; + }); }); diff --git a/tests/frontend/components/recipeContextMenu.rematch.test.js b/tests/frontend/components/recipeContextMenu.rematch.test.js new file mode 100644 index 00000000..b650f25b --- /dev/null +++ b/tests/frontend/components/recipeContextMenu.rematch.test.js @@ -0,0 +1,188 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const updateSingleItemMock = vi.fn(); +const handleCommonMenuActionsMock = vi.fn(() => false); + +const stateStub = { + virtualScroller: { updateSingleItem: updateSingleItemMock }, +}; + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + updateRecipeMetadata: vi.fn(), +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, +})); + +vi.mock('../../../static/js/managers/MoveManager.js', () => ({ + moveManager: { showMoveModal: vi.fn() }, +})); + +vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({ + ModelContextMenuMixin: { + handleCommonMenuActions: handleCommonMenuActionsMock, + initNSFWSelector: vi.fn(), + }, +})); + +const flushAsyncTasks = async (rounds = 5) => { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}; + +describe('RecipeContextMenu.rematchRecipe', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ` + +
+ `; + global.fetch = vi.fn(); + }); + + afterEach(() => { + delete global.fetch; + }); + + async function createMenu() { + const { RecipeContextMenu } = await import( + '../../../static/js/components/ContextMenu/RecipeContextMenu.js' + ); + return new RecipeContextMenu(); + } + + // Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired` + // mirror would fire the skipped toast here. + it('posts to the per-recipe rematch endpoint and toasts the rematched count', 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: 2, skipped: 0 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }), + }); + + document + .querySelector('[data-action="rematch"]') + .dispatchEvent(new Event('click', { bubbles: true })); + + await flushAsyncTasks(); + + expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', { + method: 'POST', + }); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchComplete', + { rematched: 2, skipped: 0, total: 1 }, + 'success' + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchSkipped', + expect.anything(), + expect.anything() + ); + expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1'); + expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', { + id: 'recipe-1', + title: 'Updated Recipe', + }); + }); + + it('toasts the skipped message when nothing was rematched', 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: 1 }), + }); + + document + .querySelector('[data-action="rematch"]') + .dispatchEvent(new Event('click', { bubbles: true })); + + await flushAsyncTasks(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchSkipped', + { total: 1 }, + 'info' + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchComplete', + expect.anything(), + expect.anything() + ); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + // Oracle R4-F2 pin: failure surfaces `result.error` (e.g. the 409 body). + it('surfaces result.error when the rematch is rejected', async () => { + const menu = await createMenu(); + const card = document.getElementById('card'); + menu.showMenu(100, 100, card); + + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ success: false, error: 'Recipe rematch already in progress' }), + }); + + document + .querySelector('[data-action="rematch"]') + .dispatchEvent(new Event('click', { bubbles: true })); + + await flushAsyncTasks(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchFailed', + { message: 'Recipe rematch already in progress' }, + 'error' + ); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('toasts the failure message when the fetch throws', async () => { + const menu = await createMenu(); + const card = document.getElementById('card'); + menu.showMenu(100, 100, card); + + global.fetch.mockRejectedValueOnce(new Error('network down')); + + document + .querySelector('[data-action="rematch"]') + .dispatchEvent(new Event('click', { bubbles: true })); + + await flushAsyncTasks(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchFailed', + { message: 'network down' }, + 'error' + ); + }); +}); diff --git a/tests/frontend/managers/BulkManager.rematch.test.js b/tests/frontend/managers/BulkManager.rematch.test.js new file mode 100644 index 00000000..94369902 --- /dev/null +++ b/tests/frontend/managers/BulkManager.rematch.test.js @@ -0,0 +1,226 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const rematchBulkModelsMock = vi.fn(); +const updateSingleItemMock = vi.fn(); + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + restoreProgressBar: vi.fn(), +}; + +const stateStub = { + currentPageType: 'recipes', + bulkMode: false, + selectedModels: new Set(), + loadingManager: loadingManagerStub, + virtualScroller: { updateSingleItem: updateSingleItemMock }, + global: { settings: {} }, +}; + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, + getCurrentPageState: vi.fn(), +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendEmbeddingToWorkflow: vi.fn(), + buildLoraSyntax: vi.fn(), + getNSFWLevelName: vi.fn(), +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: vi.fn(), + resetAndReload: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + RecipeSidebarApiClient: class { + constructor() { + this.rematchBulkModels = rematchBulkModelsMock; + } + }, + updateRecipeMetadata: vi.fn(), + extractRecipeId: vi.fn(), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' }, + MODEL_CONFIG: {}, +})); + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { showModal: vi.fn(), closeModal: vi.fn() }, +})); + +vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ + updateCardsForBulkMode: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)), +})); + +vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({ + getPriorityTagSuggestions: vi.fn(), +})); + +vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({ + getNsfwLevelSelector: vi.fn(), +})); + +describe('BulkManager.rematchSelectedRecipes', () => { + beforeEach(() => { + vi.clearAllMocks(); + stateStub.currentPageType = 'recipes'; + stateStub.bulkMode = false; + stateStub.selectedModels.clear(); + }); + + async function createBulkManager() { + const { BulkManager } = await import('../../../static/js/managers/BulkManager.js'); + return new BulkManager(); + } + + it('exposes the rematch action on the recipes page action config', async () => { + const bulk = await createBulkManager(); + expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true); + }); + + // Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind + // `repaired` mirror would fire the skipped toast with count 0 here. + it('toasts the rematched count when the bulk rematch succeeds', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + stateStub.selectedModels.add('/recipes/b.webp'); + stateStub.selectedModels.add('/recipes/c.webp'); + + const rematchedRecipe = { file_path: '/recipes/a.webp', title: 'A' }; + rematchBulkModelsMock.mockResolvedValue({ + success: true, + total: 3, + rematched: 2, + skipped: 1, + errors: 0, + recipes: [rematchedRecipe], + }); + + await bulk.rematchSelectedRecipes(); + + expect(rematchBulkModelsMock).toHaveBeenCalledWith([ + '/recipes/a.webp', + '/recipes/b.webp', + '/recipes/c.webp', + ]); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchComplete', + { rematched: 2, skipped: 1, total: 3 }, + 'success' + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchSkipped', + expect.anything(), + expect.anything() + ); + expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe); + expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled(); + expect(loadingManagerStub.hide).toHaveBeenCalled(); + expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled(); + }); + + it('toasts the skipped message when nothing was rematched', 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: 2, + errors: 0, + recipes: [], + }); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchSkipped', + { total: 2 }, + 'info' + ); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchComplete', + expect.anything(), + expect.anything() + ); + expect(loadingManagerStub.hide).toHaveBeenCalled(); + expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled(); + }); + + it('surfaces the backend error message when the bulk rematch fails', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + + rematchBulkModelsMock.mockResolvedValue({ + success: false, + error: 'Rematch already in progress', + }); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchFailed', + { message: 'Rematch already in progress' }, + 'error' + ); + expect(loadingManagerStub.hide).toHaveBeenCalled(); + }); + + it('toasts the failure message when the API call throws', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + + rematchBulkModelsMock.mockRejectedValue(new Error('network down')); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.rematchFailed', + { message: 'network down' }, + 'error' + ); + }); + + it('warns and does not call the API when nothing is selected', async () => { + const bulk = await createBulkManager(); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.noRecipesSelected', + {}, + 'warning' + ); + expect(rematchBulkModelsMock).not.toHaveBeenCalled(); + }); + + it('warns and does not call the API outside the recipes page', async () => { + const bulk = await createBulkManager(); + stateStub.currentPageType = 'loras'; + stateStub.selectedModels.add('/models/a.safetensors'); + + await bulk.rematchSelectedRecipes(); + + expect(showToastMock).toHaveBeenCalledWith( + 'This operation is only available for recipes', + {}, + 'warning' + ); + expect(rematchBulkModelsMock).not.toHaveBeenCalled(); + }); +});