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