diff --git a/locales/de.json b/locales/de.json index 70b74fbf..67b0397b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1501,6 +1501,22 @@ "note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.", "downloadButton": "{count} LoRA(s) herunterladen" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "Lokale Beispielbilder", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", @@ -2168,6 +2184,7 @@ "createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen", "created": "Rezept erfolgreich erstellt", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "Kein vorheriges Rezept verfügbar", "noNextRecipe": "Kein weiteres Rezept verfügbar", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", diff --git a/locales/en.json b/locales/en.json index ad1bd3c7..a11412e2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1501,6 +1501,22 @@ "note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.", "downloadButton": "Download {count} LoRA(s)" }, + "rematchOptions": { + "title": "Rematch Recipes", + "messageGlobal": "All recipes will be scanned against your local model library.", + "messageSingle": "This recipe will be scanned against your local model library.", + "messageBulk": "{count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "Also reconnect missing models by file name", + "relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.", + "confirmButton": "Rematch" + }, + "rematchResults": { + "title": "Rematch Results — Filename Matches", + "message": "These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "Undo", + "undone": "Undone", + "undoFailed": "Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "Local Example Images", "message": "No local example images found for this model. View options:", @@ -2168,6 +2184,7 @@ "createMissingData": "Missing required data to create recipe", "created": "Recipe created successfully", "noMissingLoras": "No missing LoRAs to download", + "unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "No previous recipe available", "noNextRecipe": "No next recipe available", "missingLorasInfoFailed": "Failed to get information for missing LoRAs", diff --git a/locales/es.json b/locales/es.json index 52e64c39..50c24e75 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1501,6 +1501,22 @@ "note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.", "downloadButton": "Descargar {count} LoRA(s)" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "Imágenes de ejemplo locales", "message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", @@ -2168,6 +2184,7 @@ "createMissingData": "Faltan datos necesarios para crear la receta", "created": "Receta creada exitosamente", "noMissingLoras": "No hay LoRAs faltantes para descargar", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "No hay receta anterior disponible", "noNextRecipe": "No hay siguiente receta disponible", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", diff --git a/locales/fr.json b/locales/fr.json index 247ff456..0b888b57 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1501,6 +1501,22 @@ "note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.", "downloadButton": "Télécharger {count} LoRA(s)" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "Images d'exemple locales", "message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", @@ -2168,6 +2184,7 @@ "createMissingData": "Données requises manquantes pour créer le Recipe", "created": "Recipe créé avec succès", "noMissingLoras": "Aucun LoRA manquant à télécharger", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "Aucune Recipe précédente", "noNextRecipe": "Aucune Recipe suivante", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", diff --git a/locales/he.json b/locales/he.json index d3ed0634..0fc200c2 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1501,6 +1501,22 @@ "note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.", "downloadButton": "הורד {count} LoRA(s)" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "תמונות דוגמה מקומיות", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", @@ -2168,6 +2184,7 @@ "createMissingData": "חסרים נתונים נדרשים ליצירת המתכון", "created": "המתכון נוצר בהצלחה", "noMissingLoras": "אין LoRAs חסרים להורדה", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "אין מתכון קודם זמין", "noNextRecipe": "אין מתכון נוסף זמין", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", diff --git a/locales/ja.json b/locales/ja.json index 2a0a76c0..58f33c9c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1501,6 +1501,22 @@ "note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。", "downloadButton": "{count} 個の LoRA をダウンロード" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "ローカル例画像", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", @@ -2168,6 +2184,7 @@ "createMissingData": "レシピ作成に必要なデータが不足しています", "created": "レシピを作成しました", "noMissingLoras": "ダウンロードする不足LoRAがありません", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "前のレシピがありません", "noNextRecipe": "次のレシピがありません", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", diff --git a/locales/ko.json b/locales/ko.json index f1eac0d0..a164d80e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1501,6 +1501,22 @@ "note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.", "downloadButton": "{count}개 LoRA 다운로드" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "로컬 예시 이미지", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", @@ -2168,6 +2184,7 @@ "createMissingData": "레시피 생성에 필요한 데이터가 없습니다", "created": "레시피가 생성되었습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "이전 레시피가 없습니다", "noNextRecipe": "다음 레시피가 없습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", diff --git a/locales/ru.json b/locales/ru.json index ac04450e..cb54c60f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1501,6 +1501,22 @@ "note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.", "downloadButton": "Скачать {count} LoRA(s)" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "Локальные примеры изображений", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", @@ -2168,6 +2184,7 @@ "createMissingData": "Отсутствуют необходимые данные для создания рецепта", "created": "Рецепт успешно создан", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "Предыдущий рецепт отсутствует", "noNextRecipe": "Следующий рецепт отсутствует", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index dcfc3ec9..b8bfdbb4 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1501,6 +1501,22 @@ "note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。", "downloadButton": "下载 {count} 个 LoRA(s)" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "本地示例图片", "message": "未找到此模型的本地示例图片。可选操作:", @@ -2168,6 +2184,7 @@ "createMissingData": "缺少创建配方所需的数据", "created": "配方创建成功", "noMissingLoras": "没有缺失的 LoRA 可下载", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "没有上一个配方", "noNextRecipe": "没有下一个配方", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index e98428cd..732ebc99 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1501,6 +1501,22 @@ "note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。", "downloadButton": "下載 {count} 個 LoRA(s)" }, + "rematchOptions": { + "title": "[TODO: Translate] Rematch Recipes", + "messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.", + "messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.", + "messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.", + "relaxedLabel": "[TODO: Translate] Relaxed matching", + "relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.", + "confirmButton": "[TODO: Translate] Rematch" + }, + "rematchResults": { + "title": "[TODO: Translate] Rematch Results — Filename Matches", + "message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.", + "undo": "[TODO: Translate] Undo", + "undone": "[TODO: Translate] Undone", + "undoFailed": "[TODO: Translate] Failed to undo rematch: {message}" + }, "exampleAccess": { "title": "本機範例圖片", "message": "此模型未找到本機範例圖片。可選擇:", @@ -2168,6 +2184,7 @@ "createMissingData": "缺少建立配方所需的資料", "created": "配方建立成功", "noMissingLoras": "無缺少的 LoRA 可下載", + "unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.", "noPreviousRecipe": "沒有上一個配方", "noNextRecipe": "沒有下一個配方", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index a5d9383d..0a98c863 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -74,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]: return await asyncio.to_thread(ExifUtils.get_image_dimensions, path) +async def _parse_relaxed_flag(request: web.Request) -> bool: + """Read the relaxed-rematch flag from the JSON body or query string. + + The flag defaults to False (strict candidacy). A JSON body value wins; + ``?relaxed=true`` is honored as a fallback so GET-only clients can opt + in. Body parse failures (empty/invalid JSON) are treated as "no flag". + """ + relaxed = False + if request.can_read_body: + try: + data = await request.json() + except Exception: # noqa: BLE001 - any parse failure means no flag + data = None + if isinstance(data, dict): + relaxed = bool(data.get("relaxed")) + if not relaxed: + relaxed = request.query.get("relaxed", "").lower() == "true" + return relaxed + + @dataclass(frozen=True) class RecipeHandlerSet: """Group of handlers providing recipe route implementations.""" @@ -812,6 +832,8 @@ class RecipeManagementHandler: recipe_scanner.reset_cancellation() + relaxed = await _parse_relaxed_flag(request) + async def progress_callback(data): await self._ws_manager.broadcast_recipe_rematch_progress(data) @@ -819,7 +841,8 @@ class RecipeManagementHandler: async def run_rematch(): try: await recipe_scanner.rematch_all_recipes( - progress_callback=progress_callback + progress_callback=progress_callback, + relaxed=relaxed, ) except Exception as e: self._logger.error( @@ -892,7 +915,13 @@ class RecipeManagementHandler: status=400, ) - result = await recipe_scanner.rematch_recipes_bulk(recipe_ids) + relaxed = bool(data.get("relaxed")) or ( + request.query.get("relaxed", "").lower() == "true" + ) + + result = await recipe_scanner.rematch_recipes_bulk( + recipe_ids, relaxed=relaxed + ) return web.json_response(result) except Exception as exc: self._logger.error( @@ -921,7 +950,10 @@ class RecipeManagementHandler: ) recipe_id = request.match_info["recipe_id"] - result = await recipe_scanner.rematch_recipe_by_id(recipe_id) + relaxed = await _parse_relaxed_flag(request) + result = await recipe_scanner.rematch_recipe_by_id( + recipe_id, relaxed=relaxed + ) return web.json_response(result) except RecipeNotFoundError as exc: return web.json_response({"success": False, "error": str(exc)}, status=404) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index ea86a591..4c93863f 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -483,32 +483,43 @@ class RecipeScanner: suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower())) return suggestions[:limit] - def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool: + def _is_rematch_candidate( + self, entry: dict[str, Any], relaxed: bool = False + ) -> bool: """Return True when a recipe entry is eligible for local re-matching. An entry counts as unresolved when its identity is known to be broken (``isDeleted`` or ``hashInvalid``) or when it is missing identity fields (``hash``/``file_name``). A healthy entry whose - hash is simply not present in the local library is NOT a candidate: - it may be a recipe imported without downloading the model yet, and - its CivitAI-valid hash must never be overwritten by the imprecise - filename fallback. + hash is simply not present in the local library is NOT a candidate + in the default strict mode: it may be a recipe imported without + downloading the model yet, and its CivitAI-valid hash must never be + overwritten by the imprecise filename fallback. + + With ``relaxed=True`` any entry carrying an identifier is a + candidate, including healthy ones — the caller opted into trying to + reconnect "Not in Library" entries by file name. Entries without + any identifier are never candidates in either mode. """ if not isinstance(entry, dict): return False - unresolved = ( - entry.get("isDeleted") - or entry.get("hashInvalid") - or not entry.get("hash") - or not entry.get("file_name") - ) has_identifier = ( entry.get("hash") or entry.get("modelVersionId") or entry.get("id") or entry.get("file_name") ) - return bool(unresolved and has_identifier) + if not has_identifier: + return False + if relaxed: + return True + unresolved = ( + entry.get("isDeleted") + or entry.get("hashInvalid") + or not entry.get("hash") + or not entry.get("file_name") + ) + return bool(unresolved) async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]: """Build a version-cached map of computed AutoV3 hashes to local items. @@ -809,7 +820,9 @@ class RecipeScanner: """Check if cancellation has been requested.""" return self._cancel_requested - async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: + async def rematch_recipe_by_id( + self, recipe_id: str, *, relaxed: bool = False + ) -> Dict[str, Any]: """Rematch a single recipe's deleted lora/checkpoint entries locally. Logs one INFO summary line for this run and delegates the per-recipe @@ -817,12 +830,14 @@ class RecipeScanner: Args: recipe_id: ID of the recipe to rematch + relaxed: When True, healthy entries are rematch candidates too + (see ``_rematch_single_recipe``). Returns: Dict summary of the rematch result (see ``_rematch_recipe_by_id``). Raises RecipeNotFoundError when the recipe is missing. """ - result = await self._rematch_recipe_by_id(recipe_id) + result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed) recipe_name = (result.get("recipe") or {}).get("name") or recipe_id logger.info( "Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors", @@ -835,7 +850,9 @@ class RecipeScanner: ) return result - async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: + async def _rematch_recipe_by_id( + self, recipe_id: str, *, relaxed: bool = False + ) -> Dict[str, Any]: """Rematch a single recipe's deleted lora/checkpoint entries locally. Match snapshots (local hash cache, computed autov3 cache, filename @@ -846,12 +863,16 @@ class RecipeScanner: Args: recipe_id: ID of the recipe to rematch + relaxed: When True, healthy entries are rematch candidates too + (see ``_rematch_single_recipe``). Returns: Dict summary of the rematch result with unified counters (matched_recipes, matched_entries, unresolved_recipes, unresolved_entries plus the legacy rematched/skipped/errors - fields) and a per-entry ``details`` report. The legacy ``skipped`` + fields) and a per-entry ``details`` report plus a flattened + ``l4_matches`` list (filename-level matches for review/undo, + consistent with the bulk/global paths). The legacy ``skipped`` field means "recipe not updated" and overlaps ``unresolved_recipes`` (a recipe with unmatched candidates counts as both). Raises RecipeNotFoundError when the recipe is missing. @@ -872,7 +893,8 @@ class RecipeScanner: try: rematched, _errors, details = await self._rematch_single_recipe( - recipe, local_cache, autov3_cache, filename_cache + recipe, local_cache, autov3_cache, filename_cache, + relaxed=relaxed, ) except RecipePersistenceError as exc: logger.error( @@ -891,12 +913,16 @@ class RecipeScanner: "unresolved_recipes": 0, "unresolved_entries": 0, "details": {"matched": [], "unresolved": []}, + "l4_matches": [], "recipe": recipe, "error": str(exc), } unresolved_entries = len(details["unresolved"]) unresolved_recipes = 1 if unresolved_entries > 0 else 0 + # Flattened L4 matches for the results modal, consistent with + # the bulk/global paths. + l4_matches = self._collect_l4_matches(recipe_id, details) if rematched == 0: return { @@ -908,6 +934,7 @@ class RecipeScanner: "unresolved_recipes": unresolved_recipes, "unresolved_entries": unresolved_entries, "details": details, + "l4_matches": l4_matches, "recipe": recipe, } @@ -921,6 +948,7 @@ class RecipeScanner: "unresolved_recipes": unresolved_recipes, "unresolved_entries": unresolved_entries, "details": details, + "l4_matches": l4_matches, "recipe": await self.get_recipe_by_id(recipe_id), } @@ -930,6 +958,8 @@ class RecipeScanner: local_cache: dict[str, dict[str, Any]], autov3_cache: dict[str, dict[str, Any]], filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None, + *, + relaxed: bool = False, ) -> Tuple[int, int, Dict[str, Any]]: """Rematch a single recipe's lora/checkpoint entries against local models. @@ -945,16 +975,24 @@ class RecipeScanner: autov3_cache: L3 computed-autov3 cache snapshot filename_cache: L4 filename cache snapshot, or None to disable the filename fallback + relaxed: When True, healthy entries ("Not in Library") are also + rematch candidates. Anti-churn rule: an entry that is a + candidate ONLY because of relaxed mode is skipped when its + hash already resolves in the L1 ``local_cache`` — it is + already correctly linked and rematching would only add noise + and a pointless snapshot. Returns: Tuple of (rematched_entries, errors, details). The errors element is always 0 on a normal return — a persistence failure RAISES ``RecipePersistenceError`` so callers can count it. ``details`` carries the per-entry outcome: - ``{"matched": [{type, entry, file_name, match_level}], + ``{"matched": [{type, entry, file_name, match_level, lora_index?}], "unresolved": [{type, entry}]}`` where an unresolved entry is a rematch candidate that found no local match — an expected outcome (the model may simply not exist locally), not an error. + ``lora_index`` is only present for lora entries (the checkpoint + restore endpoint needs no index). Raises: RecipePersistenceError: when the recipe changed but @@ -963,11 +1001,23 @@ class RecipeScanner: rematched = 0 details: Dict[str, Any] = {"matched": [], "unresolved": []} + def is_actionable_candidate(entry: Dict[str, Any]) -> bool: + """Apply candidacy plus the relaxed-mode anti-churn rule.""" + if self._is_rematch_candidate(entry): + return True + if not relaxed or not self._is_rematch_candidate(entry, relaxed=True): + return False + # Relaxed-only candidate: skip when the stored hash already + # resolves in the L1 local cache — the entry is already correctly + # linked and rematching would just add noise and a snapshot. + entry_hash = (entry.get("hash") or "").lower() + return local_cache.get(entry_hash) is None + # Lora entries loras = recipe.get("loras", []) if isinstance(loras, list): - for entry in loras: - if not self._is_rematch_candidate(entry): + for lora_index, entry in enumerate(loras): + if not is_actionable_candidate(entry): continue item, level = await self._match_rematch_entry_with_level( entry, @@ -991,6 +1041,7 @@ class RecipeScanner: "entry": self._entry_identifier(entry), "file_name": item.get("file_name") or "", "match_level": level, + "lora_index": lora_index, } ) self._write_rematch_lora_entry(entry, item) @@ -1000,7 +1051,7 @@ class RecipeScanner: # silently since ``entry.get`` on a str would raise AttributeError). checkpoint = recipe.get("checkpoint") if isinstance(checkpoint, dict): - if self._is_rematch_candidate(checkpoint): + if is_actionable_candidate(checkpoint): item, level = await self._match_rematch_entry_with_level( checkpoint, local_cache, @@ -1065,8 +1116,36 @@ class RecipeScanner: self._update_fts_index_for_recipe(recipe, "update") return (rematched, 0, details) + @staticmethod + def _collect_l4_matches( + recipe_id: Any, details: Dict[str, Any] + ) -> List[Dict[str, Any]]: + """Flatten a recipe's L4 (filename-level) matches for review. + + Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows — + one per matched detail at level L4. ``lora_index`` is only present + for lora entries (checkpoint restore needs no index). + """ + rows: List[Dict[str, Any]] = [] + for match in details.get("matched", []): + if match.get("match_level") != "L4": + continue + row: Dict[str, Any] = { + "recipe_id": recipe_id, + "type": match.get("type"), + "entry": match.get("entry"), + "file_name": match.get("file_name"), + } + if "lora_index" in match: + row["lora_index"] = match["lora_index"] + rows.append(row) + return rows + async def rematch_all_recipes( - self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None + self, + progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None, + *, + relaxed: bool = False, ) -> Dict[str, Any]: """Rematch every recipe's deleted lora/checkpoint entries locally. @@ -1080,14 +1159,19 @@ class RecipeScanner: Args: progress_callback: Optional callback for progress updates - (started/processing/cancelled/completed events). + (started/processing/cancelled/completed events). The + completed/cancelled payloads carry ``l4_matches``, a + flattened list of filename-level matches for review/undo. + relaxed: When True, healthy entries are rematch candidates too + (see ``_rematch_single_recipe``). Returns: Dict summary of the rematch run with unified counters (matched_recipes/matched_entries/unresolved_recipes/unresolved_ entries plus the legacy success/status/rematched/skipped/errors/ - total fields). ``rematched`` (legacy) counts updated recipes — - use ``matched_entries`` for the entry-level total. + total fields) and ``l4_matches``. ``rematched`` (legacy) counts + updated recipes — use ``matched_entries`` for the entry-level + total. """ start_time = time.perf_counter() @@ -1109,6 +1193,7 @@ class RecipeScanner: unresolved_entries = 0 skipped_count = 0 errors_count = 0 + l4_matches: List[Dict[str, Any]] = [] for i, recipe in enumerate(all_recipes): if self.is_cancelled(): @@ -1137,6 +1222,7 @@ class RecipeScanner: "matched_entries": matched_entries, "unresolved_recipes": unresolved_recipes, "unresolved_entries": unresolved_entries, + "l4_matches": l4_matches, } ) return { @@ -1150,6 +1236,7 @@ class RecipeScanner: "matched_entries": matched_entries, "unresolved_recipes": unresolved_recipes, "unresolved_entries": unresolved_entries, + "l4_matches": l4_matches, } try: @@ -1165,11 +1252,15 @@ class RecipeScanner: ) rematched, _errors, details = await self._rematch_single_recipe( - recipe, local_cache, autov3_cache, filename_cache + recipe, local_cache, autov3_cache, filename_cache, + relaxed=relaxed, ) if rematched > 0: matched_recipes += 1 matched_entries += rematched + l4_matches.extend( + self._collect_l4_matches(recipe.get("id"), details) + ) else: skipped_count += 1 @@ -1215,6 +1306,7 @@ class RecipeScanner: "matched_entries": matched_entries, "unresolved_recipes": unresolved_recipes, "unresolved_entries": unresolved_entries, + "l4_matches": l4_matches, } ) @@ -1228,9 +1320,12 @@ class RecipeScanner: "matched_entries": matched_entries, "unresolved_recipes": unresolved_recipes, "unresolved_entries": unresolved_entries, + "l4_matches": l4_matches, } - async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]: + async def rematch_recipes_bulk( + self, recipe_ids: List[str], *, relaxed: bool = False + ) -> Dict[str, Any]: """Rematch a set of recipes by their IDs. Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are @@ -1241,14 +1336,18 @@ class RecipeScanner: Args: recipe_ids: List of recipe ids to rematch. + relaxed: When True, healthy entries are rematch candidates too + (see ``_rematch_single_recipe``). Returns: Dict summary of the bulk run with unified counters (matched_recipes, matched_entries, unresolved_recipes, unresolved_entries plus the legacy total/rematched/skipped/errors - fields) and a per-recipe ``details`` list. The legacy ``rematched`` - field is the total entry count (same as ``matched_entries``) — - unlike ``rematch_all_recipes`` where it counts updated recipes. + fields), a per-recipe ``details`` list, and ``l4_matches`` — a + flattened list of filename-level matches for review/undo. The + legacy ``rematched`` field is the total entry count (same as + ``matched_entries``) — unlike ``rematch_all_recipes`` where it + counts updated recipes. """ total = len(recipe_ids) matched_recipes = 0 @@ -1259,10 +1358,13 @@ class RecipeScanner: errors = 0 recipes: List[Dict[str, Any]] = [] details_list: List[Dict[str, Any]] = [] + l4_matches: List[Dict[str, Any]] = [] for recipe_id in recipe_ids: try: - result = await self._rematch_recipe_by_id(recipe_id) + result = await self._rematch_recipe_by_id( + recipe_id, relaxed=relaxed + ) if result.get("success"): matched_recipes += result.get("matched_recipes", 0) matched_entries += result.get("matched_entries", 0) @@ -1275,6 +1377,9 @@ class RecipeScanner: details_list.append( {"recipe_id": recipe_id, **result["details"]} ) + l4_matches.extend( + self._collect_l4_matches(recipe_id, result["details"]) + ) else: errors += result.get("errors", 0) except RecipeNotFoundError: @@ -1309,12 +1414,22 @@ class RecipeScanner: "unresolved_entries": unresolved_entries, "recipes": recipes, "details": details_list, + "l4_matches": l4_matches, } def _write_rematch_lora_entry( self, entry: Dict[str, Any], item: Dict[str, Any] ) -> None: """Write back a matched local model to a lora recipe entry.""" + # Snapshot the pre-rematch state so the association can be restored + # later (undo), mirroring the manual reconnect flow in + # ``update_lora_entry``. Never nest snapshots. + snapshot = { + key: copy.deepcopy(value) + for key, value in entry.items() + if key != "reconnectSnapshot" + } + entry["isDeleted"] = False entry["hashInvalid"] = False @@ -1338,6 +1453,8 @@ class RecipeScanner: if civitai.get("name"): entry["modelVersionName"] = civitai["name"] + entry["reconnectSnapshot"] = snapshot + def _write_rematch_checkpoint_entry( self, entry: Dict[str, Any], item: Dict[str, Any] ) -> None: @@ -1349,6 +1466,15 @@ class RecipeScanner: when they already exist on the entry (or written fresh for the identifier key when neither identifier form exists). """ + # Snapshot the pre-rematch state so the association can be restored + # later (undo), mirroring the manual reconnect flow. Never nest + # snapshots. + snapshot = { + key: copy.deepcopy(value) + for key, value in entry.items() + if key != "reconnectSnapshot" + } + entry["isDeleted"] = False entry["hashInvalid"] = False @@ -1389,6 +1515,8 @@ class RecipeScanner: else: entry["modelVersionId"] = civ_id + entry["reconnectSnapshot"] = snapshot + async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool: """Helper to save a recipe to both JSON and EXIF metadata.""" recipe_id = recipe.get("id") diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index d3760acd..941ab4b1 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -592,3 +592,183 @@ button:disabled, margin-top: 2px; flex-shrink: 0; } + +/* Recipe Rematch Options Modal */ +#rematchOptionsModal .modal-body { + padding: var(--space-3); +} + +#rematchOptionsModal .confirmation-message { + color: var(--text-color); + margin-bottom: var(--space-3); + font-size: 1em; + line-height: 1.5; +} + +/* Selectable option card — click anywhere toggles the checkbox (label wrap). + Checkmark follows the batch-import modal's custom checkbox pattern. */ +#rematchOptionsModal .rematch-option-card { + position: relative; + display: flex; + align-items: flex-start; + gap: var(--space-2); + padding: var(--space-3); + background: var(--surface-subtle); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + cursor: pointer; + user-select: none; + transition: var(--transition-base); +} + +#rematchOptionsModal .rematch-option-card:hover { + border-color: var(--lora-accent); +} + +#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) { + border-color: var(--lora-accent); + background: oklch(from var(--lora-accent) l c h / 0.08); +} + +/* Visually hidden but keyboard-focusable (focus ring lands on the card). */ +#rematchOptionsModal .rematch-option-card input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) { + box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2); +} + +#rematchOptionsModal .rematch-option-checkmark { + width: 18px; + height: 18px; + margin-top: 1px; + flex-shrink: 0; + border: 2px solid var(--border-color); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-base); + background: var(--bg-color); +} + +#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark { + background: var(--lora-accent); + border-color: var(--lora-accent); +} + +#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after { + content: '\f00c'; + font-family: 'Font Awesome 6 Free', sans-serif; + font-weight: 900; + color: var(--lora-text); + font-size: 12px; +} + +#rematchOptionsModal .rematch-option-text { + display: flex; + flex-direction: column; + gap: var(--space-1); + color: var(--text-color); + min-width: 0; +} + +#rematchOptionsModal .rematch-option-title { + font-weight: 600; + font-size: 0.95em; +} + +#rematchOptionsModal .rematch-option-caveat { + display: flex; + align-items: flex-start; + gap: var(--space-2); + font-size: 0.85em; + line-height: 1.4; + color: var(--text-muted); +} + +#rematchOptionsModal .rematch-option-caveat i { + color: var(--lora-accent); + margin-top: 2px; + flex-shrink: 0; +} + +/* Recipe Rematch L4 Results Modal */ +#rematchResultsModal .modal-body { + padding: var(--space-3); +} + +#rematchResultsModal .confirmation-message { + color: var(--text-color); + margin-bottom: var(--space-3); + font-size: 1em; + line-height: 1.5; +} + +#rematchResultsModal .rematch-results-preview { + background: var(--surface-subtle); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2) var(--space-3); +} + +#rematchResultsModal .rematch-results-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 320px; + overflow-y: auto; +} + +#rematchResultsModal .rematch-results-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border-color); + font-size: 0.9em; +} + +#rematchResultsModal .rematch-results-row:last-child { + border-bottom: none; +} + +#rematchResultsModal .rematch-results-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +#rematchResultsModal .rematch-results-entry { + font-weight: 500; + color: var(--text-color); + overflow-wrap: anywhere; +} + +#rematchResultsModal .rematch-results-file { + color: var(--text-muted); + overflow-wrap: anywhere; +} + +#rematchResultsModal .rematch-results-recipe { + font-size: 0.85em; + opacity: 0.7; + color: var(--text-muted); + overflow-wrap: anywhere; +} + +#rematchResultsModal .rematch-results-row.undone .rematch-results-info { + text-decoration: line-through; + opacity: 0.6; +} + +#rematchResultsModal .rematch-results-undo { + flex-shrink: 0; +} diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index bf7f8689..f75ae6ba 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -677,7 +677,7 @@ export class RecipeSidebarApiClient { }; } - async rematchBulkModels(filePaths) { + async rematchBulkModels(filePaths, options = {}) { if (!filePaths || filePaths.length === 0) { throw new Error('No file paths provided'); } @@ -690,14 +690,19 @@ export class RecipeSidebarApiClient { throw new Error('No recipe IDs could be derived from file paths'); } + const body = { recipe_ids: recipeIds }; + // Only sent when opted in — the strict body stays exactly + // {recipe_ids} for backward compatibility. + if (options.relaxed === true) { + body.relaxed = true; + } + const response = await fetch(this.apiConfig.endpoints.rematchBulk, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - recipe_ids: recipeIds, - }), + body: JSON.stringify(body), }); const result = await response.json(); diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index 70f6fe4b..130f70d5 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -4,6 +4,7 @@ import { translate } from '../../utils/i18nHelpers.js'; import { state } from '../../state/index.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; +import { rematchModalManager } from '../../managers/RematchModalManager.js'; export class GlobalContextMenu extends BaseContextMenu { constructor() { @@ -368,6 +369,18 @@ export class GlobalContextMenu extends BaseContextMenu { return; } + // Collect options (relaxed matching) before starting anything; the + // run only begins when the user confirms the dialog. + rematchModalManager.showOptionsModal({ + onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed), + }); + } + + async _startRematch(menuItem, relaxed = false) { + if (this._rematchInProgress) { + return; + } + this._rematchInProgress = true; menuItem?.classList.add('disabled'); @@ -384,6 +397,7 @@ export class GlobalContextMenu extends BaseContextMenu { const response = await fetch('/api/lm/recipes/rematch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relaxed: !!relaxed }), }); const result = await response.json(); @@ -458,6 +472,11 @@ export class GlobalContextMenu extends BaseContextMenu { if (window.recipesPage) { window.recipesPage.refresh(); } + // Filename-level (L4) matches are imprecise — + // always surface them for review/undo. + if (Array.isArray(p.l4_matches) && p.l4_matches.length > 0) { + rematchModalManager.showResultsModal(p.l4_matches); + } } else if (p.status === 'error') { throw new Error(p.error || 'Rematch failed'); } else if (p.status === 'cancelled') { diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 4ad965d0..87353652 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { state } from '../../state/index.js'; import { moveManager } from '../../managers/MoveManager.js'; +import { rematchModalManager } from '../../managers/RematchModalManager.js'; import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js'; export class RecipeContextMenu extends BaseContextMenu { @@ -303,11 +304,22 @@ export class RecipeContextMenu extends BaseContextMenu { // Capture before any await: the menu's click handler nulls currentCard const filePath = this.currentCard?.dataset?.filepath; + // Collect options (relaxed matching) before starting anything; the + // run only begins when the user confirms the dialog. + rematchModalManager.showOptionsModal({ + scope: 'single', + onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed), + }); + } + + async _startRematchRecipe(recipeId, filePath, relaxed = false) { try { showToast('Rematching recipe to local models...', {}, 'info'); const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, { - method: 'POST' + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relaxed: !!relaxed }), }); const result = await response.json(); @@ -330,6 +342,11 @@ export class RecipeContextMenu extends BaseContextMenu { state.virtualScroller.updateSingleItem(filePath, updatedRecipe); } } + // Filename-level (L4) matches are imprecise — always + // surface them for review/undo. + if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) { + rematchModalManager.showResultsModal(result.l4_matches); + } } else if (result.unresolved_entries > 0) { // Entries existed but have no local model — expected for // models deleted from Civitai; informational, not an error. diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 140e0e4b..fbb577f5 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -1,5 +1,5 @@ // Recipe Modal Component -import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js'; +import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js'; import { isModelWeightFile } from '../utils/modelFileTypes.js'; import { buildCivitaiUrl } from '../utils/civitaiUtils.js'; import { translate } from '../utils/i18nHelpers.js'; @@ -1078,8 +1078,9 @@ class RecipeModal { // Mirror the checkpoint "broken" rule: deleted, an // unresolvable hash, or a name-only remnant with no CivitAI - // identifiers at all cannot be fixed by downloading — - // reconnecting a local LoRA is the only remediation. + // identifiers at all cannot be fixed by downloading, so no + // download button is offered. Reconnect is always available + // for missing entries (see renderLoraItemActions). const needsReconnect = !existsLocally && (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora)); @@ -1180,7 +1181,7 @@ class RecipeModal { ${actionsRow} - ${needsReconnect ? ` + ${!existsLocally ? `

${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}

@@ -2853,11 +2854,7 @@ class RecipeModal { * the model cannot be resolved — never for transient transport errors. */ _isUnresolvableDownloadError(message) { - if (!message) { - return false; - } - const text = String(message).toLowerCase(); - return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text); + return isUnresolvableDownloadError(message); } getResourceCivitaiUrl(resource) { @@ -2915,19 +2912,9 @@ class RecipeModal { } const controls = []; - if (needsReconnect) { - const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect'); - const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA'); - controls.push(` - - `); - } else { + if (!needsReconnect) { // needsReconnect already implies canDownloadLora() here, so the - // download action is unconditional. + // download action is unconditional in this branch. const downloadLabel = translate('recipes.resources.download', {}, 'Download'); const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); controls.push(` @@ -2938,6 +2925,18 @@ class RecipeModal { `); } + // Reconnect is always offered for missing entries — when the LoRA + // already exists locally under a different hash, downloading first + // just to flip the button would be a waste. + const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect'); + const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA'); + controls.push(` + + `); return `
${controls.join('')}
`; } diff --git a/static/js/core.js b/static/js/core.js index 2088c4e5..c9943bff 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js'; import { settingsManager } from './managers/SettingsManager.js'; import { moveManager } from './managers/MoveManager.js'; import { bulkManager } from './managers/BulkManager.js'; +import { rematchModalManager } from './managers/RematchModalManager.js'; import { ExampleImagesManager } from './managers/ExampleImagesManager.js'; import { helpManager } from './managers/HelpManager.js'; import { doctorManager } from './managers/DoctorManager.js'; @@ -68,6 +69,7 @@ export class AppCore { window.doctorManager = doctorManager; window.moveManager = moveManager; window.bulkManager = bulkManager; + window.rematchModalManager = rematchModalManager; // Initialize UI components window.headerManager = new HeaderManager(); diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index bbbb12f3..6507e60d 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -3,6 +3,7 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm import { handleUndoDelete } from '../utils/undoHelpers.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; +import { rematchModalManager } from './RematchModalManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; @@ -978,6 +979,15 @@ export class BulkManager { return; } + // Collect options (relaxed matching) before starting anything; the + // run only begins when the user confirms the dialog. + rematchModalManager.showOptionsModal({ + recipeCount: state.selectedModels.size, + onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed), + }); + } + + async _startRematchSelectedRecipes(relaxed = false) { try { const apiClient = this.getActiveApiClient(); const filePaths = Array.from(state.selectedModels); @@ -989,7 +999,7 @@ export class BulkManager { state.loadingManager.showSimpleLoading('Rematching recipes to local models...'); - const result = await apiClient.rematchBulkModels(filePaths); + const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed }); if (result.success) { const total = result.total || filePaths.length; @@ -1050,6 +1060,12 @@ export class BulkManager { } if (state.bulkMode) this.toggleBulkMode(); + + // Filename-level (L4) matches are imprecise — always surface + // them for review/undo. + if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) { + rematchModalManager.showResultsModal(result.l4_matches); + } } else { throw new Error(result.error || 'Bulk rematch failed'); } diff --git a/static/js/managers/BulkMissingLoraDownloadManager.js b/static/js/managers/BulkMissingLoraDownloadManager.js index fdc0b281..601e7a44 100644 --- a/static/js/managers/BulkMissingLoraDownloadManager.js +++ b/static/js/managers/BulkMissingLoraDownloadManager.js @@ -1,7 +1,9 @@ import { showToast } from '../utils/uiHelpers.js'; +import { isUnresolvableDownloadError } from '../utils/uiHelpers.js'; import { translate } from '../utils/i18nHelpers.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; +import { extractRecipeId } from '../api/recipeApi.js'; import { state } from '../state/index.js'; import { modalManager } from './ModalManager.js'; @@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager { this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA); this.pendingLoras = []; this.pendingRecipes = []; + this.pendingMissingByRecipe = null; } /** @@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager { // Execute download await this.executeDownload(this.pendingLoras); this.pendingLoras = []; + this.pendingMissingByRecipe = null; } /** @@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager { // Collect missing LoRAs with deduplication const stats = this.collectMissingLoras(selectedRecipes); + // Kept so executeDownload can mark unresolvable failures back onto + // every recipe occurrence (hashInvalid → reconnect candidacy). + this.pendingMissingByRecipe = stats.missingLorasByRecipe; if (stats.uniqueCount === 0) { showToast('toast.recipes.noMissingLorasInSelection', {}, 'info'); @@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager { let completedDownloads = 0; let failedDownloads = 0; + let markedInvalidCount = 0; let currentLoraProgress = 0; let cancelled = false; @@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager { if (!response.success) { console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`); failedDownloads++; + // An unresolvable failure (model gone on CivitAI) flips + // every recipe occurrence to reconnect candidacy — same + // rule as the single-LoRA download in RecipeModal. + if (isUnresolvableDownloadError(response.error)) { + markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora); + } } else { completedDownloads++; updateProgress(100, completedDownloads, ''); @@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager { if (!cancelled) { console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error); failedDownloads++; + if (isUnresolvableDownloadError(error?.message)) { + markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora); + } } } } @@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager { }, 'warning'); } + // Unresolvable failures were marked hash-invalid during the loop; + // tell the user those entries now offer reconnect instead of download. + if (markedInvalidCount > 0) { + showToast('toast.recipes.unresolvableMarkedForReconnect', { + count: markedInvalidCount + }, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`); + } + // Update each affected recipe card with fresh data (LoRA inLibrary flags changed) if (state.virtualScroller) { - const { extractRecipeId } = await import('../api/recipeApi.js'); for (const recipe of this.pendingRecipes) { const recipeId = extractRecipeId(recipe.file_path); if (!recipeId) continue; @@ -354,6 +378,59 @@ export class BulkMissingLoraDownloadManager { } } + /** + * Mark every recipe occurrence of a failed LoRA as hash-invalid. + * + * Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag + * makes each occurrence an unresolved rematch candidate and swaps its + * action from download to reconnect. Only called for unresolvable + * failures — transient errors leave entries untouched. + * + * @param {Object} failedLora - The deduplicated LoRA that failed + * @returns {Promise} - How many recipe entries were marked + */ + async markLoraHashInvalidInRecipes(failedLora) { + const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId; + if (!failedKey || !this.pendingMissingByRecipe) { + return 0; + } + + let marked = 0; + for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) { + const recipeId = extractRecipeId(recipe.file_path) || recipe.id; + if (!recipeId || !Array.isArray(recipe.loras)) { + continue; + } + for (const entry of missingLoras) { + const entryKey = entry.hash || entry.id || entry.modelVersionId; + if (entryKey !== failedKey) { + continue; + } + const loraIndex = recipe.loras.indexOf(entry); + if (loraIndex < 0) { + continue; + } + try { + const response = await fetch('/api/lm/recipe/lora/mark-hash-invalid', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipe_id: recipeId, + lora_index: loraIndex, + }), + }); + if (response.ok) { + entry.hashInvalid = true; + marked++; + } + } catch (error) { + console.warn('Failed to mark LoRA hash invalid:', error); + } + } + } + return marked; + } + /** * Get LoRA root directory from API * @returns {Promise} - LoRA root directory or null diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 06eb45b7..e91efa83 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -347,6 +347,32 @@ export class ModalManager { }); } + // Register rematchOptionsModal + const rematchOptionsModal = document.getElementById('rematchOptionsModal'); + if (rematchOptionsModal) { + this.registerModal('rematchOptionsModal', { + element: rematchOptionsModal, + onClose: () => { + this.getModal('rematchOptionsModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + }, + closeOnOutsideClick: true + }); + } + + // Register rematchResultsModal + const rematchResultsModal = document.getElementById('rematchResultsModal'); + if (rematchResultsModal) { + this.registerModal('rematchResultsModal', { + element: rematchResultsModal, + onClose: () => { + this.getModal('rematchResultsModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + }, + closeOnOutsideClick: true + }); + } + document.addEventListener('keydown', this.boundHandleEscape); this.initialized = true; } diff --git a/static/js/managers/RematchModalManager.js b/static/js/managers/RematchModalManager.js new file mode 100644 index 00000000..269b8881 --- /dev/null +++ b/static/js/managers/RematchModalManager.js @@ -0,0 +1,171 @@ +import { modalManager } from './ModalManager.js'; +import { translate } from '../utils/i18nHelpers.js'; +import { showToast } from '../utils/uiHelpers.js'; + +/** + * Owns the two recipe-rematch modals: + * + * - rematchOptionsModal — shown BEFORE a global/bulk/single rematch run; + * collects the "relaxed matching" opt-in and only then invokes the run + * callback. + * - rematchResultsModal — shown AFTER a run that produced L4 (filename + * level) matches; lists them for review with a per-row Undo that calls + * the existing restore endpoints. + */ +export class RematchModalManager { + constructor() { + this._optionsConfirmCallback = null; + this._resultsMatches = []; + } + + /** + * Open the options modal. `onConfirm({ relaxed })` fires only when the + * user clicks Rematch — Cancel/X runs nothing. + * + * @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options + */ + showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) { + const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global'); + const message = document.getElementById('rematchOptionsMessage'); + if (message) { + if (resolvedScope === 'bulk') { + message.textContent = translate( + 'modals.rematchOptions.messageBulk', + { count: recipeCount }, + `${recipeCount} selected recipe(s) will be scanned against your local model library.` + ); + } else if (resolvedScope === 'single') { + message.textContent = translate( + 'modals.rematchOptions.messageSingle', + {}, + 'This recipe will be scanned against your local model library.' + ); + } else { + message.textContent = translate( + 'modals.rematchOptions.messageGlobal', + {}, + 'All recipes will be scanned against your local model library.' + ); + } + } + const checkbox = document.getElementById('rematchOptionsRelaxed'); + if (checkbox) { + checkbox.checked = false; + } + this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null; + modalManager.showModal('rematchOptionsModal'); + } + + confirmOptions() { + const checkbox = document.getElementById('rematchOptionsRelaxed'); + const relaxed = checkbox ? !!checkbox.checked : false; + const callback = this._optionsConfirmCallback; + this._optionsConfirmCallback = null; + modalManager.closeModal('rematchOptionsModal'); + if (callback) { + // Returned so callers (and tests) can await the started run. + return callback({ relaxed }); + } + return undefined; + } + + cancelOptions() { + this._optionsConfirmCallback = null; + modalManager.closeModal('rematchOptionsModal'); + } + + /** + * Open the results modal listing L4 (filename-level) matches. + * + * @param {Array<{recipe_id: string, type: string, entry: string, file_name: string, lora_index?: number}>} l4Matches + */ + showResultsModal(l4Matches) { + if (!Array.isArray(l4Matches) || l4Matches.length === 0) { + return; + } + const list = document.getElementById('rematchResultsList'); + if (!list) { + return; + } + this._resultsMatches = l4Matches; + list.innerHTML = ''; + + l4Matches.forEach((match, index) => { + const row = document.createElement('li'); + row.className = 'rematch-results-row'; + + const info = document.createElement('div'); + info.className = 'rematch-results-info'; + + const entryName = document.createElement('span'); + entryName.className = 'rematch-results-entry'; + entryName.textContent = match.entry || ''; + + const matchedFile = document.createElement('span'); + matchedFile.className = 'rematch-results-file'; + matchedFile.textContent = `→ ${match.file_name || ''}`; + + const recipeRef = document.createElement('span'); + recipeRef.className = 'rematch-results-recipe'; + recipeRef.textContent = match.recipe_id || ''; + + info.appendChild(entryName); + info.appendChild(matchedFile); + info.appendChild(recipeRef); + + const undoButton = document.createElement('button'); + undoButton.className = 'secondary-btn rematch-results-undo'; + undoButton.textContent = translate('modals.rematchResults.undo', {}, 'Undo'); + undoButton.addEventListener('click', () => this.undoMatch(index, row, undoButton)); + + row.appendChild(info); + row.appendChild(undoButton); + list.appendChild(row); + }); + + modalManager.showModal('rematchResultsModal'); + } + + /** + * Undo a single L4 match via the existing restore endpoints. On success + * the row is struck through and its button disabled. + */ + async undoMatch(index, row, button) { + const match = this._resultsMatches[index]; + if (!match || button.disabled) { + return; + } + try { + const isCheckpoint = match.type === 'checkpoint'; + const body = isCheckpoint + ? { recipe_id: match.recipe_id } + : { recipe_id: match.recipe_id, lora_index: match.lora_index }; + const response = await fetch( + isCheckpoint + ? '/api/lm/recipe/checkpoint/restore' + : '/api/lm/recipe/lora/restore', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ); + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(result.error || 'Restore failed'); + } + row.classList.add('undone'); + button.disabled = true; + button.textContent = translate('modals.rematchResults.undone', {}, 'Undone'); + } catch (error) { + console.error('Failed to undo rematch match:', error); + showToast( + 'modals.rematchResults.undoFailed', + { message: error.message }, + 'error' + ); + } + } +} + +export const rematchModalManager = new RematchModalManager(); diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index ce654c57..1f932b8c 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -325,6 +325,23 @@ export function isTypingContext(target) { return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select'; } +/** + * Decide whether a download failure means the model is unrecoverable. + * + * The hash-invalid flag (and the resulting rematch/reconnect candidacy) is + * only set when CivitAI explicitly says the model cannot be resolved — never + * for transient transport errors (network, 5xx). + * @param {*} message - The error message carried by the failed download + * @returns {boolean} + */ +export function isUnresolvableDownloadError(message) { + if (!message) { + return false; + } + const text = String(message).toLowerCase(); + return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text); +} + export function restoreFolderFilter() { const activeFolder = getStorageItem('activeFolder'); const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`); diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index d7440885..0385e2bc 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -125,4 +125,54 @@
+ + + + + + + \ No newline at end of file diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js index 63d552b1..70a5f1ab 100644 --- a/tests/frontend/api/recipeApi.bulk.test.js +++ b/tests/frontend/api/recipeApi.bulk.test.js @@ -309,6 +309,21 @@ describe('RecipeSidebarApiClient bulk operations', () => { expect(global.fetch).not.toHaveBeenCalled(); }); + it('includes relaxed in the bulk rematch body only when opted in', async () => { + const api = new RecipeSidebarApiClient(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, total: 1, rematched: 1, skipped: 0, errors: 0, recipes: [] }), + }); + + await api.rematchBulkModels(['/recipes/a.webp'], { relaxed: true }); + + expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ + recipe_ids: ['a'], + relaxed: true, + }); + }); + it('throws the backend error when bulk rematch fails', async () => { const api = new RecipeSidebarApiClient(); global.fetch.mockResolvedValue({ diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index 37c54669..61c33ace 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -143,6 +143,16 @@ async function flushAsyncTasks() { await new Promise((resolve) => setTimeout(resolve, 0)); } +// The real RematchModalManager runs against the mocked modalManager; the +// global rematch menu action now opens the options dialog first and only +// starts once confirmOptions() is invoked (the user clicking Rematch). +async function getRematchModalManager() { + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + return rematchModalManager; +} + function createDeferred() { let resolve; let reject; @@ -2266,15 +2276,23 @@ describe('Interaction-level regression coverage', () => { }); rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + // The click only opens the options dialog — nothing starts yet. + expect(global.fetch).not.toHaveBeenCalled(); + expect(rematchItem.classList.contains('disabled')).toBe(false); + + const rematchModalManager = await getRematchModalManager(); + const runPromise = rematchModalManager.confirmOptions(); expect(rematchItem.classList.contains('disabled')).toBe(true); for (let i = 0; i < 5; i++) { await flushAsyncTasks(); } + await runPromise; expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relaxed: false }), }); expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress'); expect(global.fetch).toHaveBeenCalledTimes(2); @@ -2331,10 +2349,15 @@ describe('Interaction-level regression coverage', () => { }); rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + expect(global.fetch).not.toHaveBeenCalled(); + + const rematchModalManager = await getRematchModalManager(); + const runPromise = rematchModalManager.confirmOptions(); for (let i = 0; i < 5; i++) { await flushAsyncTasks(); } + await runPromise; expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.'); expect(showToastMock).toHaveBeenCalledWith( @@ -2384,10 +2407,15 @@ describe('Interaction-level regression coverage', () => { }); rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + expect(global.fetch).not.toHaveBeenCalled(); + + const rematchModalManager = await getRematchModalManager(); + const runPromise = rematchModalManager.confirmOptions(); for (let i = 0; i < 5; i++) { await flushAsyncTasks(); } + await runPromise; expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.'); expect(showToastMock).toHaveBeenCalledWith( @@ -2437,10 +2465,15 @@ describe('Interaction-level regression coverage', () => { }); rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + expect(global.fetch).not.toHaveBeenCalled(); + + const rematchModalManager = await getRematchModalManager(); + const runPromise = rematchModalManager.confirmOptions(); for (let i = 0; i < 5; i++) { await flushAsyncTasks(); } + await runPromise; expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.'); expect(showToastMock).toHaveBeenCalledWith( @@ -2489,10 +2522,15 @@ describe('Interaction-level regression coverage', () => { }); rematchItem.dispatchEvent(new Event('click', { bubbles: true })); + expect(global.fetch).not.toHaveBeenCalled(); + + const rematchModalManager = await getRematchModalManager(); + const runPromise = rematchModalManager.confirmOptions(); for (let i = 0; i < 5; i++) { await flushAsyncTasks(); } + await runPromise; expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).'); expect(showToastMock).toHaveBeenCalledWith( diff --git a/tests/frontend/components/recipeContextMenu.rematch.test.js b/tests/frontend/components/recipeContextMenu.rematch.test.js index d7cba36b..0aea586b 100644 --- a/tests/frontend/components/recipeContextMenu.rematch.test.js +++ b/tests/frontend/components/recipeContextMenu.rematch.test.js @@ -44,6 +44,29 @@ const flushAsyncTasks = async (rounds = 5) => { } }; +// The single-recipe rematch now opens the options dialog first and only +// starts once confirmOptions() is invoked (the user clicking Rematch). +async function confirmRematchOptions() { + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + return rematchModalManager.confirmOptions(); +} + +async function cancelRematchOptions() { + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + rematchModalManager.cancelOptions(); +} + +async function getRematchModalManager() { + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + return rematchModalManager; +} + describe('RecipeContextMenu.rematchRecipe', () => { beforeEach(() => { vi.clearAllMocks(); @@ -91,8 +114,16 @@ describe('RecipeContextMenu.rematchRecipe', () => { await flushAsyncTasks(); + // The click only opened the options dialog — nothing started yet. + expect(global.fetch).not.toHaveBeenCalled(); + + await confirmRematchOptions(); + await flushAsyncTasks(); + expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relaxed: false }), }); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchComplete', @@ -126,6 +157,8 @@ describe('RecipeContextMenu.rematchRecipe', () => { .dispatchEvent(new Event('click', { bubbles: true })); await flushAsyncTasks(); + await confirmRematchOptions(); + await flushAsyncTasks(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchUnmatched', @@ -155,6 +188,8 @@ describe('RecipeContextMenu.rematchRecipe', () => { .dispatchEvent(new Event('click', { bubbles: true })); await flushAsyncTasks(); + await confirmRematchOptions(); + await flushAsyncTasks(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchSkipped', @@ -186,6 +221,8 @@ describe('RecipeContextMenu.rematchRecipe', () => { .dispatchEvent(new Event('click', { bubbles: true })); await flushAsyncTasks(); + await confirmRematchOptions(); + await flushAsyncTasks(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchFailed', @@ -207,6 +244,8 @@ describe('RecipeContextMenu.rematchRecipe', () => { .dispatchEvent(new Event('click', { bubbles: true })); await flushAsyncTasks(); + await confirmRematchOptions(); + await flushAsyncTasks(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchFailed', @@ -214,4 +253,88 @@ describe('RecipeContextMenu.rematchRecipe', () => { 'error' ); }); + + it('sends relaxed: true when the relaxed checkbox is checked', async () => { + const menu = await createMenu(); + const card = document.getElementById('card'); + menu.showMenu(100, 100, card); + + document.body.insertAdjacentHTML( + 'beforeend', + '' + ); + + 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(); + + // The dialog resets the checkbox to unchecked on open; the user opts in. + document.getElementById('rematchOptionsRelaxed').checked = true; + await confirmRematchOptions(); + await flushAsyncTasks(); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/rematch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ relaxed: true }), + }); + }); + + it('starts nothing when the options dialog is cancelled', async () => { + const menu = await createMenu(); + const card = document.getElementById('card'); + menu.showMenu(100, 100, card); + + document + .querySelector('[data-action="rematch"]') + .dispatchEvent(new Event('click', { bubbles: true })); + + await flushAsyncTasks(); + await cancelRematchOptions(); + await flushAsyncTasks(); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('shows the results modal when the result carries l4_matches', async () => { + const menu = await createMenu(); + const card = document.getElementById('card'); + menu.showMenu(100, 100, card); + + const l4Matches = [ + { recipe_id: 'recipe-1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 }, + ]; + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, rematched: 1, matched_entries: 1, l4_matches: l4Matches }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }), + }); + + const rematchModalManager = await getRematchModalManager(); + const showResultsSpy = vi + .spyOn(rematchModalManager, 'showResultsModal') + .mockImplementation(() => {}); + + document + .querySelector('[data-action="rematch"]') + .dispatchEvent(new Event('click', { bubbles: true })); + + await flushAsyncTasks(); + await confirmRematchOptions(); + await flushAsyncTasks(); + + expect(showResultsSpy).toHaveBeenCalledWith(l4Matches); + showResultsSpy.mockRestore(); + }); }); diff --git a/tests/frontend/components/recipeModal.resourceItems.test.js b/tests/frontend/components/recipeModal.resourceItems.test.js index 18cbb45a..cc92d431 100644 --- a/tests/frontend/components/recipeModal.resourceItems.test.js +++ b/tests/frontend/components/recipeModal.resourceItems.test.js @@ -51,6 +51,10 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ stripLoraTags: vi.fn((text) => text), sendPromptToWorkflow: vi.fn(), sendGenParamsToWorkflow: vi.fn(), + // Keep the real predicate: the download-failure tests assert on its + // unresolvable-error classification. + isUnresolvableDownloadError: (message) => + !!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()), })); vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ @@ -292,7 +296,7 @@ describe('RecipeModal resource item interactions', () => { ); }); - it('renders a download action (not reconnect) for a version-only LoRA', async () => { + it('renders a download action alongside reconnect for a version-only LoRA', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); @@ -301,10 +305,12 @@ describe('RecipeModal resource item interactions', () => { expect(item).not.toBeNull(); expect(item.classList.contains('missing-locally')).toBe(true); // Missing from the local library (badge) but still downloadable by its - // exact CivitAI version id, so the row offers Download, not Reconnect. + // exact CivitAI version id, so the row offers Download as the primary + // action; Reconnect stays available for entries the user already has + // locally under a different hash. expect(item.querySelector('.missing-badge')).not.toBeNull(); expect(item.querySelector('.lora-download')).not.toBeNull(); - expect(item.querySelector('.lora-reconnect')).toBeNull(); + expect(item.querySelector('.lora-reconnect')).not.toBeNull(); }); it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => { @@ -588,10 +594,12 @@ describe('RecipeModal resource item interactions', () => { await new Promise(resolve => setTimeout(resolve, 50)); expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false); - // The entry keeps the download action and never flips to reconnect + // The entry keeps the download action and never flips to hash-invalid + // (reconnect is always present for missing entries now; the signal here + // is that the download action survives and no invalid badge appears) const item = document.querySelector('[data-lora-index="1"]'); expect(item.querySelector('.lora-download')).not.toBeNull(); - expect(item.querySelector('.lora-reconnect')).toBeNull(); + expect(item.querySelector('.invalid-hash-badge')).toBeNull(); }); it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => { diff --git a/tests/frontend/managers/BulkManager.rematch.test.js b/tests/frontend/managers/BulkManager.rematch.test.js index 6e7ffb38..f33480f4 100644 --- a/tests/frontend/managers/BulkManager.rematch.test.js +++ b/tests/frontend/managers/BulkManager.rematch.test.js @@ -73,6 +73,22 @@ vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({ getNsfwLevelSelector: vi.fn(), })); +// The real RematchModalManager runs against the mocked modalManager; confirm +// is invoked explicitly, mirroring the user clicking Rematch in the dialog. +async function confirmRematchOptions() { + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + return rematchModalManager.confirmOptions(); +} + +async function cancelRematchOptions() { + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + rematchModalManager.cancelOptions(); +} + describe('BulkManager.rematchSelectedRecipes', () => { beforeEach(() => { vi.clearAllMocks(); @@ -114,12 +130,16 @@ describe('BulkManager.rematchSelectedRecipes', () => { }); await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); - expect(rematchBulkModelsMock).toHaveBeenCalledWith([ - '/recipes/a.webp', - '/recipes/b.webp', - '/recipes/c.webp', - ]); + expect(rematchBulkModelsMock).toHaveBeenCalledWith( + [ + '/recipes/a.webp', + '/recipes/b.webp', + '/recipes/c.webp', + ], + { relaxed: false } + ); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchComplete', { rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 }, @@ -155,6 +175,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { }); await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchCompleteErrors', @@ -182,6 +203,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { }); await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchAllFailed', @@ -215,6 +237,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { }); await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchUnmatched', @@ -243,6 +266,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { }); await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchSkipped', @@ -268,6 +292,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { }); await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchFailed', @@ -284,6 +309,7 @@ describe('BulkManager.rematchSelectedRecipes', () => { rematchBulkModelsMock.mockRejectedValue(new Error('network down')); await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); expect(showToastMock).toHaveBeenCalledWith( 'toast.recipes.rematchFailed', @@ -319,4 +345,100 @@ describe('BulkManager.rematchSelectedRecipes', () => { ); expect(rematchBulkModelsMock).not.toHaveBeenCalled(); }); + + it('does not start the rematch until the options dialog is confirmed', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + + await bulk.rematchSelectedRecipes(); + + expect(rematchBulkModelsMock).not.toHaveBeenCalled(); + + rematchBulkModelsMock.mockResolvedValue({ + success: true, + total: 1, + rematched: 1, + skipped: 0, + errors: 0, + matched_recipes: 1, + matched_entries: 1, + recipes: [], + }); + + await confirmRematchOptions(); + + expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: false }); + }); + + it('sends relaxed: true when the relaxed checkbox is checked', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + document.body.innerHTML = ''; + + rematchBulkModelsMock.mockResolvedValue({ + success: true, + total: 1, + rematched: 0, + skipped: 1, + errors: 0, + recipes: [], + }); + + await bulk.rematchSelectedRecipes(); + // The dialog resets the checkbox to unchecked on open; the user opts in. + document.getElementById('rematchOptionsRelaxed').checked = true; + await confirmRematchOptions(); + + expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: true }); + + document.body.innerHTML = ''; + }); + + it('starts nothing when the options dialog is cancelled', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + + await bulk.rematchSelectedRecipes(); + await cancelRematchOptions(); + + expect(rematchBulkModelsMock).not.toHaveBeenCalled(); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.rematchComplete', + expect.anything(), + expect.anything() + ); + }); + + it('shows the L4 results modal when the bulk result carries l4_matches', async () => { + const bulk = await createBulkManager(); + stateStub.selectedModels.add('/recipes/a.webp'); + + const l4Matches = [ + { recipe_id: 'a', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 }, + ]; + rematchBulkModelsMock.mockResolvedValue({ + success: true, + total: 1, + rematched: 1, + skipped: 0, + errors: 0, + matched_recipes: 1, + matched_entries: 1, + recipes: [], + l4_matches: l4Matches, + }); + + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + const showResultsSpy = vi + .spyOn(rematchModalManager, 'showResultsModal') + .mockImplementation(() => {}); + + await bulk.rematchSelectedRecipes(); + await confirmRematchOptions(); + + expect(showResultsSpy).toHaveBeenCalledWith(l4Matches); + showResultsSpy.mockRestore(); + }); }); diff --git a/tests/frontend/managers/BulkMissingLoraDownloadManager.test.js b/tests/frontend/managers/BulkMissingLoraDownloadManager.test.js new file mode 100644 index 00000000..d7da2fa8 --- /dev/null +++ b/tests/frontend/managers/BulkMissingLoraDownloadManager.test.js @@ -0,0 +1,192 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const MODULE = '../../../static/js/managers/BulkMissingLoraDownloadManager.js'; + +const showToastMock = vi.fn(); +const updateProgressMock = vi.fn(); +const updateSingleItemMock = vi.fn(); + +const mockApiClient = { + downloadModel: vi.fn(), + cancelDownload: vi.fn(), + fetchModelRoots: vi.fn(() => Promise.resolve({ roots: ['/models/loras'] })), +}; + +const loadingManagerStub = { + showDownloadProgress: vi.fn(() => updateProgressMock), + setStatus: vi.fn(), + showCancelButton: vi.fn(), + hide: vi.fn(), +}; + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + // Keep the real predicate: these tests assert on its classification. + isUnresolvableDownloadError: (message) => + !!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: vi.fn((_, __, fallback) => fallback ?? ''), +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: vi.fn(() => mockApiClient), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' }, +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + extractRecipeId: (filePath) => { + if (!filePath) return null; + const basename = filePath.split('/').pop().split('\\').pop(); + const dotIndex = basename.lastIndexOf('.'); + return dotIndex > 0 ? basename.substring(0, dotIndex) : basename; + }, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: { + loadingManager: loadingManagerStub, + virtualScroller: { updateSingleItem: updateSingleItemMock }, + global: { settings: {} }, + }, +})); + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { showModal: vi.fn(), closeModal: vi.fn() }, +})); + +/** Mirrors the FakeWebSocket pattern from downloadManager.batchSummary.test.js. */ +class FakeWebSocket { + static instances = []; + + constructor(url) { + this.url = url; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + this.close = vi.fn(); + FakeWebSocket.instances.push(this); + queueMicrotask(() => { + if (this.onopen) this.onopen(); + }); + } +} + +const makeRecipe = (filePath, loras) => ({ file_path: filePath, loras }); + +describe('BulkMissingLoraDownloadManager unresolvable-failure write-back', () => { + let manager; + let fetchMock; + let requests; + + beforeEach(async () => { + FakeWebSocket.instances = []; + vi.clearAllMocks(); + loadingManagerStub.showDownloadProgress.mockReturnValue(updateProgressMock); + + requests = []; + fetchMock = vi.fn((url, options) => { + requests.push({ url, options }); + if (url === '/api/lm/recipe/lora/mark-hash-invalid') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + // Recipe detail refresh after the download loop + return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'refreshed' }) }); + }); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('WebSocket', FakeWebSocket); + + vi.resetModules(); + ({ bulkMissingLoraDownloadManager: manager } = await import(MODULE)); + manager.pendingLoras = []; + manager.pendingRecipes = []; + manager.pendingMissingByRecipe = null; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const primePending = (recipes) => { + const stats = manager.collectMissingLoras(recipes); + manager.pendingRecipes = recipes; + manager.pendingMissingByRecipe = stats.missingLorasByRecipe; + return stats.uniqueLoras; + }; + + it('marks every recipe occurrence hash-invalid when the failure is unresolvable', async () => { + const entryA = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 }; + const entryB = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 }; + const recipe1 = makeRecipe('/recipes/r1.json', [entryA]); + const recipe2 = makeRecipe('/recipes/r2.json', [{ hash: 'x', file_name: 'keep.safetensors', inLibrary: true }, entryB]); + const uniqueLoras = primePending([recipe1, recipe2]); + + mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Model not found' }); + + await manager.executeDownload(uniqueLoras); + + const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid'); + expect(markCalls).toHaveLength(2); + const payloads = markCalls.map(r => JSON.parse(r.options.body)); + expect(payloads).toContainEqual({ recipe_id: 'r1', lora_index: 0 }); + expect(payloads).toContainEqual({ recipe_id: 'r2', lora_index: 1 }); + expect(entryA.hashInvalid).toBe(true); + expect(entryB.hashInvalid).toBe(true); + + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.unresolvableMarkedForReconnect', + { count: 2 }, + 'info', + expect.any(String), + ); + }); + + it('leaves entries untouched when the failure is transient', async () => { + const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 }; + const recipe = makeRecipe('/recipes/r1.json', [entry]); + const uniqueLoras = primePending([recipe]); + + mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Connection timed out' }); + + await manager.executeDownload(uniqueLoras); + + expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false); + expect(entry.hashInvalid).toBeUndefined(); + expect(showToastMock).not.toHaveBeenCalledWith( + 'toast.recipes.unresolvableMarkedForReconnect', + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it('marks hash-invalid when the download request itself throws an unresolvable error', async () => { + const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 }; + const recipe = makeRecipe('/recipes/r1.json', [entry]); + const uniqueLoras = primePending([recipe]); + + mockApiClient.downloadModel.mockRejectedValue(new Error('410 Gone')); + + await manager.executeDownload(uniqueLoras); + + const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid'); + expect(markCalls).toHaveLength(1); + expect(entry.hashInvalid).toBe(true); + }); + + it('does not mark entries whose download succeeds', async () => { + const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 }; + const recipe = makeRecipe('/recipes/r1.json', [entry]); + const uniqueLoras = primePending([recipe]); + + mockApiClient.downloadModel.mockResolvedValue({ success: true }); + + await manager.executeDownload(uniqueLoras); + + expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false); + }); +}); diff --git a/tests/frontend/managers/RematchModalManager.test.js b/tests/frontend/managers/RematchModalManager.test.js new file mode 100644 index 00000000..6b3292eb --- /dev/null +++ b/tests/frontend/managers/RematchModalManager.test.js @@ -0,0 +1,226 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); +const modalManagerMock = { + showModal: vi.fn(), + closeModal: vi.fn(), +}; + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: modalManagerMock, +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, +})); + +async function getManager() { + const { rematchModalManager } = await import( + '../../../static/js/managers/RematchModalManager.js' + ); + return rematchModalManager; +} + +describe('RematchModalManager options dialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ` +

+ + `; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('does not invoke the callback until confirmOptions is called', async () => { + const manager = await getManager(); + const onConfirm = vi.fn(); + + manager.showOptionsModal({ recipeCount: 3, onConfirm }); + + expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchOptionsModal'); + expect(onConfirm).not.toHaveBeenCalled(); + // Bulk message mentions the selection size. + expect(document.getElementById('rematchOptionsMessage').textContent).toContain('3'); + // The checkbox always starts unchecked. + expect(document.getElementById('rematchOptionsRelaxed').checked).toBe(false); + + manager.confirmOptions(); + expect(onConfirm).toHaveBeenCalledWith({ relaxed: false }); + expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal'); + }); + + it('uses the generic message when no recipe count is given', async () => { + const manager = await getManager(); + + manager.showOptionsModal({ onConfirm: vi.fn() }); + + expect(translateMock).toHaveBeenCalledWith( + 'modals.rematchOptions.messageGlobal', + {}, + 'All recipes will be scanned against your local model library.' + ); + }); + + it('uses the single-recipe message for scope: single', async () => { + const manager = await getManager(); + + manager.showOptionsModal({ scope: 'single', onConfirm: vi.fn() }); + + expect(translateMock).toHaveBeenCalledWith( + 'modals.rematchOptions.messageSingle', + {}, + 'This recipe will be scanned against your local model library.' + ); + }); + + it('passes relaxed: true when the checkbox is checked', async () => { + const manager = await getManager(); + const onConfirm = vi.fn(); + + manager.showOptionsModal({ onConfirm }); + document.getElementById('rematchOptionsRelaxed').checked = true; + manager.confirmOptions(); + + expect(onConfirm).toHaveBeenCalledWith({ relaxed: true }); + }); + + it('resets the checkbox to unchecked each time the dialog opens', async () => { + const manager = await getManager(); + const checkbox = document.getElementById('rematchOptionsRelaxed'); + checkbox.checked = true; + + manager.showOptionsModal({ onConfirm: vi.fn() }); + + expect(checkbox.checked).toBe(false); + }); + + it('cancelOptions runs nothing and clears the callback', async () => { + const manager = await getManager(); + const onConfirm = vi.fn(); + + manager.showOptionsModal({ onConfirm }); + manager.cancelOptions(); + + expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal'); + // A later confirm must not fire the cancelled callback. + manager.confirmOptions(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); + +describe('RematchModalManager results modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + global.fetch = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete global.fetch; + }); + + it('does nothing for an empty match list', async () => { + const manager = await getManager(); + + manager.showResultsModal([]); + + expect(modalManagerMock.showModal).not.toHaveBeenCalled(); + }); + + it('renders one row per L4 match with entry, file name and recipe', async () => { + const manager = await getManager(); + + manager.showResultsModal([ + { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 }, + { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' }, + ]); + + const rows = document.querySelectorAll('#rematchResultsList .rematch-results-row'); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain('old.safetensors'); + expect(rows[0].textContent).toContain('new.safetensors'); + expect(rows[0].textContent).toContain('r1'); + expect(rows[1].textContent).toContain('cp-new.safetensors'); + expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchResultsModal'); + }); + + it('undo posts to the lora restore endpoint and disables the row', async () => { + const manager = await getManager(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + manager.showResultsModal([ + { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 }, + ]); + + const row = document.querySelector('.rematch-results-row'); + const button = row.querySelector('.rematch-results-undo'); + button.click(); + await vi.waitFor(() => expect(button.disabled).toBe(true)); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }), + }); + expect(row.classList.contains('undone')).toBe(true); + }); + + it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => { + const manager = await getManager(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + manager.showResultsModal([ + { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' }, + ]); + + const button = document.querySelector('.rematch-results-undo'); + button.click(); + await vi.waitFor(() => expect(button.disabled).toBe(true)); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipe_id: 'r2' }), + }); + }); + + it('keeps the row actionable and toasts when undo fails', async () => { + const manager = await getManager(); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: false, error: 'no snapshot' }), + }); + + manager.showResultsModal([ + { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 }, + ]); + + const row = document.querySelector('.rematch-results-row'); + const button = row.querySelector('.rematch-results-undo'); + button.click(); + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled()); + + expect(button.disabled).toBe(false); + expect(row.classList.contains('undone')).toBe(false); + expect(showToastMock).toHaveBeenCalledWith( + 'modals.rematchResults.undoFailed', + { message: 'no snapshot' }, + 'error' + ); + }); +}); diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 3629a96a..585b4101 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -60,6 +60,9 @@ class StubRecipeScanner: self.rematch_all_calls: List[Any] = [] self.rematch_by_id_calls: List[str] = [] self.rematch_bulk_calls: List[List[str]] = [] + self.rematch_all_relaxed: List[bool] = [] + self.rematch_by_id_relaxed: List[bool] = [] + self.rematch_bulk_relaxed: List[bool] = [] self.rematch_results: Dict[str, Dict[str, Any]] = {} async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner @@ -131,7 +134,7 @@ class StubRecipeScanner: def reset_cancellation(self) -> None: self.reset_calls += 1 - async def rematch_all_recipes(self, progress_callback=None): + async def rematch_all_recipes(self, progress_callback=None, *, relaxed: bool = False): """Run a canned rematch-all run, mirroring the real progress events.""" if progress_callback: await progress_callback({"status": "started"}) @@ -142,6 +145,7 @@ class StubRecipeScanner: {"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1} ) self.rematch_all_calls.append(progress_callback) + self.rematch_all_relaxed.append(relaxed) return { "success": True, "status": "completed", @@ -151,14 +155,20 @@ class StubRecipeScanner: "total": 1, } - async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: + async def rematch_recipe_by_id( + self, recipe_id: str, *, relaxed: bool = False + ) -> Dict[str, Any]: self.rematch_by_id_calls.append(recipe_id) + self.rematch_by_id_relaxed.append(relaxed) if recipe_id not in self.rematch_results: raise RecipeNotFoundError(f"Recipe not found: {recipe_id}") return self.rematch_results[recipe_id] - async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]: + async def rematch_recipes_bulk( + self, recipe_ids: List[str], *, relaxed: bool = False + ) -> Dict[str, Any]: self.rematch_bulk_calls.append(list(recipe_ids)) + self.rematch_bulk_relaxed.append(relaxed) total = len(recipe_ids) rematched = 0 skipped = 0 @@ -1992,6 +2002,82 @@ async def test_rematch_recipe_maps_not_found_to_404(monkeypatch, tmp_path: Path) assert harness.scanner.rematch_by_id_calls == ["ghost"] +async def test_rematch_recipes_passes_relaxed_flag_from_body( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipes/rematch", json={"relaxed": True} + ) + payload = await response.json() + assert response.status == 200, payload + await asyncio.sleep(0.1) + assert harness.scanner.rematch_all_relaxed == [True] + + +async def test_rematch_recipes_relaxed_defaults_to_false( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post("/api/lm/recipes/rematch") + payload = await response.json() + assert response.status == 200, payload + await asyncio.sleep(0.1) + assert harness.scanner.rematch_all_relaxed == [False] + + +async def test_rematch_recipes_relaxed_query_param_fallback( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post("/api/lm/recipes/rematch?relaxed=true") + payload = await response.json() + assert response.status == 200, payload + await asyncio.sleep(0.1) + assert harness.scanner.rematch_all_relaxed == [True] + + +async def test_rematch_recipes_bulk_passes_relaxed_flag_from_body( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipes/rematch-bulk", + json={"recipe_ids": ["r1"], "relaxed": True}, + ) + payload = await response.json() + assert response.status == 200, payload + assert harness.scanner.rematch_bulk_relaxed == [True] + + +async def test_rematch_recipes_bulk_relaxed_query_param_fallback( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipes/rematch-bulk?relaxed=true", + json={"recipe_ids": ["r1"]}, + ) + payload = await response.json() + assert response.status == 200, payload + assert harness.scanner.rematch_bulk_relaxed == [True] + + +async def test_rematch_recipe_passes_relaxed_flag_from_query( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + harness.scanner.rematch_results = { + "abc123": {"success": True, "rematched": 1}, + } + response = await harness.client.post( + "/api/lm/recipe/abc123/rematch?relaxed=true" + ) + payload = await response.json() + assert response.status == 200, payload + assert harness.scanner.rematch_by_id_relaxed == [True] + + async def test_get_rematch_progress_404_when_no_progress( monkeypatch, tmp_path: Path ) -> None: diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index aa57d5c6..e2bad6c2 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -4012,6 +4012,7 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat "entry": "old.safetensors", "file_name": "m.safetensors", "match_level": "L1", + "lora_index": 0, } ] assert result["recipe"] is enriched @@ -4032,6 +4033,79 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat assert resort_calls == [] # Metis F1 — hoisted to public entry points +# Rematch write-back must snapshot the pre-match state (undo affordance) + + +async def test_write_rematch_lora_entry_snapshots_pre_match_state(tmp_path: Path): + scanner, _, _ = _make_rematch_scanner([], [], tmp_path) + original_entry = { + "isDeleted": True, + "hashInvalid": False, + "hash": "oldhash", + "file_name": "old.safetensors", + "modelVersionId": 0, + "modelName": "Old Name", + } + entry = dict(original_entry) + item = _civitai_lora_item( + sha256="b" * 64, + version_id=222, + name="v2.0", + model_name="New Model", + file_name="new.safetensors", + ) + + scanner._write_rematch_lora_entry(entry, item) + + assert entry["hash"] == "b" * 64 + assert entry["file_name"] == "new.safetensors" + assert entry["reconnectSnapshot"] == original_entry + + +async def test_write_rematch_lora_entry_snapshot_never_nests(tmp_path: Path): + scanner, _, _ = _make_rematch_scanner([], [], tmp_path) + entry = { + "isDeleted": True, + "hash": "oldhash", + "file_name": "old.safetensors", + "reconnectSnapshot": {"file_name": "even-older.safetensors"}, + } + item = _civitai_lora_item(sha256="c" * 64, file_name="new.safetensors") + + scanner._write_rematch_lora_entry(entry, item) + + snapshot = entry["reconnectSnapshot"] + assert snapshot["file_name"] == "old.safetensors" + assert "reconnectSnapshot" not in snapshot + + +async def test_write_rematch_checkpoint_entry_snapshots_pre_match_state(tmp_path: Path): + scanner, _, _ = _make_rematch_scanner([], [], tmp_path) + original_entry = { + "isDeleted": True, + "hashInvalid": True, + "hash": "oldhash", + "file_name": "old.safetensors", + "name": "Old CP", + "modelVersionId": 0, + } + entry = dict(original_entry) + item = _civitai_checkpoint_item( + sha256="d" * 64, + version_id=333, + name="cp-v1", + model_name="New CP", + file_name="new-cp.safetensors", + ) + + scanner._write_rematch_checkpoint_entry(entry, item) + + assert entry["hash"] == "d" * 64 + assert entry["file_name"] == "new-cp.safetensors" + assert entry["reconnectSnapshot"] == original_entry + assert "reconnectSnapshot" not in entry["reconnectSnapshot"] + + # Acceptance criterion (2): checkpoint entry rematched via L2 — parser style @@ -4659,6 +4733,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop( local_cache: dict[str, Any], autov3_cache: dict[str, Any], filename_cache=None, + **_kwargs: Any, ) -> tuple[int, int, dict[str, Any]]: if recipe.get("id") == "boom": raise RuntimeError("kaboom") @@ -4717,12 +4792,15 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat local_cache: dict[str, Any], autov3_cache: dict[str, Any], filename_cache=None, + **kwargs: Any, ) -> tuple[int, int, dict[str, Any]]: nonlocal entered if recipe.get("id") == "r0": entered = True await release.wait() - return await original(recipe, local_cache, autov3_cache, filename_cache) + return await original( + recipe, local_cache, autov3_cache, filename_cache, **kwargs + ) monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single) @@ -4899,6 +4977,236 @@ async def test_rematch_all_autov3_cache_reuse_across_calls( assert len(called) == 1 +# --------------------------------------------------------------------------- +# Relaxed rematch candidacy (Feature 3) +# --------------------------------------------------------------------------- + + +async def test_is_rematch_candidate_relaxed_accepts_healthy_entry(tmp_path: Path): + scanner, _, _ = _make_rematch_scanner([], [], tmp_path) + healthy = {"hash": "abc", "file_name": "m.safetensors"} + assert scanner._is_rematch_candidate(healthy, relaxed=True) + # Default strict behavior is unchanged. + assert not scanner._is_rematch_candidate(healthy) + assert not scanner._is_rematch_candidate(healthy, relaxed=False) + + +async def test_is_rematch_candidate_relaxed_still_requires_identifier(tmp_path: Path): + scanner, _, _ = _make_rematch_scanner([], [], tmp_path) + assert not scanner._is_rematch_candidate({}, relaxed=True) + assert not scanner._is_rematch_candidate({"isDeleted": True}, relaxed=True) + assert not scanner._is_rematch_candidate("garbage", relaxed=True) + + +async def test_rematch_relaxed_skips_healthy_entry_with_local_hash( + tmp_path: Path, monkeypatch +): + # Anti-churn: a relaxed-only candidate whose hash already resolves in the + # L1 local cache is already correctly linked — no write-back, no + # snapshot, and it counts as neither matched nor unresolved. + sha256 = ("A1" * 32).lower() + item = _civitai_lora_item(sha256=sha256, file_name="m.safetensors") + scanner, _, _ = _make_rematch_scanner([item], [], tmp_path) + recipe: Dict[str, Any] = { + "id": "r1", + "loras": [{"hash": sha256, "file_name": "m.safetensors"}], + } + _set_recipe_cache(scanner, [recipe]) + saved, _ = await _spy_rematch_persistence(scanner, monkeypatch) + + result = await scanner.rematch_recipe_by_id("r1", relaxed=True) + + assert result["success"] is True + assert result["matched_entries"] == 0 + assert result["unresolved_entries"] == 0 + assert result["details"] == {"matched": [], "unresolved": []} + assert saved == [] + assert "reconnectSnapshot" not in recipe["loras"][0] + + +async def test_rematch_relaxed_matches_healthy_missing_entry_via_l4( + tmp_path: Path, monkeypatch +): + # A healthy entry whose hash is NOT in the local library becomes an L4 + # filename match under relaxed mode when the base models agree. + sha256 = ("B2" * 32).lower() + item = _rematch_item( + sha256=sha256, + sub_type="lora", + base_model="SD 1.5", + file_name="detail.safetensors", + ) + scanner, _, _ = _make_rematch_scanner([item], [], tmp_path) + recipe: Dict[str, Any] = { + "id": "r1", + "base_model": "SD 1.5", + "loras": [ + { + "hash": "f" * 64, # not present locally + "file_name": "detail.safetensors", + } + ], + } + _set_recipe_cache(scanner, [recipe]) + saved, _ = await _spy_rematch_persistence(scanner, monkeypatch) + + # Strict mode never touches the healthy entry. + strict = await scanner.rematch_recipe_by_id("r1") + assert strict["matched_entries"] == 0 + assert saved == [] + + result = await scanner.rematch_recipe_by_id("r1", relaxed=True) + + assert result["matched_entries"] == 1 + assert result["details"]["matched"] == [ + { + "type": "lora", + "entry": "detail.safetensors", + "file_name": "detail.safetensors", + "match_level": "L4", + "lora_index": 0, + } + ] + entry = recipe["loras"][0] + assert entry["hash"] == sha256 + assert entry["reconnectSnapshot"]["hash"] == "f" * 64 + assert saved == [recipe] + + +async def test_rematch_matched_details_carry_lora_index_and_bulk_flattens_l4( + tmp_path: Path, monkeypatch +): + sha256_l1 = ("C3" * 32).lower() + l1_item = _civitai_lora_item(sha256=sha256_l1, file_name="l1.safetensors") + l4_item = _rematch_item( + sha256=("D4" * 32).lower(), + sub_type="lora", + base_model="SD 1.5", + file_name="detail.safetensors", + ) + scanner, _, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path) + recipes: list[Dict[str, Any]] = [ + { + "id": "r0", + "base_model": "SD 1.5", + "loras": [ + # index 0: not a candidate at all (healthy, strict run) + {"hash": "zzz", "file_name": "other.safetensors"}, + # index 1: L4 filename match + {"isDeleted": True, "file_name": "detail.safetensors"}, + # index 2: L1 hash match + { + "isDeleted": True, + "hash": sha256_l1, + "file_name": "old.safetensors", + }, + ], + }, + {"id": "r1", "loras": []}, + ] + _set_recipe_cache(scanner, recipes) + await _spy_rematch_persistence(scanner, monkeypatch) + await _spy_resort(scanner, monkeypatch) + + result = await scanner.rematch_recipes_bulk(["r0", "r1"]) + + assert result["matched_entries"] == 2 + matched = result["details"][0]["matched"] + assert matched[0]["lora_index"] == 1 + assert matched[0]["match_level"] == "L4" + assert matched[1]["lora_index"] == 2 + assert matched[1]["match_level"] == "L1" + # Only the L4 match is flattened for review; L1 matches need none. + assert result["l4_matches"] == [ + { + "recipe_id": "r0", + "type": "lora", + "entry": "detail.safetensors", + "file_name": "detail.safetensors", + "lora_index": 1, + } + ] + + +async def test_rematch_recipe_by_id_returns_flattened_l4_matches( + tmp_path: Path, monkeypatch +): + # The single-recipe return carries the same flattened l4_matches shape + # as the bulk/global paths so the frontend results modal works for all + # three entry points. + l4_item = _rematch_item( + sha256=("F6" * 32).lower(), + sub_type="lora", + base_model="SD 1.5", + file_name="detail.safetensors", + ) + scanner, _, _ = _make_rematch_scanner([l4_item], [], tmp_path) + recipe: Dict[str, Any] = { + "id": "r1", + "base_model": "SD 1.5", + "loras": [{"isDeleted": True, "file_name": "detail.safetensors"}], + } + _set_recipe_cache(scanner, [recipe]) + await _spy_rematch_persistence(scanner, monkeypatch) + + result = await scanner.rematch_recipe_by_id("r1") + + assert result["l4_matches"] == [ + { + "recipe_id": "r1", + "type": "lora", + "entry": "detail.safetensors", + "file_name": "detail.safetensors", + "lora_index": 0, + } + ] + + +async def test_rematch_all_recipes_reports_l4_matches_in_completed_payload( + tmp_path: Path, monkeypatch +): + l4_item = _rematch_item( + sha256=("E5" * 32).lower(), + sub_type="checkpoint", + base_model="SDXL", + file_name="realistic.safetensors", + ) + scanner, _, _ = _make_rematch_scanner([], [l4_item], tmp_path) + recipe: Dict[str, Any] = { + "id": "r1", + "loras": [], + "checkpoint": { + "isDeleted": True, + "file_name": "realistic.safetensors", + "baseModel": "SDXL", + }, + } + _set_recipe_cache(scanner, [recipe]) + await _spy_rematch_persistence(scanner, monkeypatch) + await _spy_resort(scanner, monkeypatch) + + events: list[Dict[str, Any]] = [] + + async def cb(ev: Dict[str, Any]) -> None: + events.append(ev) + + result = await scanner.rematch_all_recipes(progress_callback=cb) + + expected_l4 = [ + { + "recipe_id": "r1", + "type": "checkpoint", + "entry": "realistic.safetensors", + "file_name": "realistic.safetensors", + } + ] + # Checkpoint matches carry no lora_index (the checkpoint restore + # endpoint only needs recipe_id). + assert result["l4_matches"] == expected_l4 + completed = [e for e in events if e["status"] == "completed"] + assert completed and completed[0]["l4_matches"] == expected_l4 + + async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch): scanner, _ = recipe_scanner