feat(ui): add global, bulk and per-recipe rematch actions

This commit is contained in:
Will Miao
2026-08-09 11:31:00 +08:00
parent 3001f0f0ef
commit 420530f532
21 changed files with 991 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,6 +97,9 @@
<div class="context-menu-item" data-action="repair-metadata">
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span>
</div>
<div class="context-menu-item" data-action="rematch-metadata">
<i class="fas fa-link"></i> <span>{{ t('loras.bulkOperations.rematchMetadata') }}</span>
</div>
<div class="context-menu-item" data-action="reimport-metadata">
<i class="fas fa-undo-alt"></i> <span>{{ t('loras.bulkOperations.reimportMetadata') }}</span>
</div>
@@ -202,6 +205,9 @@
<div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
</div>
<div class="context-menu-item" data-action="rematch-recipes">
<i class="fas fa-link"></i> <span>{{ t('globalContextMenu.rematchRecipes.label') }}</span>
</div>
</div>
<!-- Sidebar Folder Context Menu -->

View File

@@ -21,6 +21,9 @@
<div class="context-menu-item" data-action="repair">
<i class="fas fa-tools"></i> {{ t('loras.contextMenu.repairMetadata') }}
</div>
<div class="context-menu-item" data-action="rematch">
<i class="fas fa-link"></i> {{ t('loras.contextMenu.rematchMetadata') }}
</div>
<div class="context-menu-item" data-action="reimport">
<i class="fas fa-undo-alt"></i> {{ t('loras.contextMenu.reimportMetadata') }}
</div>

View File

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

View File

@@ -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 = `
<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() };
// 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 = `
<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);
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;
});
});

View File

@@ -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 = `
<div id="recipeContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="rematch"></div>
<div class="context-menu-item download-missing-item" data-action="download-missing"></div>
</div>
<div id="card" class="model-card" data-id="recipe-1" data-filepath="/recipes/recipe-1.webp"></div>
`;
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'
);
});
});

View File

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