mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-23 20:14:08 -03:00
Compare commits
7
Commits
6a259a14fa
...
f1d3ac0cdc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1d3ac0cdc | ||
|
|
e2c45905f0 | ||
|
|
b2c68e6a65 | ||
|
|
eb0f6dd3b6 | ||
|
|
0bf87f9092 | ||
|
|
1da2433bb2 | ||
|
|
2d6cf545b9 |
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "Bereits heruntergeladene Modellversionen überspringen",
|
||||
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Nach Modell gruppieren",
|
||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Modell löschen",
|
||||
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?"
|
||||
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Modell ausschließen",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
||||
"copyFailed": "Kopieren fehlgeschlagen"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "Fehler beim Laden der Elemente",
|
||||
"loadMoreFailed": "Fehler beim Laden weiterer Elemente",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "Skip previously downloaded model versions",
|
||||
"help": "When enabled, versions downloaded before will be skipped."
|
||||
},
|
||||
"deleteUndoEnabled": "Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Group by Model",
|
||||
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "Frees {size}",
|
||||
"title": "Delete Model",
|
||||
"message": "Are you sure you want to delete this model and all associated files?"
|
||||
"message": "Are you sure you want to delete this model and all associated files?",
|
||||
"permanentWarning": "This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Exclude Model",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "Failed to update trigger words",
|
||||
"copyFailed": "Copy failed"
|
||||
},
|
||||
"undo": {
|
||||
"action": "Undo",
|
||||
"deleted": "Deleted {name}",
|
||||
"deletedBulk": "Deleted {count} item(s)",
|
||||
"expired": "Undo window expired. The item was permanently deleted.",
|
||||
"failed": "Undo failed: {error}",
|
||||
"restored": "Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "Failed to load items",
|
||||
"loadMoreFailed": "Failed to load more items",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "Omitir versiones de modelos previamente descargadas",
|
||||
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Agrupar por modelo",
|
||||
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Eliminar modelo",
|
||||
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?"
|
||||
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Excluir modelo",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "Error al actualizar palabras clave",
|
||||
"copyFailed": "Error al copiar"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "Error al cargar elementos",
|
||||
"loadMoreFailed": "Error al cargar más elementos",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "Ignorer les versions de modèles précédemment téléchargées",
|
||||
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Grouper par modèle",
|
||||
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Supprimer le modèle",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?"
|
||||
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Exclure le modèle",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "Échec de la mise à jour des mots-clés",
|
||||
"copyFailed": "Échec de la copie"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "Échec du chargement des éléments",
|
||||
"loadMoreFailed": "Échec du chargement de plus d'éléments",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "דלג על גרסאות מודלים שהורדו בעבר",
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "קיבוץ לפי דגם",
|
||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "מחק מודל",
|
||||
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?"
|
||||
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "החרג מודל",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "עדכון מילות הטריגר נכשל",
|
||||
"copyFailed": "ההעתקה נכשלה"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "טעינת הפריטים נכשלה",
|
||||
"loadMoreFailed": "טעינת פריטים נוספים נכשלה",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "以前にダウンロードしたモデルバージョンをスキップ",
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "モデルを削除",
|
||||
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?"
|
||||
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "モデルを除外",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "トリガーワードの更新に失敗しました",
|
||||
"copyFailed": "コピーに失敗しました"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "アイテムの読み込みに失敗しました",
|
||||
"loadMoreFailed": "追加アイテムの読み込みに失敗しました",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "이전에 다운로드한 모델 버전 건너뛰기",
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "모델 삭제",
|
||||
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?"
|
||||
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "모델 제외",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
||||
"copyFailed": "복사 실패"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "항목 로딩 실패",
|
||||
"loadMoreFailed": "더 많은 항목 로딩 실패",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "Пропускать ранее загруженные версии моделей",
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Группировать по модели",
|
||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Удалить модель",
|
||||
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?"
|
||||
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Исключить модель",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "Не удалось обновить триггерные слова",
|
||||
"copyFailed": "Копирование не удалось"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "Не удалось загрузить элементы",
|
||||
"loadMoreFailed": "Не удалось загрузить больше элементов",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "跳过已下载的模型版本",
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "删除模型",
|
||||
"message": "你确定要删除此模型及所有相关文件吗?"
|
||||
"message": "你确定要删除此模型及所有相关文件吗?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "排除模型",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "触发词更新失败",
|
||||
"copyFailed": "复制失败"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "加载项目失败",
|
||||
"loadMoreFailed": "加载更多项目失败",
|
||||
|
||||
+13
-1
@@ -443,6 +443,7 @@
|
||||
"label": "跳過已下載的模型版本",
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
@@ -1277,8 +1278,11 @@
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "刪除模型",
|
||||
"message": "您確定要刪除此模型及所有相關檔案嗎?"
|
||||
"message": "您確定要刪除此模型及所有相關檔案嗎?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "排除模型",
|
||||
@@ -2113,6 +2117,14 @@
|
||||
"updateFailed": "更新觸發詞失敗",
|
||||
"copyFailed": "複製失敗"
|
||||
},
|
||||
"undo": {
|
||||
"action": "[TODO: Translate] Undo",
|
||||
"deleted": "[TODO: Translate] Deleted {name}",
|
||||
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||
"restored": "[TODO: Translate] Item restored"
|
||||
},
|
||||
"virtual": {
|
||||
"loadFailed": "載入項目失敗",
|
||||
"loadMoreFailed": "載入更多項目失敗",
|
||||
|
||||
@@ -25,10 +25,12 @@ from .routes.recipe_routes import RecipeRoutes
|
||||
from .routes.stats_routes import StatsRoutes
|
||||
from .routes.update_routes import UpdateRoutes
|
||||
from .routes.misc_routes import MiscRoutes
|
||||
from .routes.pending_delete_routes import PendingDeleteRoutes
|
||||
from .routes.preview_routes import PreviewRoutes
|
||||
from .routes.example_images_routes import ExampleImagesRoutes
|
||||
from .services.service_registry import ServiceRegistry
|
||||
from .services.settings_manager import get_settings_manager
|
||||
from .services.pending_delete_service import get_pending_delete_service
|
||||
from .utils.example_images_migration import ExampleImagesMigration
|
||||
from .services.websocket_manager import ws_manager
|
||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||
@@ -170,6 +172,7 @@ class LoraManager:
|
||||
RecipeRoutes.setup_routes(app)
|
||||
UpdateRoutes.setup_routes(app)
|
||||
MiscRoutes.setup_routes(app)
|
||||
PendingDeleteRoutes.setup_routes(app)
|
||||
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||
PreviewRoutes.setup_routes(app)
|
||||
|
||||
@@ -245,6 +248,17 @@ class LoraManager:
|
||||
cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks"
|
||||
)
|
||||
|
||||
# Startup sweep: purge pending-delete batches that expired during a
|
||||
# previous run. Non-blocking (fire-and-forget); purge_expired only
|
||||
# removes already-expired batches, so a staged undo that survived a
|
||||
# restart stays restorable. Covers both plugin and standalone modes
|
||||
# (StandaloneLoraManager reuses this classmethod).
|
||||
pending_delete_service = await get_pending_delete_service()
|
||||
asyncio.create_task(
|
||||
pending_delete_service.purge_expired(),
|
||||
name="pending_delete_startup_sweep",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"LoRA Manager: All services initialized and background tasks scheduled"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Handler for the pending-delete undo endpoint.
|
||||
|
||||
Restores a staged delete batch (models or recipes) via
|
||||
``PendingDeleteService.undo`` and then repairs the affected library caches:
|
||||
the model cache entry is restored from the manifest's ``model_snapshot``
|
||||
(including the version index and hash index), tag counts are re-incremented,
|
||||
and the recipe cache is re-populated via ``RecipeScanner.add_recipe``.
|
||||
|
||||
The per-type scanner is resolved from the manifest's ``model_type`` page value
|
||||
through the SAME ServiceRegistry getters the model route registrars use
|
||||
(lora/checkpoint/embedding) - never a hardcoded lora scanner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, cast
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...services.pending_delete_service import get_pending_delete_service
|
||||
from .model_handlers import _broadcast_models_changed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Manifest ``model_type`` page values -> ServiceRegistry scanner getter names.
|
||||
# The model route registrars resolve per-type scanners via these getters
|
||||
# (lora_routes / checkpoint_routes / embedding_routes); undo must do the same
|
||||
# so the CORRECT cache is restored for the deleted model's type.
|
||||
_MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
||||
"loras": "get_lora_scanner",
|
||||
"checkpoints": "get_checkpoint_scanner",
|
||||
"embeddings": "get_embedding_scanner",
|
||||
}
|
||||
|
||||
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
||||
# joined into filesystem paths by ``_find_batch_dir``, so reject anything that
|
||||
# does not match this exact shape (blocks path-traversal via batch_id).
|
||||
_BATCH_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
|
||||
|
||||
class PendingDeleteHandler:
|
||||
"""Handle undo requests for staged model/recipe deletions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
service_factory: Callable[[], Awaitable[Any]] = get_pending_delete_service,
|
||||
scanner_getter: Optional[Callable[[str], Awaitable[Any]]] = None,
|
||||
recipe_scanner_getter: Optional[Callable[[], Awaitable[Any]]] = None,
|
||||
) -> None:
|
||||
self._service_factory: Callable[[], Awaitable[Any]] = service_factory
|
||||
self._scanner_getter: Callable[[str], Awaitable[Any]] = (
|
||||
scanner_getter or self._resolve_scanner
|
||||
)
|
||||
self._recipe_scanner_getter: Callable[[], Awaitable[Any]] = (
|
||||
recipe_scanner_getter or self._resolve_recipe_scanner
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_scanner(model_type: str) -> Any:
|
||||
"""Resolve the per-type scanner for a manifest ``model_type``.
|
||||
|
||||
The getter is looked up on the ServiceRegistry module namespace at call
|
||||
time so tests (and the registry stubs) can patch it.
|
||||
"""
|
||||
from ...services import service_registry
|
||||
|
||||
getter_name = _MODEL_TYPE_GETTER_NAMES.get(model_type)
|
||||
if getter_name is None:
|
||||
raise ValueError(f"Unknown model type: {model_type}")
|
||||
getter = getattr(service_registry.ServiceRegistry, getter_name, None)
|
||||
if not callable(getter):
|
||||
raise ValueError(f"No scanner getter for model type: {model_type}")
|
||||
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
|
||||
if scanner is None:
|
||||
raise ValueError(f"No scanner registered for model type: {model_type}")
|
||||
return scanner
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_recipe_scanner() -> Any:
|
||||
"""Resolve the recipe scanner via the ServiceRegistry module namespace."""
|
||||
from ...services import service_registry
|
||||
|
||||
getter = getattr(service_registry.ServiceRegistry, "get_recipe_scanner", None)
|
||||
if not callable(getter):
|
||||
raise ValueError("Recipe scanner getter unavailable")
|
||||
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
|
||||
if scanner is None:
|
||||
raise ValueError("No recipe scanner registered")
|
||||
return scanner
|
||||
|
||||
async def undo_delete(self, request: web.Request) -> web.Response:
|
||||
"""Restore a staged batch and its library cache entry.
|
||||
|
||||
Body: ``{"batch_id": str}``. On success returns
|
||||
``{"success": True, "restored": [<original paths>], "kind": kind}``.
|
||||
Expired/unknown batches and occupied target paths -> 404.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
batch_id = data.get("batch_id")
|
||||
if not batch_id or not isinstance(batch_id, str):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "batch_id is required"}, status=400
|
||||
)
|
||||
if not _BATCH_ID_RE.fullmatch(batch_id):
|
||||
# batch_id is joined into a path by _find_batch_dir - restrict to
|
||||
# the exact staged-id shape so traversal payloads get 400.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid batch_id"}, status=400
|
||||
)
|
||||
|
||||
service = await self._service_factory()
|
||||
try:
|
||||
# Read the manifest BEFORE undo: undo() removes the batch dir.
|
||||
manifest = await self._read_staged_manifest(service, batch_id)
|
||||
result = await service.undo(batch_id)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
logger.error("Unexpected error undoing batch %s: %s", batch_id, exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
kind = result.get("kind")
|
||||
try:
|
||||
if kind == "model":
|
||||
if manifest is not None:
|
||||
await self._restore_model_cache(manifest)
|
||||
else:
|
||||
# undo() raises when the manifest is missing, so this only
|
||||
# happens defensively - files are restored regardless.
|
||||
logger.warning(
|
||||
"Manifest missing after undo of %s; skipping cache restore",
|
||||
batch_id,
|
||||
)
|
||||
_broadcast_models_changed()
|
||||
elif kind == "recipe":
|
||||
# Recipe undo is client-refresh only: re-add to the scanner
|
||||
# cache, no models_changed broadcast.
|
||||
if manifest is not None:
|
||||
await self._restore_recipe_cache(result, manifest)
|
||||
else:
|
||||
logger.warning(
|
||||
"Manifest missing after undo of %s; skipping cache restore",
|
||||
batch_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Files are already restored; only the cache restoration failed.
|
||||
logger.error(
|
||||
"Cache restoration failed after undo of %s: %s",
|
||||
batch_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"restored": result.get("restored", []),
|
||||
"kind": kind,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _read_staged_manifest(
|
||||
service: Any, batch_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Locate and read the batch manifest while it still exists on disk."""
|
||||
batch_dir = await service._find_batch_dir(batch_id)
|
||||
if not batch_dir:
|
||||
return None
|
||||
manifest_path = os.path.join(batch_dir, "manifest.json")
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.debug("Failed to read manifest for batch %s: %s", batch_id, exc)
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
async def _restore_model_cache(self, manifest: Dict[str, Any]) -> None:
|
||||
"""Re-add every deleted model's cache entry from the manifest.
|
||||
|
||||
Each main-file entry carries the deleted model's ``snapshot`` (added at
|
||||
stage time), so a merged bulk manifest holds ALL snapshots - undo must
|
||||
restore every one, not just the top-level winner's. Old-format
|
||||
manifests without entry snapshots fall back to the top-level
|
||||
``model_snapshot`` (backward compat / single-delete path).
|
||||
"""
|
||||
model_type = manifest.get("model_type")
|
||||
if not model_type or not isinstance(model_type, str):
|
||||
raise ValueError(f"Manifest carries no model_type: {manifest.get('batch_id')}")
|
||||
scanner = await self._scanner_getter(model_type)
|
||||
|
||||
# Collect one snapshot per distinct file_path from the entry snapshots.
|
||||
snapshots: List[Dict[str, Any]] = []
|
||||
seen: Set[str] = set()
|
||||
for entry in manifest.get("entries") or []:
|
||||
snapshot = entry.get("snapshot")
|
||||
if not isinstance(snapshot, dict):
|
||||
continue
|
||||
file_path = snapshot.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
continue
|
||||
if file_path in seen:
|
||||
continue
|
||||
seen.add(file_path)
|
||||
snapshots.append(snapshot)
|
||||
|
||||
if not snapshots:
|
||||
# Backward compat: pre-F3 manifests carry only the top-level
|
||||
# model_snapshot (single-delete path, unchanged behavior).
|
||||
top = manifest.get("model_snapshot")
|
||||
if isinstance(top, dict) and top.get("file_path"):
|
||||
snapshots = [top]
|
||||
else:
|
||||
logger.warning(
|
||||
"Manifest %s has no restorable model snapshot; skipping cache restore",
|
||||
manifest.get("batch_id"),
|
||||
)
|
||||
return
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
if cache is None:
|
||||
logger.warning(
|
||||
"Scanner cache unavailable for %s; skipping cache restore", model_type
|
||||
)
|
||||
return
|
||||
|
||||
for snapshot in snapshots:
|
||||
file_path = str(snapshot["file_path"])
|
||||
# A rescan between delete and undo may have re-added a stale entry
|
||||
# for this path - drop it so exactly one (the snapshot) remains.
|
||||
cache.raw_data = [
|
||||
item for item in cache.raw_data if item.get("file_path") != file_path
|
||||
]
|
||||
|
||||
# Restore tag counts (mirror of the bulk-delete decrement in
|
||||
# _batch_update_cache_for_deleted_models: undo re-increments).
|
||||
tags = snapshot.get("tags")
|
||||
if isinstance(tags, list):
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str) or not tag:
|
||||
continue
|
||||
scanner._tags_count[tag] = scanner._tags_count.get(tag, 0) + 1
|
||||
|
||||
cache.raw_data.append(dict(snapshot))
|
||||
|
||||
# Re-register the path in the hash index (add_entry guards a
|
||||
# missing sha256 internally; still guard defensively here).
|
||||
sha256 = snapshot.get("sha256") or ""
|
||||
autov3 = snapshot.get("autov3")
|
||||
hash_index = getattr(scanner, "_hash_index", None)
|
||||
if hash_index is not None and sha256 and file_path:
|
||||
hash_index.add_entry(sha256, file_path, autov3)
|
||||
|
||||
# Follow the bulk-delete cache-update pattern ONCE after all entries,
|
||||
# including the explicit version-index rebuild so the version index
|
||||
# does not go stale.
|
||||
cache.rebuild_version_index()
|
||||
await cache.resort()
|
||||
|
||||
scanner.bump_cache_version()
|
||||
|
||||
persist = getattr(scanner, "_persist_current_cache", None)
|
||||
if callable(persist):
|
||||
result = persist()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def _restore_recipe_cache(
|
||||
self, result: Dict[str, Any], manifest: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Re-add a restored recipe via ``RecipeScanner.add_recipe``.
|
||||
|
||||
The recipe JSON embeds the full recipe_data (incl. id/file_path);
|
||||
``add_recipe`` only READS the ``_json_path_map`` so the forced frontend
|
||||
refresh self-heals any transient path-map gap.
|
||||
"""
|
||||
restored = result.get("restored") or []
|
||||
json_path = next(
|
||||
(p for p in restored if isinstance(p, str) and p.endswith(".json")),
|
||||
None,
|
||||
)
|
||||
if not json_path or not os.path.exists(json_path):
|
||||
# Defensive fallback to the manifest's recipe_snapshot file_path.
|
||||
snapshot = manifest.get("recipe_snapshot") or {}
|
||||
fallback = snapshot.get("file_path")
|
||||
if fallback and os.path.exists(fallback):
|
||||
json_path = fallback
|
||||
else:
|
||||
logger.warning(
|
||||
"Restored recipe JSON not found in %s; skipping cache restore",
|
||||
restored,
|
||||
)
|
||||
return
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as handle:
|
||||
recipe_data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to load restored recipe JSON %s: %s", json_path, exc)
|
||||
return
|
||||
if not isinstance(recipe_data, dict):
|
||||
return
|
||||
recipe_scanner = await self._recipe_scanner_getter()
|
||||
await recipe_scanner.add_recipe(recipe_data)
|
||||
|
||||
|
||||
__all__ = ["PendingDeleteHandler"]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Route controller for the pending-delete undo endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .handlers.pending_delete_handler import PendingDeleteHandler
|
||||
|
||||
|
||||
class PendingDeleteRoutes:
|
||||
"""Shared route controller mirroring MiscRoutes/UpdateRoutes.
|
||||
|
||||
Registered ONCE per mode (py/lora_manager.py, standalone.py); NEVER through
|
||||
the per-model-type ModelRouteRegistrar, which is instantiated per model
|
||||
type and would register this non-prefixed route three times.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def setup_routes(app: web.Application) -> None:
|
||||
"""Register the shared undo-delete endpoint."""
|
||||
handler = PendingDeleteHandler()
|
||||
_ = app.router.add_post("/api/lm/undo-delete", handler.undo_delete)
|
||||
|
||||
|
||||
__all__ = ["PendingDeleteRoutes"]
|
||||
@@ -13,7 +13,7 @@ from ..utils.models import CheckpointMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -328,7 +328,8 @@ class CheckpointScanner(ModelScanner):
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
||||
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".metadata.json"):
|
||||
continue
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.pending_delete_service import get_pending_delete_service
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
@@ -129,9 +130,24 @@ class ModelLifecycleService:
|
||||
target_dir = os.path.dirname(file_path)
|
||||
base_name = os.path.basename(file_path)
|
||||
file_name, main_extension = os.path.splitext(base_name)
|
||||
deleted_files = await delete_model_artifacts(
|
||||
target_dir, file_name, main_extension=main_extension
|
||||
|
||||
# Stage the delete into the pending-delete service when undo is
|
||||
# enabled; a successful stage renames the artifacts away, otherwise
|
||||
# fall back to the direct hard delete.
|
||||
pending_delete_service = await get_pending_delete_service()
|
||||
batch_id = await pending_delete_service.stage_model_delete(
|
||||
scanner=self._scanner,
|
||||
target_dir=target_dir,
|
||||
file_name=file_name,
|
||||
main_extension=main_extension,
|
||||
original_file_path=file_path,
|
||||
cached_entry=cached_entry,
|
||||
)
|
||||
deleted_files: List[str] = []
|
||||
if batch_id is None:
|
||||
deleted_files = await delete_model_artifacts(
|
||||
target_dir, file_name, main_extension=main_extension
|
||||
)
|
||||
|
||||
if cache:
|
||||
cache.raw_data = [
|
||||
@@ -151,7 +167,11 @@ class ModelLifecycleService:
|
||||
if callable(persist_current_cache):
|
||||
await cast(Awaitable[Any], persist_current_cache())
|
||||
|
||||
return {"success": True, "deleted_files": deleted_files}
|
||||
return {
|
||||
"success": True,
|
||||
"deleted_files": deleted_files,
|
||||
"batch_id": batch_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_model_id_from_payload(payload: Any) -> Optional[int]:
|
||||
|
||||
@@ -19,12 +19,28 @@ from .service_registry import ServiceRegistry
|
||||
from .websocket_manager import ws_manager
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
from .settings_manager import get_settings_manager
|
||||
from .pending_delete_service import PENDING_DELETE_DIR_NAME, get_pending_delete_service
|
||||
from .cache_entry_validator import CacheEntryValidator
|
||||
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_excluded_dir(name: str) -> bool:
|
||||
"""Return True when a directory entry must be skipped during model walks.
|
||||
|
||||
The pending-delete staging directory is excluded so staged files never
|
||||
appear in the library as ghost model entries.
|
||||
"""
|
||||
return name == PENDING_DELETE_DIR_NAME
|
||||
|
||||
|
||||
def _is_pending_delete_path(path: str) -> bool:
|
||||
"""Return True when any path component is the pending-delete staging dir."""
|
||||
normalized = str(path).replace(os.sep, "/")
|
||||
return any(part == PENDING_DELETE_DIR_NAME for part in normalized.split("/"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheBuildResult:
|
||||
"""Represents the outcome of scanning model files for cache building."""
|
||||
@@ -711,6 +727,8 @@ class ModelScanner:
|
||||
if ext in self.file_extensions:
|
||||
total_files += 1
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
if _is_excluded_dir(entry.name):
|
||||
continue
|
||||
count_recursive(entry.path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
||||
@@ -864,7 +882,8 @@ class ModelScanner:
|
||||
continue
|
||||
|
||||
# Recursively scan directory
|
||||
for root, _, files in os.walk(root_path, followlinks=True):
|
||||
for root, dirnames, files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
real_root = os.path.realpath(root)
|
||||
if real_root in visited_real_paths:
|
||||
continue
|
||||
@@ -1137,6 +1156,11 @@ class ModelScanner:
|
||||
hash_index = hash_index or self._hash_index
|
||||
excluded_models = excluded_models if excluded_models is not None else self._excluded_models
|
||||
|
||||
# Belt-and-braces: staged files must never become library entries even
|
||||
# if a caller invokes this method directly with a staging path.
|
||||
if _is_pending_delete_path(file_path):
|
||||
return None
|
||||
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||
|
||||
if should_skip:
|
||||
@@ -1456,6 +1480,8 @@ class ModelScanner:
|
||||
if self.is_cancelled():
|
||||
return
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
if _is_excluded_dir(entry.name):
|
||||
continue
|
||||
await scan_recursive(entry.path, root_path, visited_paths)
|
||||
except Exception as entry_error:
|
||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||
@@ -2206,6 +2232,11 @@ class ModelScanner:
|
||||
# Track deleted models to update cache once
|
||||
deleted_models = []
|
||||
|
||||
# Stage each file into the pending-delete staging area and merge
|
||||
# all per-file batches into ONE batch for the whole bulk action.
|
||||
pending_delete_service = await get_pending_delete_service()
|
||||
batch_ids: List[str] = []
|
||||
|
||||
for file_path in file_paths:
|
||||
if self.is_cancelled():
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
|
||||
@@ -2218,11 +2249,35 @@ class ModelScanner:
|
||||
base_name = os.path.basename(file_path)
|
||||
file_name, main_extension = os.path.splitext(base_name)
|
||||
|
||||
deleted_files = await delete_model_artifacts(
|
||||
target_dir,
|
||||
file_name,
|
||||
# Snapshot the cache entry BEFORE the cache mutation that
|
||||
# runs after the loop - the manifest needs it for undo.
|
||||
cached_entry = None
|
||||
if cache is not None:
|
||||
cached_entry = next(
|
||||
(item for item in cache.raw_data if item.get('file_path') == file_path),
|
||||
None,
|
||||
)
|
||||
|
||||
batch_id = await pending_delete_service.stage_model_delete(
|
||||
scanner=self,
|
||||
target_dir=target_dir,
|
||||
file_name=file_name,
|
||||
main_extension=main_extension,
|
||||
original_file_path=file_path,
|
||||
cached_entry=cached_entry,
|
||||
)
|
||||
|
||||
if batch_id is not None:
|
||||
# Artifacts were renamed into staging: the main file is
|
||||
# gone from its original location.
|
||||
batch_ids.append(batch_id)
|
||||
deleted_files = [file_path]
|
||||
else:
|
||||
deleted_files = await delete_model_artifacts(
|
||||
target_dir,
|
||||
file_name,
|
||||
main_extension=main_extension,
|
||||
)
|
||||
|
||||
if deleted_files:
|
||||
deleted_models.append(file_path)
|
||||
@@ -2246,6 +2301,18 @@ class ModelScanner:
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
# Merge every staged per-file batch into ONE undoable batch. On a
|
||||
# merge failure (cross-volume EXDEV etc.) the response falls back
|
||||
# to the constituent batch_ids array so the frontend can undo them
|
||||
# sequentially.
|
||||
batch_field: Dict[str, Any] = {}
|
||||
if batch_ids:
|
||||
merged_id = await pending_delete_service.merge_batches(batch_ids)
|
||||
if merged_id is not None:
|
||||
batch_field['batch_id'] = merged_id
|
||||
else:
|
||||
batch_field['batch_ids'] = list(batch_ids)
|
||||
|
||||
# Batch update cache if any models were deleted
|
||||
if deleted_models:
|
||||
# Update the cache in a batch operation
|
||||
@@ -2257,7 +2324,8 @@ class ModelScanner:
|
||||
'total_deleted': total_deleted,
|
||||
'total_attempted': len(file_paths),
|
||||
'cache_updated': cache_updated,
|
||||
'results': results
|
||||
'results': results,
|
||||
**batch_field
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
"""Pending-delete staging service.
|
||||
|
||||
Stages model/recipe deletes into hidden per-root staging directories so a
|
||||
30-second undo window can restore them before the physical purge runs. The
|
||||
service is the foundation for the delete-undo feature: every staged batch is
|
||||
described by a ``manifest.json`` which is the ONLY source of truth.
|
||||
|
||||
LOCK HIERARCHY (critical - asyncio.Lock is NOT re-entrant):
|
||||
``_ops_lock`` is acquired ONLY by stage_model_delete, stage_recipe_delete,
|
||||
merge_batches, undo and purge_batch. ``purge_expired()`` NEVER acquires it -
|
||||
it enumerates staging dirs and delegates each batch to ``purge_batch`` (which
|
||||
locks). The opportunistic ``await self.purge_expired()`` at the start of
|
||||
stage_*/undo MUST therefore run BEFORE those methods acquire the lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
cast,
|
||||
)
|
||||
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils import settings_paths
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Undo window in seconds before a staged batch becomes purge-eligible.
|
||||
PENDING_DELETE_TTL_SECONDS = 30
|
||||
# Hidden staging directory name placed under each model root (and the settings
|
||||
# dir for recipes).
|
||||
PENDING_DELETE_DIR_NAME = ".lm-pending-delete"
|
||||
# Manifest file name inside every batch directory.
|
||||
MANIFEST_FILE_NAME = "manifest.json"
|
||||
# Suffix appended when quarantining malformed/manifest-less batch dirs. The
|
||||
# quarantine is terminal: never re-renamed, never re-quarantined, never
|
||||
# deleted by the sweep.
|
||||
ORPHANED_SUFFIX = ".orphaned"
|
||||
|
||||
# Map scanner.model_type (singular) to the manifest page type values.
|
||||
_MODEL_TYPE_PAGE_MAP = {
|
||||
"lora": "loras",
|
||||
"checkpoint": "checkpoints",
|
||||
"embedding": "embeddings",
|
||||
}
|
||||
|
||||
# Module-level alias so tests can spy on timer task creation without patching
|
||||
# the global asyncio module.
|
||||
_create_task = asyncio.create_task
|
||||
|
||||
|
||||
class PendingDeleteService:
|
||||
"""Stage, undo and purge pending model/recipe deletions.
|
||||
|
||||
Singleton + asyncio.Lock pattern (mirrors py/services/model_scanner.py).
|
||||
"""
|
||||
|
||||
_instance: Optional["PendingDeleteService"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "PendingDeleteService":
|
||||
"""Return the lazily initialised singleton instance."""
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
# Serialises stage/merge/undo/purge_batch. purge_expired never locks.
|
||||
self._ops_lock = asyncio.Lock()
|
||||
# Track fire-and-forget purge timer tasks to keep them alive and to
|
||||
# cancel them on shutdown / singleton reset.
|
||||
self._purge_tasks: Set[Any] = set()
|
||||
# Roots the service has staged into (in-process). Combined with the
|
||||
# ServiceRegistry roots during sweeps so undo/purge work even before
|
||||
# every scanner is registered.
|
||||
self._known_roots: List[str] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
async def stage_model_delete(
|
||||
self,
|
||||
*,
|
||||
scanner: Any,
|
||||
target_dir: str,
|
||||
file_name: str,
|
||||
main_extension: Optional[str],
|
||||
original_file_path: str,
|
||||
cached_entry: Optional[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Rename a model's artifacts into a per-root staging batch.
|
||||
|
||||
Returns the batch id, or ``None`` when undo is disabled, the staging
|
||||
root cannot be resolved, or staging failed (caller falls back to a
|
||||
hard delete).
|
||||
"""
|
||||
# LOCK-FREE section: opportunistic purge must never run while holding
|
||||
# the ops lock (the lock is not re-entrant).
|
||||
await self._opportunistic_purge()
|
||||
|
||||
if not self._undo_enabled():
|
||||
return None
|
||||
|
||||
async with self._ops_lock:
|
||||
batch_dir: Optional[str] = None
|
||||
staged_pairs: List[Dict[str, Any]] = []
|
||||
try:
|
||||
root = self._find_model_root(scanner, original_file_path)
|
||||
if not root:
|
||||
logger.warning(
|
||||
"No model root contains %s; skipping staging",
|
||||
original_file_path,
|
||||
)
|
||||
return None
|
||||
|
||||
artifacts = self._enumerate_model_artifacts(
|
||||
target_dir, file_name, main_extension
|
||||
)
|
||||
if not artifacts:
|
||||
logger.warning(
|
||||
"No existing artifacts for %s; skipping staging",
|
||||
original_file_path,
|
||||
)
|
||||
return None
|
||||
|
||||
batch_id = self._new_batch_id()
|
||||
batch_dir = os.path.join(
|
||||
os.path.join(root, PENDING_DELETE_DIR_NAME), batch_id
|
||||
)
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
|
||||
staged_pairs = self._rename_artifacts_into_batch(
|
||||
batch_dir, artifacts, staged_pairs
|
||||
)
|
||||
# Attach the model snapshot to the MAIN-file entry (the one
|
||||
# whose original path is the model file itself, not the
|
||||
# metadata/preview sidecars). Merged bulk manifests therefore
|
||||
# carry EVERY deleted model's snapshot on its entry; the
|
||||
# top-level model_snapshot is kept for backward compatibility
|
||||
# and the single-delete path.
|
||||
main_abs = os.path.abspath(original_file_path)
|
||||
for entry in staged_pairs:
|
||||
if entry.get("original") == main_abs:
|
||||
entry["snapshot"] = cached_entry
|
||||
break
|
||||
manifest = self._build_manifest(
|
||||
batch_id=batch_id,
|
||||
kind="model",
|
||||
model_type=self._resolve_model_type(scanner),
|
||||
expires_at=int(time.time()) + PENDING_DELETE_TTL_SECONDS,
|
||||
entries=staged_pairs,
|
||||
model_snapshot=cached_entry,
|
||||
)
|
||||
self._write_manifest_atomic(batch_dir, manifest)
|
||||
self._remember_root(root)
|
||||
# Arm the per-batch purge timer. Safe inside the lock: task
|
||||
# creation does not await, and purge_batch re-reads the
|
||||
# manifest's expires_at at fire time, so stale timers no-op.
|
||||
self._arm_purge_timer(batch_id)
|
||||
logger.info(
|
||||
"Staged model delete batch %s with %d file(s)",
|
||||
batch_id,
|
||||
len(staged_pairs),
|
||||
)
|
||||
return batch_id
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Staging model %s failed: %s; rolling back", original_file_path, exc
|
||||
)
|
||||
if batch_dir:
|
||||
self._rollback_model_staging(batch_dir, staged_pairs)
|
||||
self._remove_empty_dir(batch_dir)
|
||||
return None
|
||||
except Exception as exc: # defensive - never block the delete flow
|
||||
logger.warning(
|
||||
"Unexpected error staging model %s: %s", original_file_path, exc
|
||||
)
|
||||
return None
|
||||
|
||||
async def stage_recipe_delete(
|
||||
self,
|
||||
*,
|
||||
recipe_json_path: str,
|
||||
image_path: Optional[str],
|
||||
recipe_data: Optional[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Copy a recipe JSON (and, when it exists, its image) into staging.
|
||||
|
||||
Returns the batch id, or ``None`` when undo is disabled / staging
|
||||
failed. Missing or shared preview images are skipped.
|
||||
"""
|
||||
await self._opportunistic_purge()
|
||||
|
||||
if not self._undo_enabled():
|
||||
return None
|
||||
|
||||
async with self._ops_lock:
|
||||
batch_dir: Optional[str] = None
|
||||
staged_pairs: List[Dict[str, Any]] = []
|
||||
try:
|
||||
json_path = os.path.abspath(os.path.normpath(recipe_json_path))
|
||||
if not os.path.exists(json_path):
|
||||
logger.warning(
|
||||
"Recipe JSON %s does not exist; skipping staging", json_path
|
||||
)
|
||||
return None
|
||||
|
||||
batch_id = self._new_batch_id()
|
||||
batch_dir = os.path.join(self._recipe_staging_parent(), batch_id)
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
|
||||
staged_pairs = self._copy_recipe_artifacts(
|
||||
batch_dir, json_path, image_path, staged_pairs
|
||||
)
|
||||
manifest = self._build_manifest(
|
||||
batch_id=batch_id,
|
||||
kind="recipe",
|
||||
model_type=None,
|
||||
expires_at=int(time.time()) + PENDING_DELETE_TTL_SECONDS,
|
||||
entries=staged_pairs,
|
||||
recipe_snapshot=recipe_data,
|
||||
)
|
||||
self._write_manifest_atomic(batch_dir, manifest)
|
||||
self._arm_purge_timer(batch_id)
|
||||
logger.info(
|
||||
"Staged recipe delete batch %s with %d file(s)",
|
||||
batch_id,
|
||||
len(staged_pairs),
|
||||
)
|
||||
return batch_id
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Staging recipe %s failed: %s; rolling back",
|
||||
recipe_json_path,
|
||||
exc,
|
||||
)
|
||||
if batch_dir:
|
||||
self._rollback_recipe_staging(batch_dir, staged_pairs)
|
||||
self._remove_empty_dir(batch_dir)
|
||||
return None
|
||||
except Exception as exc: # defensive - never block the delete flow
|
||||
logger.warning(
|
||||
"Unexpected error staging recipe %s: %s", recipe_json_path, exc
|
||||
)
|
||||
return None
|
||||
|
||||
async def merge_batches(self, batch_ids: Sequence[str]) -> Optional[str]:
|
||||
"""Merge several batches into the first batch's manifest.
|
||||
|
||||
Winner is ``batch_ids[0]``. The staged files of losing batches are
|
||||
MOVED (os.rename) into the winner's batch dir and their ``staged``
|
||||
paths rewritten in the merged manifest BEFORE any loser dir is
|
||||
removed. ``expires_at`` is re-anchored to ``now + TTL`` at merge time
|
||||
and a FRESH purge timer is armed for the winner.
|
||||
|
||||
On any move failure every already-moved file is moved BACK and the
|
||||
original batch dirs/manifests are left intact; ``None`` is returned so
|
||||
callers fall back to the ``batch_ids`` array contract. Cross-volume
|
||||
merges hit EXDEV here - expected and fine (the fallback is the normal
|
||||
path for those bulks).
|
||||
"""
|
||||
if not batch_ids:
|
||||
return None
|
||||
|
||||
async with self._ops_lock:
|
||||
winner_id = batch_ids[0]
|
||||
winner_dir = await self._find_batch_dir(winner_id)
|
||||
if not winner_dir:
|
||||
return None
|
||||
winner_manifest = self._read_manifest(winner_dir)
|
||||
if winner_manifest is None:
|
||||
return None
|
||||
|
||||
# Track (entry, original_staged_path, loser_dir) for rollback.
|
||||
moved: List[Tuple[Dict[str, Any], str, str]] = []
|
||||
processed_losers: List[str] = []
|
||||
|
||||
try:
|
||||
for loser_id in batch_ids[1:]:
|
||||
loser_dir = await self._find_batch_dir(loser_id)
|
||||
if not loser_dir or os.path.normpath(loser_dir) == os.path.normpath(
|
||||
winner_dir
|
||||
):
|
||||
continue
|
||||
loser_manifest = self._read_manifest(loser_dir)
|
||||
if loser_manifest is None:
|
||||
# Corrupted loser: leave it for the sweep to quarantine.
|
||||
continue
|
||||
for entry in loser_manifest.get("entries") or []:
|
||||
if entry.get("restored"):
|
||||
continue
|
||||
staged_path = entry.get("staged")
|
||||
if not staged_path or not os.path.exists(staged_path):
|
||||
continue
|
||||
new_staged = os.path.join(
|
||||
winner_dir, os.path.basename(staged_path)
|
||||
)
|
||||
if os.path.exists(new_staged):
|
||||
# os.rename would silently overwrite the existing
|
||||
# staged file on POSIX - never drop a staged file.
|
||||
# Abort the merge so callers fall back to the
|
||||
# batch_ids array contract.
|
||||
raise OSError(
|
||||
f"Merge collision: {os.path.basename(staged_path)} "
|
||||
"already staged in winner batch"
|
||||
)
|
||||
os.rename(staged_path, new_staged)
|
||||
original_staged = entry["staged"]
|
||||
entry["staged"] = os.path.abspath(new_staged)
|
||||
winner_manifest["entries"].append(entry)
|
||||
moved.append((entry, original_staged, loser_dir))
|
||||
processed_losers.append(loser_dir)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Merge of %s failed after moving files: %s; rolling back",
|
||||
list(batch_ids),
|
||||
exc,
|
||||
)
|
||||
self._rollback_merge_moves(moved)
|
||||
return None
|
||||
|
||||
# Re-anchor expiry and persist the merged manifest atomically.
|
||||
winner_manifest["expires_at"] = (
|
||||
int(time.time()) + PENDING_DELETE_TTL_SECONDS
|
||||
)
|
||||
try:
|
||||
self._write_manifest_atomic(winner_dir, winner_manifest)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to write merged manifest for %s: %s; rolling back",
|
||||
winner_id,
|
||||
exc,
|
||||
)
|
||||
self._rollback_merge_moves(moved)
|
||||
return None
|
||||
|
||||
# All moves committed: remove loser dirs (must be empty by now).
|
||||
for loser_dir in processed_losers:
|
||||
self._remove_manifest(loser_dir)
|
||||
self._remove_empty_dir(loser_dir)
|
||||
|
||||
# Arm a fresh purge timer for the winner with the re-anchored
|
||||
# expiry (the winner's original timer fires at the OLD expiry and
|
||||
# no-ops after re-reading the manifest - without this fresh timer
|
||||
# an idle server would never purge the merged batch).
|
||||
self._arm_purge_timer(winner_id)
|
||||
logger.info("Merged batches %s into %s", list(batch_ids), winner_id)
|
||||
return winner_id
|
||||
|
||||
async def undo(self, batch_id: str) -> Dict[str, Any]:
|
||||
"""Restore every staged file of a batch to its original path.
|
||||
|
||||
Raises ``ValueError`` for unknown batches, expired batches ("Undo
|
||||
window expired") and occupied target paths ("Target path occupied").
|
||||
Restores entries one at a time, persisting the manifest after each, so
|
||||
a mid-undo failure leaves a retry-able state.
|
||||
"""
|
||||
await self._opportunistic_purge()
|
||||
|
||||
async with self._ops_lock:
|
||||
batch_dir = await self._find_batch_dir(batch_id)
|
||||
if not batch_dir:
|
||||
raise ValueError(f"Unknown batch id: {batch_id}")
|
||||
manifest = self._read_manifest(batch_dir)
|
||||
if manifest is None:
|
||||
raise ValueError(f"Manifest missing for batch {batch_id}")
|
||||
|
||||
if manifest.get("state") == "restored":
|
||||
return self._undo_result(manifest)
|
||||
|
||||
now = time.time()
|
||||
expires_at = manifest.get("expires_at")
|
||||
if isinstance(expires_at, (int, float)) and expires_at < now:
|
||||
raise ValueError("Undo window expired")
|
||||
|
||||
entries = manifest.get("entries") or []
|
||||
|
||||
# Pre-check ALL target paths (except already-restored entries) so
|
||||
# an occupied original path protects the new file and leaves the
|
||||
# whole batch intact.
|
||||
for entry in entries:
|
||||
if entry.get("restored"):
|
||||
continue
|
||||
original_path = entry.get("original")
|
||||
if original_path and os.path.exists(original_path):
|
||||
raise ValueError("Target path occupied")
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("restored"):
|
||||
continue
|
||||
staged_path = entry.get("staged")
|
||||
original_path = entry.get("original")
|
||||
if not staged_path or not original_path:
|
||||
entry["restored"] = True
|
||||
continue
|
||||
if not os.path.exists(staged_path):
|
||||
# Staged file already gone (purged or manually removed):
|
||||
# treat as restored and finish the rest of the batch.
|
||||
entry["restored"] = True
|
||||
self._write_manifest_atomic(batch_dir, manifest)
|
||||
continue
|
||||
self._restore_file(staged_path, original_path)
|
||||
entry["restored"] = True
|
||||
# Persist after each entry so a mid-undo failure is retry-able.
|
||||
self._write_manifest_atomic(batch_dir, manifest)
|
||||
|
||||
manifest["state"] = "restored"
|
||||
try:
|
||||
self._write_manifest_atomic(batch_dir, manifest)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to mark manifest restored for %s: %s", batch_id, exc
|
||||
)
|
||||
|
||||
# Remove the manifest + batch dir only after all entries restored.
|
||||
self._remove_manifest(batch_dir)
|
||||
self._remove_empty_dir(batch_dir)
|
||||
|
||||
logger.info("Restored pending-delete batch %s", batch_id)
|
||||
return self._undo_result(manifest)
|
||||
|
||||
async def purge_expired(self) -> int:
|
||||
"""Purge every expired batch across ALL model roots and the recipe dir.
|
||||
|
||||
Lock-free by design: enumerates staging parents (all scanner types via
|
||||
the ServiceRegistry plus the global recipe staging dir) and delegates
|
||||
each batch to :meth:`purge_batch`, which acquires the ops lock. Never
|
||||
call this while holding the ops lock.
|
||||
"""
|
||||
purged = 0
|
||||
for parent in await self._get_all_staging_parents():
|
||||
if not os.path.isdir(parent):
|
||||
continue
|
||||
for name in self._list_dir_names(parent):
|
||||
if name.endswith(ORPHANED_SUFFIX):
|
||||
# Quarantine is terminal - never re-rename or delete.
|
||||
continue
|
||||
try:
|
||||
await self.purge_batch(name)
|
||||
purged += 1
|
||||
except Exception as exc: # defensive - sweep must not crash
|
||||
logger.warning("Failed to purge batch %s: %s", name, exc)
|
||||
return purged
|
||||
|
||||
async def purge_batch(self, batch_id: str) -> None:
|
||||
"""Purge one batch. Silent no-op for missing/undone/not-yet-expired.
|
||||
|
||||
Missing staged files (already-restored / partially-restored batches)
|
||||
are treated as already-purged. A per-file purge failure (locked file)
|
||||
skips only that file and keeps the batch dir for the next round.
|
||||
"""
|
||||
async with self._ops_lock:
|
||||
batch_dir = await self._find_batch_dir(batch_id)
|
||||
if not batch_dir:
|
||||
return
|
||||
self._purge_batch_dir(batch_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
async def _opportunistic_purge(self) -> None:
|
||||
"""Fire the opportunistic sweep. Cheap when empty; never locked."""
|
||||
try:
|
||||
await self.purge_expired()
|
||||
except Exception as exc: # defensive - staging/undo must still proceed
|
||||
logger.warning("Opportunistic pending-delete purge failed: %s", exc)
|
||||
|
||||
def _undo_enabled(self) -> bool:
|
||||
try:
|
||||
return bool(get_settings_manager().get("delete_undo_enabled", True))
|
||||
except Exception as exc: # defensive - default to enabled
|
||||
logger.warning("Failed to read delete_undo_enabled setting: %s", exc)
|
||||
return True
|
||||
|
||||
def _remember_root(self, root: str) -> None:
|
||||
"""Record a root the service has staged into (in-process registry)."""
|
||||
if root and root not in self._known_roots:
|
||||
self._known_roots.append(root)
|
||||
|
||||
def _find_model_root(self, scanner: Any, original_file_path: Optional[str]) -> Optional[str]:
|
||||
"""Return the configured root containing ``original_file_path``."""
|
||||
finder = getattr(scanner, "_find_root_for_file", None)
|
||||
if callable(finder):
|
||||
try:
|
||||
root = cast(Optional[str], finder(original_file_path))
|
||||
if root:
|
||||
return os.path.abspath(root)
|
||||
except Exception as exc: # defensive - fall back to roots scan
|
||||
logger.debug("_find_root_for_file failed: %s", exc)
|
||||
|
||||
if not original_file_path:
|
||||
return None
|
||||
roots_getter = getattr(scanner, "get_model_roots", None)
|
||||
if not callable(roots_getter):
|
||||
return None
|
||||
try:
|
||||
normalized = os.path.abspath(os.path.normpath(original_file_path))
|
||||
for root in cast(Sequence[str], roots_getter()) or []:
|
||||
root_abs = os.path.abspath(os.path.normpath(root))
|
||||
if normalized == root_abs or normalized.startswith(root_abs + os.sep):
|
||||
return root_abs
|
||||
except Exception as exc: # defensive - never block the delete flow
|
||||
logger.debug("get_model_roots fallback failed: %s", exc)
|
||||
return None
|
||||
|
||||
def _resolve_model_type(self, scanner: Any) -> Optional[str]:
|
||||
raw = getattr(scanner, "model_type", None)
|
||||
if not raw:
|
||||
return None
|
||||
return _MODEL_TYPE_PAGE_MAP.get(raw, raw)
|
||||
|
||||
def _enumerate_model_artifacts(
|
||||
self, target_dir: str, file_name: str, main_extension: Optional[str]
|
||||
) -> List[str]:
|
||||
"""Enumerate existing artifacts exactly like delete_model_artifacts."""
|
||||
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{file_name}{ext}")
|
||||
|
||||
artifacts: List[str] = []
|
||||
for pattern in patterns:
|
||||
path = os.path.abspath(os.path.join(target_dir, pattern))
|
||||
if os.path.exists(path):
|
||||
artifacts.append(path)
|
||||
return artifacts
|
||||
|
||||
def _rename_artifacts_into_batch(
|
||||
self,
|
||||
batch_dir: str,
|
||||
artifacts: Sequence[str],
|
||||
staged_pairs: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Rename artifacts into the batch dir, recording progress per file.
|
||||
|
||||
Progress is appended to ``staged_pairs`` before the next move so a
|
||||
mid-way OSError leaves the caller with the already-moved files for
|
||||
rollback.
|
||||
"""
|
||||
for original_path in artifacts:
|
||||
staged_path = os.path.join(batch_dir, os.path.basename(original_path))
|
||||
os.rename(original_path, staged_path)
|
||||
staged_pairs.append(
|
||||
{
|
||||
"staged": os.path.abspath(staged_path),
|
||||
"original": os.path.abspath(original_path),
|
||||
"restored": False,
|
||||
}
|
||||
)
|
||||
return staged_pairs
|
||||
|
||||
def _copy_recipe_artifacts(
|
||||
self,
|
||||
batch_dir: str,
|
||||
json_path: str,
|
||||
image_path: Optional[str],
|
||||
staged_pairs: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Copy the recipe JSON and, when it exists, the image into the batch."""
|
||||
staged_json = os.path.join(batch_dir, os.path.basename(json_path))
|
||||
shutil.copy2(json_path, staged_json)
|
||||
staged_pairs.append(
|
||||
{
|
||||
"staged": os.path.abspath(staged_json),
|
||||
"original": json_path,
|
||||
"restored": False,
|
||||
}
|
||||
)
|
||||
if image_path:
|
||||
image_abs = os.path.abspath(os.path.normpath(image_path))
|
||||
if os.path.exists(image_abs):
|
||||
staged_image = os.path.join(batch_dir, os.path.basename(image_abs))
|
||||
shutil.copy2(image_abs, staged_image)
|
||||
staged_pairs.append(
|
||||
{
|
||||
"staged": os.path.abspath(staged_image),
|
||||
"original": image_abs,
|
||||
"restored": False,
|
||||
}
|
||||
)
|
||||
return staged_pairs
|
||||
|
||||
def _restore_file(self, staged_path: str, original_path: str) -> None:
|
||||
"""Restore a staged file to its original path, tolerating EXDEV.
|
||||
|
||||
``os.rename`` is atomic and preferred (model staging and most recipe
|
||||
restores are same-volume). Recipe staging copies into the settings-dir
|
||||
staging parent, which may live on a DIFFERENT filesystem than the
|
||||
recipes dir; rename then raises EXDEV. Fall back to ``shutil.copy2`` +
|
||||
``os.remove`` so the bytes are restored and the staged copy removed.
|
||||
"""
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EXDEV:
|
||||
raise
|
||||
shutil.copy2(staged_path, original_path)
|
||||
os.remove(staged_path)
|
||||
|
||||
def _rollback_model_staging(
|
||||
self, batch_dir: str, staged_pairs: Sequence[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Rename already-staged files back to their originals."""
|
||||
for pair in reversed(list(staged_pairs)):
|
||||
staged_path = pair.get("staged")
|
||||
original_path = pair.get("original")
|
||||
if not staged_path or not original_path:
|
||||
continue
|
||||
if not os.path.exists(staged_path):
|
||||
continue
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
except OSError as exc: # pragma: no cover - best-effort rollback
|
||||
logger.warning(
|
||||
"Failed to roll back staged file %s -> %s: %s",
|
||||
staged_path,
|
||||
original_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
def _rollback_recipe_staging(
|
||||
self, batch_dir: str, staged_pairs: Sequence[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Remove staged copies (recipe originals were never moved)."""
|
||||
for pair in staged_pairs:
|
||||
staged_path = pair.get("staged")
|
||||
if not staged_path:
|
||||
continue
|
||||
try:
|
||||
if os.path.exists(staged_path):
|
||||
os.remove(staged_path)
|
||||
except OSError as exc: # pragma: no cover - best-effort rollback
|
||||
logger.warning(
|
||||
"Failed to remove staged copy %s: %s", staged_path, exc
|
||||
)
|
||||
|
||||
def _rollback_merge_moves(
|
||||
self, moved: Sequence[Tuple[Dict[str, Any], str, str]]
|
||||
) -> None:
|
||||
"""Move already-merged files back to their original loser batch dirs."""
|
||||
for _entry, original_staged, _loser_dir in reversed(list(moved)):
|
||||
current = _entry.get("staged")
|
||||
if not current or not original_staged:
|
||||
continue
|
||||
if not os.path.exists(current):
|
||||
continue
|
||||
try:
|
||||
os.rename(current, original_staged)
|
||||
except OSError as exc: # pragma: no cover - best-effort rollback
|
||||
logger.warning(
|
||||
"Failed to roll back merge move %s -> %s: %s",
|
||||
current,
|
||||
original_staged,
|
||||
exc,
|
||||
)
|
||||
|
||||
def _purge_batch_dir(self, batch_dir: str) -> bool:
|
||||
"""Purge one batch dir. Returns True when the batch was purged/removed."""
|
||||
if not os.path.isdir(batch_dir):
|
||||
return False
|
||||
|
||||
manifest = self._read_manifest(batch_dir)
|
||||
if manifest is None:
|
||||
# Corrupted or manifest-less batch: quarantine, NEVER delete the
|
||||
# staged files (they may be the only copy of the user's data).
|
||||
self._quarantine_batch_dir(batch_dir)
|
||||
return True
|
||||
|
||||
if manifest.get("state") == "restored":
|
||||
return False
|
||||
|
||||
expires_at = manifest.get("expires_at")
|
||||
if not isinstance(expires_at, (int, float)) or expires_at >= time.time():
|
||||
# Not yet expired - stale timers from merged-away/undone batches
|
||||
# are harmless.
|
||||
return False
|
||||
|
||||
entries = manifest.get("entries") or []
|
||||
remaining: List[Dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
staged_path = entry.get("staged")
|
||||
if not staged_path or not os.path.exists(staged_path):
|
||||
# Missing staged file = already restored / already purged.
|
||||
continue
|
||||
try:
|
||||
os.remove(staged_path)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Skipping locked staged file %s: %s", staged_path, exc
|
||||
)
|
||||
remaining.append(entry)
|
||||
|
||||
if remaining:
|
||||
# Never remove the batch dir past per-file errors; the batch is
|
||||
# retried by the next opportunistic purge.
|
||||
return False
|
||||
|
||||
self._remove_manifest(batch_dir)
|
||||
self._remove_empty_dir(batch_dir)
|
||||
return True
|
||||
|
||||
def _quarantine_batch_dir(self, batch_dir: str) -> str:
|
||||
"""Rename a malformed batch dir to ``<batch_id>.orphaned`` (terminal)."""
|
||||
orphaned_dir = f"{batch_dir}{ORPHANED_SUFFIX}"
|
||||
if os.path.exists(orphaned_dir):
|
||||
orphaned_dir = f"{batch_dir}-{int(time.time())}{ORPHANED_SUFFIX}"
|
||||
try:
|
||||
os.rename(batch_dir, orphaned_dir)
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.warning("Failed to quarantine %s: %s", batch_dir, exc)
|
||||
return batch_dir
|
||||
logger.warning(
|
||||
"Quarantined malformed/manifest-less pending-delete batch %s",
|
||||
os.path.basename(batch_dir),
|
||||
)
|
||||
return orphaned_dir
|
||||
|
||||
def _build_manifest(
|
||||
self,
|
||||
*,
|
||||
batch_id: str,
|
||||
kind: str,
|
||||
model_type: Optional[str],
|
||||
expires_at: int,
|
||||
entries: Sequence[Dict[str, Any]],
|
||||
model_snapshot: Any = None,
|
||||
recipe_snapshot: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
is_model = kind == "model"
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"kind": kind,
|
||||
"model_type": model_type if is_model else None,
|
||||
"state": "staged",
|
||||
"expires_at": int(expires_at),
|
||||
"entries": list(entries),
|
||||
"model_snapshot": model_snapshot if is_model else None,
|
||||
"recipe_snapshot": recipe_snapshot if not is_model else None,
|
||||
}
|
||||
|
||||
def _undo_result(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
|
||||
restored_paths = [
|
||||
entry["original"]
|
||||
for entry in manifest.get("entries") or []
|
||||
if entry.get("restored") and entry.get("original")
|
||||
]
|
||||
return {
|
||||
"batch_id": manifest.get("batch_id"),
|
||||
"kind": manifest.get("kind"),
|
||||
"model_type": manifest.get("model_type"),
|
||||
"restored": restored_paths,
|
||||
}
|
||||
|
||||
def _write_manifest_atomic(
|
||||
self, batch_dir: str, manifest: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Write manifest.json atomically (temp file + os.replace)."""
|
||||
manifest_path = os.path.join(batch_dir, MANIFEST_FILE_NAME)
|
||||
fd, temp_path = tempfile.mkstemp(
|
||||
dir=batch_dir, prefix=".manifest-", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(manifest, handle, indent=2, ensure_ascii=False)
|
||||
os.replace(temp_path, manifest_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _read_manifest(self, batch_dir: str) -> Optional[Dict[str, Any]]:
|
||||
manifest_path = os.path.join(batch_dir, MANIFEST_FILE_NAME)
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning(
|
||||
"Corrupted pending-delete manifest at %s: %s", manifest_path, exc
|
||||
)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("Invalid pending-delete manifest at %s", manifest_path)
|
||||
return None
|
||||
return payload
|
||||
|
||||
def _recipe_staging_parent(self) -> str:
|
||||
# Resolve through the module namespace so the conftest settings-dir
|
||||
# isolation patch takes effect at call time.
|
||||
return os.path.join(
|
||||
settings_paths.get_settings_dir(create=True), PENDING_DELETE_DIR_NAME
|
||||
)
|
||||
|
||||
async def _get_all_staging_parents(self) -> List[str]:
|
||||
"""Model staging parents for every scanner type + the recipe parent."""
|
||||
parents: List[str] = []
|
||||
for root in await self._get_all_model_roots():
|
||||
parent = os.path.join(root, PENDING_DELETE_DIR_NAME)
|
||||
if parent not in parents:
|
||||
parents.append(parent)
|
||||
recipe_parent = self._recipe_staging_parent()
|
||||
if recipe_parent not in parents:
|
||||
parents.append(recipe_parent)
|
||||
return parents
|
||||
|
||||
async def _get_all_model_roots(self) -> List[str]:
|
||||
"""Collect every configured model root across all scanner types.
|
||||
|
||||
Combines the in-process staging roots with the ServiceRegistry's
|
||||
per-type scanners so sweeps cover every scanner type while undo/purge
|
||||
still resolve batches staged before the registry was populated.
|
||||
"""
|
||||
from .service_registry import ServiceRegistry
|
||||
|
||||
roots: List[str] = []
|
||||
for root in self._known_roots:
|
||||
if root and root not in roots:
|
||||
roots.append(root)
|
||||
for getter_name in (
|
||||
"get_lora_scanner",
|
||||
"get_checkpoint_scanner",
|
||||
"get_embedding_scanner",
|
||||
):
|
||||
getter = getattr(ServiceRegistry, getter_name, None)
|
||||
if not callable(getter):
|
||||
continue
|
||||
try:
|
||||
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
|
||||
except Exception as exc: # defensive - keep sweeping other types
|
||||
logger.debug(
|
||||
"Failed to resolve %s for purge enumeration: %s",
|
||||
getter_name,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if scanner is None:
|
||||
continue
|
||||
get_roots = getattr(scanner, "get_model_roots", None)
|
||||
if not callable(get_roots):
|
||||
continue
|
||||
try:
|
||||
scanner_roots = cast(Sequence[Any], get_roots())
|
||||
except Exception as exc: # defensive
|
||||
logger.debug(
|
||||
"get_model_roots failed for %s: %s", getter_name, exc
|
||||
)
|
||||
continue
|
||||
for root in scanner_roots or []:
|
||||
if root and root not in roots:
|
||||
roots.append(root)
|
||||
return roots
|
||||
|
||||
async def _find_batch_dir(self, batch_id: str) -> Optional[str]:
|
||||
"""Locate a batch directory across every staging parent."""
|
||||
if not batch_id:
|
||||
return None
|
||||
for parent in await self._get_all_staging_parents():
|
||||
candidate = os.path.join(parent, batch_id)
|
||||
if os.path.isdir(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _list_dir_names(self, parent: str) -> List[str]:
|
||||
try:
|
||||
return [
|
||||
name
|
||||
for name in os.listdir(parent)
|
||||
if os.path.isdir(os.path.join(parent, name))
|
||||
]
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.debug("Failed to list staging parent %s: %s", parent, exc)
|
||||
return []
|
||||
|
||||
def _remove_manifest(self, batch_dir: str) -> None:
|
||||
try:
|
||||
os.remove(os.path.join(batch_dir, MANIFEST_FILE_NAME))
|
||||
except OSError as exc: # pragma: no cover - best-effort
|
||||
logger.debug("Failed to remove manifest in %s: %s", batch_dir, exc)
|
||||
|
||||
def _remove_empty_dir(self, directory: str) -> None:
|
||||
try:
|
||||
os.rmdir(directory)
|
||||
except OSError as exc:
|
||||
logger.debug("Directory %s not empty or missing: %s", directory, exc)
|
||||
|
||||
def _new_batch_id(self) -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def _arm_purge_timer(self, batch_id: str) -> None:
|
||||
"""Spawn a fire-and-forget purge timer for a batch.
|
||||
|
||||
The timer sleeps until the batch's current expiry and then calls
|
||||
purge_batch, which re-reads the manifest's ``expires_at`` at fire time
|
||||
so merged-away/undone/not-yet-expired batches are silent no-ops.
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
task = _create_task(
|
||||
self._purge_batch_after_ttl(batch_id),
|
||||
name=f"pending_delete_purge_{batch_id}",
|
||||
)
|
||||
self._purge_tasks.add(task)
|
||||
task.add_done_callback(self._purge_tasks.discard)
|
||||
|
||||
async def _purge_batch_after_ttl(self, batch_id: str) -> None:
|
||||
try:
|
||||
delay = await self._seconds_until_expiry(batch_id)
|
||||
if delay is None:
|
||||
return
|
||||
await asyncio.sleep(max(0.0, delay))
|
||||
await self.purge_batch(batch_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # defensive - a timer must never crash the loop
|
||||
logger.warning("Pending-delete purge timer for %s failed: %s", batch_id, exc)
|
||||
|
||||
async def _seconds_until_expiry(self, batch_id: str) -> Optional[float]:
|
||||
batch_dir = await self._find_batch_dir(batch_id)
|
||||
if not batch_dir:
|
||||
return None
|
||||
manifest = self._read_manifest(batch_dir)
|
||||
if manifest is None:
|
||||
return None
|
||||
expires_at = manifest.get("expires_at")
|
||||
if not isinstance(expires_at, (int, float)):
|
||||
return None
|
||||
return float(expires_at) - time.time()
|
||||
|
||||
def _cancel_purge_tasks(self) -> None:
|
||||
for task in list(self._purge_tasks):
|
||||
task.cancel()
|
||||
self._purge_tasks.clear()
|
||||
|
||||
|
||||
def _reset_pending_delete_service() -> None:
|
||||
"""Reset the singleton and cancel in-flight purge timers (tests/shutdown)."""
|
||||
instance = PendingDeleteService._instance
|
||||
if instance is not None:
|
||||
instance._cancel_purge_tasks()
|
||||
PendingDeleteService._instance = None
|
||||
|
||||
|
||||
async def get_pending_delete_service() -> PendingDeleteService:
|
||||
"""Return the lazily initialised global :class:`PendingDeleteService`."""
|
||||
return await PendingDeleteService.get_instance()
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Awaitable, Dict, Iterable, Optional, cast
|
||||
from ...config import config
|
||||
from ...recipes.constants import GEN_PARAM_KEYS
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ..pending_delete_service import get_pending_delete_service
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
|
||||
|
||||
@@ -201,12 +202,31 @@ class RecipePersistenceService:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
|
||||
# Stage the delete so the recipe can be undone within the undo window.
|
||||
# The staging service COPIES the JSON (and existing image) into the
|
||||
# global staging dir and stores recipe_data as the manifest snapshot;
|
||||
# the originals are removed below as before. When staging is skipped
|
||||
# (undo disabled / staging failure) the existing hard delete runs.
|
||||
pending_delete_service = await get_pending_delete_service()
|
||||
batch_id = await pending_delete_service.stage_recipe_delete(
|
||||
recipe_json_path=recipe_json_path,
|
||||
image_path=image_path,
|
||||
recipe_data=recipe_data,
|
||||
)
|
||||
|
||||
os.remove(recipe_json_path)
|
||||
if image_path and os.path.exists(image_path):
|
||||
os.remove(image_path)
|
||||
|
||||
await recipe_scanner.remove_recipe(recipe_id)
|
||||
return PersistenceResult({"success": True, "message": "Recipe deleted successfully"})
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Recipe deleted successfully",
|
||||
"batch_id": batch_id,
|
||||
}
|
||||
)
|
||||
|
||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||
"""Update persisted metadata for a recipe."""
|
||||
@@ -450,6 +470,9 @@ class RecipePersistenceService:
|
||||
|
||||
deleted_recipes: list[str] = []
|
||||
failed_recipes: list[dict[str, Any]] = []
|
||||
batch_ids: list[str] = []
|
||||
|
||||
pending_delete_service = await get_pending_delete_service()
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
@@ -461,6 +484,17 @@ class RecipePersistenceService:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
image_path = recipe_data.get("file_path")
|
||||
|
||||
# Stage each recipe into its own batch; collect the ids so the
|
||||
# whole bulk action can be merged into ONE undoable batch.
|
||||
batch_id = await pending_delete_service.stage_recipe_delete(
|
||||
recipe_json_path=recipe_json_path,
|
||||
image_path=image_path,
|
||||
recipe_data=recipe_data,
|
||||
)
|
||||
if batch_id:
|
||||
batch_ids.append(batch_id)
|
||||
|
||||
os.remove(recipe_json_path)
|
||||
if image_path and os.path.exists(image_path):
|
||||
os.remove(image_path)
|
||||
@@ -471,15 +505,27 @@ class RecipePersistenceService:
|
||||
if deleted_recipes:
|
||||
await recipe_scanner.bulk_remove(deleted_recipes)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"deleted": deleted_recipes,
|
||||
"failed": failed_recipes,
|
||||
"total_deleted": len(deleted_recipes),
|
||||
"total_failed": len(failed_recipes),
|
||||
}
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"deleted": deleted_recipes,
|
||||
"failed": failed_recipes,
|
||||
"total_deleted": len(deleted_recipes),
|
||||
"total_failed": len(failed_recipes),
|
||||
}
|
||||
|
||||
if batch_ids:
|
||||
merged_batch_id = await pending_delete_service.merge_batches(batch_ids)
|
||||
if merged_batch_id:
|
||||
# Merge succeeded: one undo action covers the whole bulk.
|
||||
payload["batch_id"] = merged_batch_id
|
||||
else:
|
||||
# Merge failure (e.g. cross-volume move): expose the constituent
|
||||
# batches so the caller can undo them one at a time.
|
||||
payload["batch_ids"] = batch_ids
|
||||
else:
|
||||
payload["batch_id"] = None
|
||||
|
||||
return PersistenceResult(payload)
|
||||
|
||||
async def save_recipe_from_widget(
|
||||
self,
|
||||
|
||||
@@ -111,6 +111,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
"group_by_model": False,
|
||||
"delete_undo_enabled": True,
|
||||
# AI / LLM provider configuration (BYOK)
|
||||
"llm_provider": "openai", # "openai" | "ollama" | "custom"
|
||||
"llm_api_key": "",
|
||||
|
||||
@@ -55,6 +55,32 @@ class MetadataManager:
|
||||
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
|
||||
return None, True # should_skip = True
|
||||
|
||||
@staticmethod
|
||||
def _fill_local_file_facts(payload: Dict[str, Any], file_path: str) -> None:
|
||||
"""Fill missing local file facts (``file_name``/``size``/``modified``) from disk.
|
||||
|
||||
These three fields are part of the required metadata schema but describe
|
||||
the local file, not remote metadata. Payloads rebuilt by the self-heal
|
||||
refresh flow (sidecar deleted, then recreated from remote data) lack
|
||||
them, which makes the recreated sidecar unparseable by
|
||||
``BaseModelMetadata.from_dict`` and causes the scanner to skip the model.
|
||||
Fill them from the actual file whenever absent.
|
||||
"""
|
||||
if not file_path:
|
||||
return
|
||||
if payload.get("file_name") and "size" in payload and "modified" in payload:
|
||||
return
|
||||
try:
|
||||
stat_result = os.stat(file_path)
|
||||
except OSError:
|
||||
return
|
||||
if not payload.get("file_name"):
|
||||
payload["file_name"] = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if "size" not in payload:
|
||||
payload["size"] = stat_result.st_size
|
||||
if "modified" not in payload:
|
||||
payload["modified"] = stat_result.st_mtime
|
||||
|
||||
@staticmethod
|
||||
async def load_metadata_payload(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -96,6 +122,11 @@ class MetadataManager:
|
||||
|
||||
if file_path:
|
||||
payload.setdefault("file_path", normalize_path(file_path))
|
||||
# Required schema fields that are local filesystem facts. When the
|
||||
# sidecar is missing (e.g. deleted and being recreated by the
|
||||
# self-heal refresh flow), restore them so the recreated sidecar
|
||||
# and cache entries stay parseable.
|
||||
MetadataManager._fill_local_file_facts(payload, file_path)
|
||||
|
||||
return payload
|
||||
|
||||
@@ -104,6 +135,14 @@ class MetadataManager:
|
||||
"""
|
||||
Replace the provided model data with the authoritative payload from disk.
|
||||
Preserves the cached folder entry if present.
|
||||
|
||||
When the sidecar is missing entirely (self-heal after manual deletion),
|
||||
the disk payload is nearly empty and the cache snapshot is the only
|
||||
source for the schema fields required by ``BaseModelMetadata.from_dict``
|
||||
(file_name/model_name/size/modified/sha256/base_model/preview_url), so
|
||||
every missing key is restored from it to keep any recreated sidecar
|
||||
parseable and avoid data loss on failed refreshes. When the sidecar
|
||||
exists, disk data stays authoritative and no cache key is resurrected.
|
||||
"""
|
||||
|
||||
file_path = model_data.get("file_path")
|
||||
@@ -111,12 +150,29 @@ class MetadataManager:
|
||||
return model_data
|
||||
|
||||
folder = model_data.get("folder")
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
sidecar_exists = os.path.exists(metadata_path)
|
||||
cached = model_data.copy()
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
if folder is not None:
|
||||
payload["folder"] = folder
|
||||
|
||||
model_data.clear()
|
||||
model_data.update(payload)
|
||||
|
||||
if not sidecar_exists:
|
||||
for key, value in cached.items():
|
||||
if key not in model_data and key != "folder":
|
||||
model_data[key] = value
|
||||
# The schema defines `modified` as the import timestamp; keep the
|
||||
# cache's value over the stat-derived fallback from
|
||||
# load_metadata_payload.
|
||||
if "modified" in cached:
|
||||
model_data["modified"] = cached["modified"]
|
||||
|
||||
# file_name/size are local file facts; prefer fresh stat values over
|
||||
# the possibly stale cache snapshot.
|
||||
MetadataManager._fill_local_file_facts(model_data, file_path)
|
||||
return model_data
|
||||
|
||||
@staticmethod
|
||||
@@ -155,7 +211,12 @@ class MetadataManager:
|
||||
metadata_dict['file_path'] = normalize_path(metadata_dict['file_path'])
|
||||
if 'preview_url' in metadata_dict:
|
||||
metadata_dict['preview_url'] = normalize_path(metadata_dict['preview_url'])
|
||||
|
||||
|
||||
# Local file facts are required schema fields; fill them when a
|
||||
# payload rebuilt without them (e.g. self-heal) is being persisted.
|
||||
if metadata_dict.get("file_path"):
|
||||
MetadataManager._fill_local_file_facts(metadata_dict, metadata_dict["file_path"])
|
||||
|
||||
# Write to temporary file first
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata_dict, f, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Awaitable, Dict, Set, cast
|
||||
|
||||
from ..config import config
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.model_scanner import _is_excluded_dir
|
||||
from ..utils.settings_paths import get_settings_dir
|
||||
|
||||
# Check if running in standalone mode
|
||||
@@ -421,7 +422,8 @@ class UsageStats:
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
||||
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
for filename in filenames:
|
||||
extension = os.path.splitext(filename)[1].lower()
|
||||
if extension not in supported_extensions:
|
||||
|
||||
@@ -339,6 +339,7 @@ class StandaloneLoraManager(LoraManager):
|
||||
from py.routes.recipe_routes import RecipeRoutes
|
||||
from py.routes.update_routes import UpdateRoutes
|
||||
from py.routes.misc_routes import MiscRoutes
|
||||
from py.routes.pending_delete_routes import PendingDeleteRoutes
|
||||
from py.routes.example_images_routes import ExampleImagesRoutes
|
||||
from py.routes.preview_routes import PreviewRoutes
|
||||
from py.routes.stats_routes import StatsRoutes
|
||||
@@ -356,6 +357,7 @@ class StandaloneLoraManager(LoraManager):
|
||||
RecipeRoutes.setup_routes(app)
|
||||
UpdateRoutes.setup_routes(app)
|
||||
MiscRoutes.setup_routes(app)
|
||||
PendingDeleteRoutes.setup_routes(app)
|
||||
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||
PreviewRoutes.setup_routes(app)
|
||||
|
||||
|
||||
@@ -80,6 +80,33 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Action toast: ghost action button + countdown (e.g. Undo delete) */
|
||||
.toast-action-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
border: 1px solid var(--lora-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-action-btn:hover {
|
||||
background: var(--lora-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast-countdown {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75em;
|
||||
opacity: 0.65;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.toast {
|
||||
|
||||
@@ -201,8 +201,13 @@ export class BaseModelApiClient {
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(filePath);
|
||||
}
|
||||
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||
return true;
|
||||
const batchId = data.batch_id || null;
|
||||
if (!batchId) {
|
||||
// Not staged (undo disabled or staging failed): keep the legacy toast.
|
||||
// When staged, the caller shows the undo action toast instead.
|
||||
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||
}
|
||||
return { success: true, batch_id: batchId };
|
||||
} else {
|
||||
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
||||
}
|
||||
@@ -1622,9 +1627,14 @@ export class BaseModelApiClient {
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
deleted_count: result.deleted_count,
|
||||
deleted_count: result.deleted_count ?? result.total_deleted,
|
||||
failed_count: result.failed_count || 0,
|
||||
errors: result.errors || []
|
||||
errors: result.errors || [],
|
||||
// Undo batch fields — batch_id on merge success, batch_ids
|
||||
// array on merge failure (same success dict for the
|
||||
// status='cancelled' staged-subset path)
|
||||
batch_id: result.batch_id || null,
|
||||
batch_ids: result.batch_ids || null
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||
|
||||
@@ -657,6 +657,10 @@ export class RecipeSidebarApiClient {
|
||||
deleted_count: result.total_deleted,
|
||||
failed_count: result.total_failed || 0,
|
||||
errors: result.failed || [],
|
||||
// Undo batch fields — batch_id on merge success, batch_ids
|
||||
// array on merge failure
|
||||
batch_id: result.batch_id || null,
|
||||
batch_ids: result.batch_ids || null,
|
||||
};
|
||||
} finally {
|
||||
state.loadingManager?.hide();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Duplicates Manager Component
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { RecipeCard } from './RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
@@ -447,6 +449,7 @@ export class DuplicatesManager {
|
||||
|
||||
// Use the modal manager to show the confirmation modal
|
||||
modalManager.showModal('duplicateDeleteModal');
|
||||
armDeleteButton(document.getElementById('duplicateDeleteModal'));
|
||||
} catch (error) {
|
||||
console.error('Error preparing delete:', error);
|
||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||
@@ -479,8 +482,34 @@ export class DuplicatesManager {
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Unknown error deleting recipes');
|
||||
}
|
||||
|
||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
|
||||
|
||||
const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
|
||||
? data.batch_ids
|
||||
: null;
|
||||
|
||||
if (data.batch_id || batchIds) {
|
||||
// One undo action restores the whole selected group
|
||||
const refreshFn = () => window.recipeManager.loadRecipes(true);
|
||||
const onAction = data.batch_id
|
||||
? () => handleUndoDelete(data.batch_id, refreshFn)
|
||||
: async () => {
|
||||
for (const id of batchIds) {
|
||||
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
|
||||
if (!succeeded) {
|
||||
showToast('toast.undo.failed', { error: '' }, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
refreshFn();
|
||||
showToast('toast.undo.restored', {}, 'success');
|
||||
};
|
||||
showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction,
|
||||
});
|
||||
} else {
|
||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
|
||||
}
|
||||
|
||||
// Exit duplicate mode if deletions were successful
|
||||
if (data.total_deleted > 0) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Model Duplicates Manager Component for LoRAs and Checkpoints
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { formatDate } from '../utils/formatters.js';
|
||||
import { resetAndReload} from '../api/modelApiFactory.js';
|
||||
@@ -700,6 +703,7 @@ export class ModelDuplicatesManager {
|
||||
|
||||
// Use the modal manager to show the confirmation modal
|
||||
modalManager.showModal('modelDuplicateDeleteModal');
|
||||
armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
|
||||
} catch (error) {
|
||||
console.error('Error preparing delete:', error);
|
||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||
@@ -732,8 +736,34 @@ export class ModelDuplicatesManager {
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Unknown error deleting models');
|
||||
}
|
||||
|
||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
|
||||
|
||||
const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
|
||||
? data.batch_ids
|
||||
: null;
|
||||
|
||||
if (data.batch_id || batchIds) {
|
||||
// One undo action restores the whole selected group
|
||||
const refreshFn = () => resetAndReload(true);
|
||||
const onAction = data.batch_id
|
||||
? () => handleUndoDelete(data.batch_id, refreshFn)
|
||||
: async () => {
|
||||
for (const id of batchIds) {
|
||||
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
|
||||
if (!succeeded) {
|
||||
showToast('toast.undo.failed', { error: '' }, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
refreshFn();
|
||||
showToast('toast.undo.restored', {}, 'success');
|
||||
};
|
||||
showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction,
|
||||
});
|
||||
} else {
|
||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
|
||||
}
|
||||
|
||||
// If models were successfully deleted
|
||||
if (data.total_deleted > 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Recipe Card Component
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { configureModelCardVideo } from './shared/ModelCard.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
@@ -7,6 +7,9 @@ import { getCurrentPageState } from '../state/index.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
|
||||
class RecipeCard {
|
||||
constructor(recipe, clickHandler) {
|
||||
@@ -375,8 +378,13 @@ class RecipeCard {
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
let deleteArmTimer = null;
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
if (deleteArmTimer) {
|
||||
clearTimeout(deleteArmTimer);
|
||||
deleteArmTimer = null;
|
||||
}
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
@@ -396,6 +404,8 @@ class RecipeCard {
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||
|
||||
deleteArmTimer = armDeleteButton(deleteModal);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
@@ -432,7 +442,16 @@ class RecipeCard {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
if (data.batch_id) {
|
||||
// Staged delete: offer undo instead of the plain success toast
|
||||
const batchId = data.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
}
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
|
||||
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
@@ -628,6 +630,7 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
modalManager.showModal('bulkDeleteModal');
|
||||
armDeleteButton(document.getElementById('bulkDeleteModal'));
|
||||
}
|
||||
|
||||
async confirmBulkDelete() {
|
||||
@@ -649,10 +652,38 @@ export class BulkManager {
|
||||
showToast('toast.api.operationCancelled', {}, 'info');
|
||||
} else if (result.success) {
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
showToast('toast.models.deletedSuccessfully', {
|
||||
count: result.deleted_count,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
}, 'success');
|
||||
const isRecipes = state.currentPageType === 'recipes';
|
||||
const refreshFn = isRecipes
|
||||
? () => window.recipeManager.loadRecipes(true)
|
||||
: () => resetAndReload(true);
|
||||
|
||||
if (result.batch_id || (result.batch_ids && result.batch_ids.length)) {
|
||||
// One undo action for the whole bulk action — the backend
|
||||
// merges staged per-file batches into a single batch, with
|
||||
// a batch_ids fallback array when the merge failed
|
||||
const onAction = result.batch_id
|
||||
? () => handleUndoDelete(result.batch_id, refreshFn)
|
||||
: async () => {
|
||||
for (const id of result.batch_ids) {
|
||||
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
|
||||
if (!succeeded) {
|
||||
showToast('toast.undo.failed', { error: '' }, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
refreshFn();
|
||||
showToast('toast.undo.restored', {}, 'success');
|
||||
};
|
||||
showActionToast('toast.undo.deletedBulk', { count: result.deleted_count }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction,
|
||||
});
|
||||
} else {
|
||||
showToast('toast.models.deletedSuccessfully', {
|
||||
count: result.deleted_count,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
}, 'success');
|
||||
}
|
||||
|
||||
filePaths.forEach(path => {
|
||||
state.virtualScroller.removeItemByFilePath(path);
|
||||
|
||||
@@ -434,6 +434,22 @@ export class ModalManager {
|
||||
this.currentOpenModal = id; // Update currently open modal
|
||||
document.body.style.top = `-${this.scrollPosition}px`;
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
modal.restoreFocusTo = null;
|
||||
if (this._isDeleteConfirmModal(modal.element)) {
|
||||
const activeElement = document.activeElement;
|
||||
modal.restoreFocusTo = activeElement && activeElement !== document.body
|
||||
? activeElement
|
||||
: null;
|
||||
modal.element.querySelector('.cancel-btn')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Several non-delete modals share the delete-modal styling class, so an
|
||||
// actual .delete-btn is required before focus is moved to Cancel.
|
||||
_isDeleteConfirmModal(element) {
|
||||
return element.classList.contains('delete-modal') &&
|
||||
Boolean(element.querySelector('.delete-btn'));
|
||||
}
|
||||
|
||||
closeModal(id) {
|
||||
@@ -463,6 +479,13 @@ export class ModalManager {
|
||||
modal.cleanupCallback();
|
||||
modal.cleanupCallback = null;
|
||||
}
|
||||
|
||||
if (modal.restoreFocusTo) {
|
||||
if (modal.restoreFocusTo.isConnected) {
|
||||
modal.restoreFocusTo.focus();
|
||||
}
|
||||
modal.restoreFocusTo = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
|
||||
@@ -1111,6 +1111,12 @@ export class SettingsManager {
|
||||
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
|
||||
}
|
||||
|
||||
// Set delete undo setting (defaults to enabled)
|
||||
const deleteUndoEnabledCheckbox = document.getElementById('deleteUndoEnabled');
|
||||
if (deleteUndoEnabledCheckbox) {
|
||||
deleteUndoEnabledCheckbox.checked = state.global.settings.delete_undo_enabled ?? true;
|
||||
}
|
||||
|
||||
// Set lora syntax format
|
||||
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
|
||||
if (loraSyntaxFormatSelect) {
|
||||
|
||||
@@ -58,6 +58,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
group_by_model: false,
|
||||
delete_undo_enabled: true,
|
||||
llm_provider: 'openai',
|
||||
llm_api_key: '',
|
||||
llm_api_base: '',
|
||||
|
||||
@@ -1,37 +1,84 @@
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { showActionToast } from './uiHelpers.js';
|
||||
import { translate } from './i18nHelpers.js';
|
||||
import { handleUndoDelete } from './undoHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { formatFileSize } from '../components/shared/utils.js';
|
||||
|
||||
const DELETE_BUTTON_ARM_DELAY_MS = 1500;
|
||||
|
||||
let pendingDeletePath = null;
|
||||
let pendingDeleteName = null;
|
||||
let pendingExcludePath = null;
|
||||
let pendingDeleteArmTimer = null;
|
||||
|
||||
// Delay-activates every delete button inside a delete-confirmation modal so a
|
||||
// misclick in the first moments after opening cannot confirm the deletion.
|
||||
// Returns the pending timeout id so callers can cancel it when the modal closes early.
|
||||
export function armDeleteButton(modalElement, delayMs = DELETE_BUTTON_ARM_DELAY_MS) {
|
||||
if (!modalElement) return null;
|
||||
|
||||
const deleteButtons = modalElement.querySelectorAll('.delete-btn');
|
||||
if (!deleteButtons.length) return null;
|
||||
|
||||
deleteButtons.forEach((button) => { button.disabled = true; });
|
||||
|
||||
return setTimeout(() => {
|
||||
deleteButtons.forEach((button) => { button.disabled = false; });
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
export function showDeleteModal(filePath) {
|
||||
pendingDeletePath = filePath;
|
||||
|
||||
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||
pendingDeleteName = modelName;
|
||||
const modal = modalManager.getModal('deleteModal').element;
|
||||
const modelInfo = modal.querySelector('.delete-model-info');
|
||||
|
||||
|
||||
const undoEnabled = state.global?.settings?.delete_undo_enabled;
|
||||
const warningKey = undoEnabled
|
||||
? 'modals.deleteModel.recoverableWarning'
|
||||
: 'modals.deleteModel.permanentWarning';
|
||||
const fileSize = card?.dataset.file_size;
|
||||
const sizeLine = fileSize
|
||||
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
|
||||
: '';
|
||||
|
||||
modelInfo.innerHTML = `
|
||||
<strong>Model:</strong> ${modelName}
|
||||
<br>
|
||||
<strong>File:</strong> ${filePath}
|
||||
<br>
|
||||
${translate(warningKey)}${sizeLine}
|
||||
`;
|
||||
|
||||
|
||||
modalManager.showModal('deleteModal');
|
||||
pendingDeleteArmTimer = armDeleteButton(modal);
|
||||
}
|
||||
|
||||
export async function confirmDelete() {
|
||||
if (!pendingDeletePath) return;
|
||||
|
||||
|
||||
try {
|
||||
await getModelApiClient().deleteModel(pendingDeletePath);
|
||||
|
||||
const modelName = pendingDeleteName;
|
||||
const result = await getModelApiClient().deleteModel(pendingDeletePath);
|
||||
|
||||
closeDeleteModal();
|
||||
|
||||
if (result?.batch_id) {
|
||||
const batchId = result.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: modelName }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => resetAndReload(true)),
|
||||
});
|
||||
}
|
||||
|
||||
if (window.modelDuplicatesManager) {
|
||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||
}
|
||||
@@ -43,7 +90,12 @@ export async function confirmDelete() {
|
||||
|
||||
export function closeDeleteModal() {
|
||||
modalManager.closeModal('deleteModal');
|
||||
if (pendingDeleteArmTimer) {
|
||||
clearTimeout(pendingDeleteArmTimer);
|
||||
pendingDeleteArmTimer = null;
|
||||
}
|
||||
pendingDeletePath = null;
|
||||
pendingDeleteName = null;
|
||||
}
|
||||
|
||||
// Functions for the exclude modal
|
||||
|
||||
+132
-27
@@ -133,15 +133,28 @@ export async function copyToClipboard(text, successMessage = null) {
|
||||
}
|
||||
}
|
||||
|
||||
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
||||
// to avoid spurious "Translation key not found" warnings from i18next
|
||||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||
const message = isPlainMessage ? key : translate(key, params, fallback);
|
||||
/**
|
||||
* Build a toast element (internal — not exported).
|
||||
* @param {string} message - Already-resolved message text
|
||||
* @param {string} type - Toast type (info/success/warning/error)
|
||||
* @returns {HTMLElement} The toast element (not yet attached to the DOM)
|
||||
*/
|
||||
function createToastElement(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
return toast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a toast to the shared container, position it, and schedule its
|
||||
* dismissal (internal — not exported).
|
||||
* @param {HTMLElement} toast - The toast element to display
|
||||
* @param {number} durationMs - How long the toast stays visible
|
||||
* @param {Function} [onDismiss] - Optional callback fired once when dismissal begins
|
||||
* @returns {Function} Manual dismiss function (idempotent)
|
||||
*/
|
||||
function appendToast(toast, durationMs, onDismiss = null) {
|
||||
// Get or create toast container
|
||||
let toastContainer = document.querySelector('.toast-container');
|
||||
if (!toastContainer) {
|
||||
@@ -161,35 +174,127 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||
// Set position based on existing toasts
|
||||
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
let dismissed = false;
|
||||
const dismiss = () => {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
|
||||
// Set timeout based on type
|
||||
let timeout = 2000; // Default (info)
|
||||
if (type === 'warning' || type === 'error') {
|
||||
timeout = 5000;
|
||||
if (typeof onDismiss === 'function') {
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => {
|
||||
toast.remove();
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => {
|
||||
toast.remove();
|
||||
|
||||
// Reposition remaining toasts
|
||||
if (toastContainer) {
|
||||
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||||
remainingToasts.forEach((t, index) => {
|
||||
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
||||
});
|
||||
// Reposition remaining toasts
|
||||
if (toastContainer) {
|
||||
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||||
remainingToasts.forEach((t, index) => {
|
||||
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
||||
});
|
||||
|
||||
// Remove container if empty
|
||||
if (remainingToasts.length === 0) {
|
||||
toastContainer.remove();
|
||||
}
|
||||
// Remove container if empty
|
||||
if (remainingToasts.length === 0) {
|
||||
toastContainer.remove();
|
||||
}
|
||||
});
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
setTimeout(dismiss, durationMs);
|
||||
});
|
||||
|
||||
return dismiss;
|
||||
}
|
||||
|
||||
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
||||
// to avoid spurious "Translation key not found" warnings from i18next
|
||||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||
const message = isPlainMessage ? key : translate(key, params, fallback);
|
||||
const toast = createToastElement(message, type);
|
||||
|
||||
// Set timeout based on type
|
||||
let duration = 2000; // Default (info)
|
||||
if (type === 'warning' || type === 'error') {
|
||||
duration = 5000;
|
||||
}
|
||||
|
||||
appendToast(toast, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast with an action button (e.g. Undo) and an optional countdown.
|
||||
* The message accepts the same key/plain-string contract as showToast, so
|
||||
* callers may pass either an i18n key or an already-translated string.
|
||||
* @param {string} key - i18n key or plain message
|
||||
* @param {Object} [params] - i18n interpolation params
|
||||
* @param {string} [type] - Toast type (info/success/warning/error)
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.actionText] - Label for the action button (button omitted when empty)
|
||||
* @param {Function} [options.onAction] - Callback invoked at most once on button click
|
||||
* @param {number} [options.durationMs=30000] - How long the toast stays visible
|
||||
* @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
|
||||
*/
|
||||
export function showActionToast(key, params = {}, type = 'info', options = {}) {
|
||||
const { actionText, onAction, durationMs = 30000, countdown = true } = options;
|
||||
|
||||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||
const message = isPlainMessage ? key : translate(key, params);
|
||||
const toast = createToastElement(message, type);
|
||||
|
||||
let countdownInterval = null;
|
||||
const clearCountdown = () => {
|
||||
if (countdownInterval !== null) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// The interval must be cleared on EVERY dismiss path (timeout, countdown end,
|
||||
// manual button click) — the onDismiss hook covers the appendToast timeout path.
|
||||
const dismiss = appendToast(toast, durationMs, clearCountdown);
|
||||
|
||||
let actionFired = false;
|
||||
if (actionText) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'toast-action-btn';
|
||||
button.textContent = actionText;
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
// Guard against double-click firing the action twice
|
||||
if (actionFired) return;
|
||||
actionFired = true;
|
||||
|
||||
clearCountdown();
|
||||
if (typeof onAction === 'function') {
|
||||
onAction();
|
||||
}
|
||||
dismiss();
|
||||
});
|
||||
toast.append(button);
|
||||
}
|
||||
|
||||
if (countdown) {
|
||||
const countdownEl = document.createElement('span');
|
||||
countdownEl.className = 'toast-countdown';
|
||||
let remainingSeconds = Math.max(0, Math.ceil(durationMs / 1000));
|
||||
countdownEl.textContent = `(${remainingSeconds}s)`;
|
||||
toast.append(countdownEl);
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
remainingSeconds -= 1;
|
||||
countdownEl.textContent = `(${Math.max(remainingSeconds, 0)}s)`;
|
||||
if (remainingSeconds <= 0) {
|
||||
clearCountdown();
|
||||
dismiss();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreFolderFilter() {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { showToast } from './uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Undo a staged delete batch via the pending-delete endpoint.
|
||||
* @param {string} batchId - The batch id returned by a staged delete response
|
||||
* @param {Function|null} refreshFn - Called once after a successful restore (unless options.refresh is false)
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.showToast=true] - Suppress toasts (used by sequential multi-batch undo loops)
|
||||
* @param {boolean} [options.refresh=true] - Suppress the refresh call (used by sequential multi-batch undo loops)
|
||||
* @returns {Promise<boolean>} Whether the undo succeeded
|
||||
*/
|
||||
export async function handleUndoDelete(batchId, refreshFn, options = {}) {
|
||||
const { showToast: showToastEnabled = true, refresh: refreshEnabled = true } = options;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/undo-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ batch_id: batchId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (refreshEnabled && typeof refreshFn === 'function') {
|
||||
refreshFn();
|
||||
}
|
||||
if (showToastEnabled) {
|
||||
showToast('toast.undo.restored', {}, 'success');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read the error body to distinguish an expired batch from other failures
|
||||
let errorMessage = '';
|
||||
try {
|
||||
const body = await response.json();
|
||||
errorMessage = body?.error || '';
|
||||
} catch {
|
||||
errorMessage = '';
|
||||
}
|
||||
|
||||
if (showToastEnabled) {
|
||||
if (response.status === 404 && errorMessage.toLowerCase().includes('expired')) {
|
||||
showToast('toast.undo.expired', {}, 'error');
|
||||
} else {
|
||||
showToast('toast.undo.failed', { error: errorMessage || response.statusText }, 'error');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (showToastEnabled) {
|
||||
showToast('toast.undo.failed', { error: error.message }, 'error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -811,6 +811,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="deleteUndoEnabled">
|
||||
{{ t('settings.deleteUndoEnabled') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="deleteUndoEnabled"
|
||||
onchange="settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
BASE_MODEL_API_MODULE,
|
||||
STATE_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
STORAGE_MODULE,
|
||||
API_CONFIG_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
SIDEBAR_MANAGER_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const showSimpleLoadingMock = vi.fn();
|
||||
const showCancelButtonMock = vi.fn();
|
||||
const hideLoadingMock = vi.fn();
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
loadingManager: {
|
||||
showSimpleLoading: showSimpleLoadingMock,
|
||||
showCancelButton: showCancelButtonMock,
|
||||
hide: hideLoadingMock,
|
||||
},
|
||||
virtualScroller: {
|
||||
removeItemByFilePath: vi.fn(),
|
||||
},
|
||||
},
|
||||
getCurrentPageState: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: vi.fn((key) => key),
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_MODULE, () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
getSessionItem: vi.fn(),
|
||||
removeSessionItem: vi.fn(),
|
||||
saveMapToStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(API_CONFIG_MODULE, () => ({
|
||||
getCompleteApiConfig: vi.fn(() => ({
|
||||
endpoints: { bulkDelete: '/api/lm/loras/bulk-delete' },
|
||||
config: { displayName: 'LoRA', singularName: 'LoRA' },
|
||||
})),
|
||||
getCurrentModelType: vi.fn(() => 'loras'),
|
||||
isValidModelType: vi.fn(() => true),
|
||||
DOWNLOAD_ENDPOINTS: {},
|
||||
HF_ENDPOINTS: {},
|
||||
WS_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||
sidebarManager: { refresh: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('BaseModelApiClient.bulkDeleteModels undo contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
async function createClient() {
|
||||
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
|
||||
class TestClient extends BaseModelApiClient {}
|
||||
return new TestClient('loras');
|
||||
}
|
||||
|
||||
function mockBulkDeleteResponse(payload) {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => payload,
|
||||
});
|
||||
}
|
||||
|
||||
it('posts the file paths and defaults both batch fields to null', async () => {
|
||||
mockBulkDeleteResponse({
|
||||
success: true,
|
||||
status: 'success',
|
||||
total_deleted: 3,
|
||||
total_attempted: 3,
|
||||
cache_updated: true,
|
||||
results: [],
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/loras/bulk-delete',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
deleted_count: 3,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: null,
|
||||
batch_ids: null,
|
||||
});
|
||||
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes through the merged batch_id when the backend staged the bulk delete', async () => {
|
||||
mockBulkDeleteResponse({
|
||||
success: true,
|
||||
status: 'success',
|
||||
total_deleted: 2,
|
||||
total_attempted: 2,
|
||||
cache_updated: true,
|
||||
results: [],
|
||||
batch_id: 'merged-batch-1',
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||
|
||||
expect(result.batch_id).toBe('merged-batch-1');
|
||||
expect(result.batch_ids).toBeNull();
|
||||
});
|
||||
|
||||
it('passes through the batch_ids fallback array when the merge failed', async () => {
|
||||
mockBulkDeleteResponse({
|
||||
success: true,
|
||||
status: 'success',
|
||||
total_deleted: 2,
|
||||
total_attempted: 2,
|
||||
cache_updated: true,
|
||||
results: [],
|
||||
batch_ids: ['batch-1', 'batch-2'],
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||
|
||||
expect(result.batch_id).toBeNull();
|
||||
expect(result.batch_ids).toEqual(['batch-1', 'batch-2']);
|
||||
});
|
||||
|
||||
it('keeps the batch field on the cancelled-status path (staged subset is undoable)', async () => {
|
||||
mockBulkDeleteResponse({
|
||||
success: true,
|
||||
status: 'cancelled',
|
||||
total_deleted: 1,
|
||||
total_attempted: 2,
|
||||
cache_updated: true,
|
||||
results: [],
|
||||
batch_id: 'partial-batch',
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.deleted_count).toBe(1);
|
||||
expect(result.batch_id).toBe('partial-batch');
|
||||
expect(result.batch_ids).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the cancelled marker when the user aborts the fetch', async () => {
|
||||
const abortError = new Error('The user aborted a request.');
|
||||
abortError.name = 'AbortError';
|
||||
global.fetch = vi.fn().mockRejectedValue(abortError);
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.bulkDeleteModels(['/models/a.safetensors']);
|
||||
|
||||
expect(result).toEqual({ success: false, cancelled: true });
|
||||
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws the backend error message when the bulk delete fails', async () => {
|
||||
mockBulkDeleteResponse({ success: false, error: 'disk full' });
|
||||
|
||||
const client = await createClient();
|
||||
await expect(client.bulkDeleteModels(['/models/a.safetensors'])).rejects.toThrow('disk full');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
BASE_MODEL_API_MODULE,
|
||||
STATE_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
STORAGE_MODULE,
|
||||
API_CONFIG_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
SIDEBAR_MANAGER_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const removeItemByFilePathMock = vi.fn();
|
||||
const showSimpleLoadingMock = vi.fn();
|
||||
const hideLoadingMock = vi.fn();
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
loadingManager: {
|
||||
showSimpleLoading: showSimpleLoadingMock,
|
||||
hide: hideLoadingMock,
|
||||
},
|
||||
virtualScroller: {
|
||||
removeItemByFilePath: removeItemByFilePathMock,
|
||||
},
|
||||
},
|
||||
getCurrentPageState: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: vi.fn((key) => key),
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_MODULE, () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
getSessionItem: vi.fn(),
|
||||
removeSessionItem: vi.fn(),
|
||||
saveMapToStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(API_CONFIG_MODULE, () => ({
|
||||
getCompleteApiConfig: vi.fn(() => ({
|
||||
endpoints: { delete: '/api/lm/loras/delete' },
|
||||
config: { displayName: 'LoRA', singularName: 'LoRA' },
|
||||
})),
|
||||
getCurrentModelType: vi.fn(() => 'loras'),
|
||||
isValidModelType: vi.fn(() => true),
|
||||
DOWNLOAD_ENDPOINTS: {},
|
||||
HF_ENDPOINTS: {},
|
||||
WS_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||
sidebarManager: { refresh: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('BaseModelApiClient.deleteModel undo contract', () => {
|
||||
beforeEach(() => {
|
||||
showToastMock.mockReset();
|
||||
removeItemByFilePathMock.mockReset();
|
||||
showSimpleLoadingMock.mockReset();
|
||||
hideLoadingMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
async function createClient() {
|
||||
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
|
||||
class TestClient extends BaseModelApiClient {}
|
||||
return new TestClient('loras');
|
||||
}
|
||||
|
||||
it('returns the batch id and suppresses the legacy success toast when staged', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-42' }),
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.deleteModel('/models/foo.safetensors');
|
||||
|
||||
expect(result).toEqual({ success: true, batch_id: 'batch-42' });
|
||||
// The card is still removed from the scroller — the file is gone either way
|
||||
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
|
||||
// No legacy toast: the caller shows the undo action toast instead
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.api.deleteSuccess',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the legacy success toast when the delete was not staged', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, deleted_files: ['/models/foo.safetensors'] }),
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.deleteModel('/models/foo.safetensors');
|
||||
|
||||
expect(result).toEqual({ success: true, batch_id: null });
|
||||
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.api.deleteSuccess', { type: 'LoRA' }, 'success');
|
||||
});
|
||||
|
||||
it('returns a truthy result so undo-blind callers keep working (ModelVersionsTab)', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-7' }),
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.deleteModel('/models/v2.safetensors');
|
||||
|
||||
// ModelVersionsTab.js:1136-1144 awaits deleteModel and treats any truthy
|
||||
// result as success — the new object must satisfy that check shape.
|
||||
expect(result).toBeTruthy();
|
||||
expect(Boolean(result && result.success)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false and shows the failure toast when the server reports failure', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: false, error: 'disk error' }),
|
||||
});
|
||||
|
||||
const client = await createClient();
|
||||
const result = await client.deleteModel('/models/foo.safetensors');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(removeItemByFilePathMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.api.deleteFailed',
|
||||
expect.objectContaining({ type: 'LoRA' }),
|
||||
'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -142,10 +142,50 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
success: true,
|
||||
deleted_count: 2,
|
||||
failed_count: 0,
|
||||
batch_id: null,
|
||||
batch_ids: null,
|
||||
});
|
||||
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through the merged batch_id from a staged bulk delete', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
total_deleted: 2,
|
||||
total_failed: 0,
|
||||
failed: [],
|
||||
batch_id: 'merged-recipe-batch',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||
|
||||
expect(result.batch_id).toBe('merged-recipe-batch');
|
||||
expect(result.batch_ids).toBeNull();
|
||||
});
|
||||
|
||||
it('passes through the batch_ids fallback array when the merge failed', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
total_deleted: 2,
|
||||
total_failed: 0,
|
||||
failed: [],
|
||||
batch_ids: ['recipe-batch-1', 'recipe-batch-2'],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||
|
||||
expect(result.batch_id).toBeNull();
|
||||
expect(result.batch_ids).toEqual(['recipe-batch-1', 'recipe-batch-2']);
|
||||
});
|
||||
|
||||
it('encodes recipe IDs when fetching recipe details', async () => {
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -123,6 +123,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
showExcludeModal: vi.fn(),
|
||||
showDeleteModal: vi.fn(),
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const showActionToastMock = vi.fn();
|
||||
const handleUndoDeleteMock = vi.fn();
|
||||
const recreateVirtualScrollMock = vi.fn();
|
||||
const translateMock = vi.fn((key) => key);
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
showActionToast: showActionToastMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
|
||||
handleUndoDelete: handleUndoDeleteMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
@@ -20,6 +27,17 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
armDeleteButton: (modalElement) => {
|
||||
if (!modalElement) return null;
|
||||
const buttons = modalElement.querySelectorAll('.delete-btn');
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
return setTimeout(() => {
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
}, 1500);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
recreateVirtualScroll: recreateVirtualScrollMock,
|
||||
}));
|
||||
@@ -211,3 +229,152 @@ describe('DuplicatesManager prompt matching toggle', () => {
|
||||
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setCurrentPageType('recipes');
|
||||
setupDom();
|
||||
state.pendingLayoutRecreate = false;
|
||||
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
|
||||
handleUndoDeleteMock.mockResolvedValue(true);
|
||||
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||
globalThis.recipeManager = { loadRecipes: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.pendingLayoutRecreate = false;
|
||||
state.virtualScroller = null;
|
||||
delete globalThis.modalManager;
|
||||
delete globalThis.recipeManager;
|
||||
delete globalThis.fetch;
|
||||
});
|
||||
|
||||
function mockBulkDelete(payload) {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => payload,
|
||||
});
|
||||
}
|
||||
|
||||
function lastActionToastOptions() {
|
||||
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
|
||||
return call[3];
|
||||
}
|
||||
|
||||
it('shows the undo action toast with the batch id and reloads recipes on undo', async () => {
|
||||
mockBulkDelete({ success: true, total_deleted: 2, batch_id: 'recipe-batch-1' });
|
||||
|
||||
const manager = new DuplicatesManager({});
|
||||
manager.inDuplicateMode = true;
|
||||
manager.selectedForDeletion.add('r1');
|
||||
manager.selectedForDeletion.add('r2');
|
||||
|
||||
await manager.confirmDeleteDuplicates();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||
'toast.undo.deletedBulk',
|
||||
{ count: 2 },
|
||||
'success',
|
||||
expect.objectContaining({
|
||||
actionText: 'toast.undo.action',
|
||||
onAction: expect.any(Function),
|
||||
})
|
||||
);
|
||||
// The legacy duplicates success toast is replaced, not duplicated
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.duplicates.deleteSuccess',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
// exitDuplicateMode still runs for successful deletions
|
||||
expect(manager.inDuplicateMode).toBe(false);
|
||||
|
||||
lastActionToastOptions().onAction();
|
||||
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
|
||||
|
||||
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||
refreshFn();
|
||||
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
|
||||
mockBulkDelete({ success: true, total_deleted: 2, batch_ids: ['rb-1', 'rb-2'] });
|
||||
|
||||
const manager = new DuplicatesManager({});
|
||||
manager.inDuplicateMode = true;
|
||||
manager.selectedForDeletion.add('r1');
|
||||
manager.selectedForDeletion.add('r2');
|
||||
|
||||
await manager.confirmDeleteDuplicates();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
await lastActionToastOptions().onAction();
|
||||
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['rb-1', null, { showToast: false, refresh: false }]);
|
||||
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['rb-2', null, { showToast: false, refresh: false }]);
|
||||
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||
});
|
||||
|
||||
it('keeps the legacy success toast when the response carries no batch field', async () => {
|
||||
mockBulkDelete({ success: true, total_deleted: 1 });
|
||||
|
||||
const manager = new DuplicatesManager({});
|
||||
manager.inDuplicateMode = true;
|
||||
manager.selectedForDeletion.add('r1');
|
||||
|
||||
await manager.confirmDeleteDuplicates();
|
||||
|
||||
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.duplicates.deleteSuccess',
|
||||
{ count: 1, type: 'recipes' },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DuplicatesManager deleteSelectedDuplicates delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
setCurrentPageType('recipes');
|
||||
setupDom();
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="duplicateDeleteModal" class="modal delete-modal">
|
||||
<div class="delete-model-info"><p><span id="duplicateDeleteCount">0</span></p></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`);
|
||||
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete globalThis.modalManager;
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after 1500ms', async () => {
|
||||
const manager = new DuplicatesManager({});
|
||||
manager.selectedForDeletion.add('r1');
|
||||
|
||||
await manager.deleteSelectedDuplicates();
|
||||
|
||||
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('duplicateDeleteModal');
|
||||
const deleteBtn = document.querySelector('#duplicateDeleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const showActionToastMock = vi.fn();
|
||||
const handleUndoDeleteMock = vi.fn();
|
||||
const resetAndReloadMock = vi.fn();
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
showActionToast: showActionToastMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
|
||||
handleUndoDelete: handleUndoDeleteMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
armDeleteButton: (modalElement) => {
|
||||
if (!modalElement) return null;
|
||||
const buttons = modalElement.querySelectorAll('.delete-btn');
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
return setTimeout(() => {
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
}, 1500);
|
||||
},
|
||||
}));
|
||||
|
||||
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
|
||||
const { state } = await import('../../../static/js/state/index.js');
|
||||
|
||||
@@ -230,3 +248,153 @@ describe('ModelDuplicatesManager verification state', () => {
|
||||
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => {
|
||||
function mockDeleteAndRecheck(deletePayload) {
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (String(url).includes('bulk-delete')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
json: async () => deletePayload,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ success: true, duplicates: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function lastActionToastOptions() {
|
||||
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
|
||||
return call[3];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handleUndoDeleteMock.mockResolvedValue(true);
|
||||
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
|
||||
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.virtualScroller = null;
|
||||
delete globalThis.modalManager;
|
||||
});
|
||||
|
||||
it('shows the undo action toast with the batch id and refreshes models on undo', async () => {
|
||||
const manager = await createManager();
|
||||
mockDeleteAndRecheck({ success: true, total_deleted: 1, batch_id: 'model-batch-1' });
|
||||
|
||||
manager.inDuplicateMode = true;
|
||||
manager.selectedForDeletion.add(carPath);
|
||||
|
||||
await manager.confirmDeleteDuplicates();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||
'toast.undo.deletedBulk',
|
||||
{ count: 1 },
|
||||
'success',
|
||||
expect.objectContaining({
|
||||
actionText: 'toast.undo.action',
|
||||
onAction: expect.any(Function),
|
||||
})
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.duplicates.deleteSuccess',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
// The existing reset + find-duplicates re-check path still runs
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
// No remaining duplicates -> duplicate mode exited
|
||||
expect(manager.inDuplicateMode).toBe(false);
|
||||
|
||||
lastActionToastOptions().onAction();
|
||||
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledWith('model-batch-1', expect.any(Function));
|
||||
|
||||
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||
resetAndReloadMock.mockClear();
|
||||
refreshFn();
|
||||
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
|
||||
const manager = await createManager();
|
||||
mockDeleteAndRecheck({ success: true, total_deleted: 2, batch_ids: ['mb-1', 'mb-2'] });
|
||||
|
||||
manager.inDuplicateMode = true;
|
||||
manager.selectedForDeletion.add(carPath);
|
||||
manager.selectedForDeletion.add(copyPath);
|
||||
|
||||
await manager.confirmDeleteDuplicates();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
resetAndReloadMock.mockClear();
|
||||
await lastActionToastOptions().onAction();
|
||||
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['mb-1', null, { showToast: false, refresh: false }]);
|
||||
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['mb-2', null, { showToast: false, refresh: false }]);
|
||||
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||
});
|
||||
|
||||
it('keeps the legacy success toast when the response carries no batch field', async () => {
|
||||
const manager = await createManager();
|
||||
mockDeleteAndRecheck({ success: true, total_deleted: 1 });
|
||||
|
||||
manager.inDuplicateMode = true;
|
||||
manager.selectedForDeletion.add(carPath);
|
||||
|
||||
await manager.confirmDeleteDuplicates();
|
||||
|
||||
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.duplicates.deleteSuccess',
|
||||
{ count: 1, type: 'loras' },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelDuplicatesManager deleteSelectedDuplicates delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete globalThis.modalManager;
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after 1500ms', async () => {
|
||||
const manager = await createManager();
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
|
||||
<div class="delete-model-info"><p><span id="modelDuplicateDeleteCount">0</span></p></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`);
|
||||
manager.selectedForDeletion.add(carPath);
|
||||
|
||||
await manager.deleteSelectedDuplicates();
|
||||
|
||||
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('modelDuplicateDeleteModal');
|
||||
const deleteBtn = document.querySelector('#modelDuplicateDeleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
RECIPE_CARD_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
RECIPE_API_MODULE,
|
||||
MODEL_CARD_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
STATE_MODULE,
|
||||
BULK_MANAGER_MODULE,
|
||||
CONSTANTS_MODULE,
|
||||
I18N_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
|
||||
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
|
||||
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const showActionToastMock = vi.fn();
|
||||
const handleUndoDeleteMock = vi.fn();
|
||||
const translateMock = vi.fn((key) => key);
|
||||
const closeModalMock = vi.fn();
|
||||
const removeItemByFilePathMock = vi.fn();
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
showActionToast: showActionToastMock,
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_API_MODULE, () => ({
|
||||
updateRecipeMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_CARD_MODULE, () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn(),
|
||||
closeModal: closeModalMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
virtualScroller: {
|
||||
removeItemByFilePath: removeItemByFilePathMock,
|
||||
},
|
||||
},
|
||||
getCurrentPageState: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock(BULK_MANAGER_MODULE, () => ({
|
||||
bulkManager: {},
|
||||
}));
|
||||
|
||||
vi.mock(CONSTANTS_MODULE, () => ({
|
||||
NSFW_LEVELS: {},
|
||||
getBaseModelAbbreviation: vi.fn(),
|
||||
getMatureBlurThreshold: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||
handleUndoDelete: handleUndoDeleteMock,
|
||||
}));
|
||||
|
||||
function setupDeleteModal() {
|
||||
document.body.innerHTML = `
|
||||
<div id="deleteModal" data-recipe-id="recipe-1" data-file-path="/recipes/r1.json">
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
// jsdom maps data-file-path to dataset.filePath
|
||||
deleteModal.dataset.recipeId = 'recipe-1';
|
||||
deleteModal.dataset.filePath = '/recipes/r1.json';
|
||||
return deleteModal;
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe('RecipeCard confirmDeleteRecipe undo flow', () => {
|
||||
beforeEach(() => {
|
||||
showToastMock.mockReset();
|
||||
showActionToastMock.mockReset();
|
||||
handleUndoDeleteMock.mockReset();
|
||||
translateMock.mockClear();
|
||||
closeModalMock.mockReset();
|
||||
removeItemByFilePathMock.mockReset();
|
||||
setupDeleteModal();
|
||||
window.recipeManager = { loadRecipes: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
delete window.recipeManager;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
async function createCard() {
|
||||
const { RecipeCard } = await import(RECIPE_CARD_MODULE);
|
||||
const card = Object.create(RecipeCard.prototype);
|
||||
card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json' };
|
||||
return card;
|
||||
}
|
||||
|
||||
it('shows the undo action toast and wires undo to handleUndoDelete + loadRecipes(true)', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'deleted', batch_id: 'recipe-batch-1' }),
|
||||
});
|
||||
|
||||
const card = await createCard();
|
||||
card.confirmDeleteRecipe();
|
||||
await flushPromises();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1', expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
}));
|
||||
// No legacy success toast when the delete was staged
|
||||
expect(showToastMock).not.toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [key, params, type, options] = showActionToastMock.mock.calls[0];
|
||||
expect(key).toBe('toast.undo.deleted');
|
||||
expect(params).toEqual({ name: 'My Recipe' });
|
||||
expect(type).toBe('success');
|
||||
expect(options.actionText).toBe('toast.undo.action');
|
||||
|
||||
options.onAction();
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||
const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
|
||||
expect(batchId).toBe('recipe-batch-1');
|
||||
|
||||
refreshFn();
|
||||
expect(window.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
|
||||
|
||||
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/recipes/r1.json');
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
});
|
||||
|
||||
it('keeps the legacy success toast when the delete was not staged', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'deleted' }),
|
||||
});
|
||||
|
||||
const card = await createCard();
|
||||
card.confirmDeleteRecipe();
|
||||
await flushPromises();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
});
|
||||
|
||||
it('shows the failure toast when the server rejects the delete', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false });
|
||||
|
||||
const card = await createCard();
|
||||
const deleteBtn = document.querySelector('.delete-btn');
|
||||
card.confirmDeleteRecipe();
|
||||
await flushPromises();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.deleteFailed',
|
||||
expect.objectContaining({ message: expect.any(String) }),
|
||||
'error'
|
||||
);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
expect(deleteBtn.textContent).toBe('Delete');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
RECIPE_CARD_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
RECIPE_API_MODULE,
|
||||
MODEL_CARD_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
BULK_MANAGER_MODULE,
|
||||
I18N_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
STATE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
|
||||
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showModalMock = vi.fn();
|
||||
const closeModalMock = vi.fn();
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
showActionToast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_API_MODULE, () => ({
|
||||
updateRecipeMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_CARD_MODULE, () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: showModalMock,
|
||||
closeModal: closeModalMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(BULK_MANAGER_MODULE, () => ({
|
||||
bulkManager: {},
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: vi.fn((key) => key),
|
||||
}));
|
||||
|
||||
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||
handleUndoDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
// modalUtils.js is intentionally NOT mocked — its real armDeleteButton drives
|
||||
// the delay-activate behavior under test. Its own imports are mocked below.
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('RecipeCard delete confirmation delay-activate', () => {
|
||||
let capturedOnClose;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
showModalMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
capturedOnClose = null;
|
||||
document.body.innerHTML = '<div id="deleteModal" class="modal delete-modal"></div>';
|
||||
showModalMock.mockImplementation((id, content, onClose) => {
|
||||
if (content) {
|
||||
document.getElementById(id).innerHTML = content;
|
||||
}
|
||||
capturedOnClose = onClose;
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
window.recipeManager = { loadRecipes: vi.fn() };
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.virtualScroller = { removeItemByFilePath: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete global.fetch;
|
||||
delete window.recipeManager;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
async function createCard() {
|
||||
const { RecipeCard } = await import(RECIPE_CARD_MODULE);
|
||||
const card = Object.create(RecipeCard.prototype);
|
||||
card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json', file_url: '/preview.png' };
|
||||
return card;
|
||||
}
|
||||
|
||||
it('opens with a disabled delete button that ignores clicks until 1500ms elapse', async () => {
|
||||
const card = await createCard();
|
||||
card.showDeleteConfirmation();
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipe/recipe-1',
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the pending arm timer when the modal closes during the countdown', async () => {
|
||||
const card = await createCard();
|
||||
card.showDeleteConfirmation();
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(700);
|
||||
capturedOnClose();
|
||||
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('re-arms a full 1500ms countdown when the modal is reopened', async () => {
|
||||
const card = await createCard();
|
||||
card.showDeleteConfirmation();
|
||||
|
||||
vi.advanceTimersByTime(1400);
|
||||
capturedOnClose();
|
||||
|
||||
card.showDeleteConfirmation();
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
UNDO_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const showActionToastMock = vi.fn();
|
||||
const handleUndoDeleteMock = vi.fn();
|
||||
const resetAndReloadMock = vi.fn();
|
||||
const bulkDeleteModelsMock = vi.fn();
|
||||
const recipeBulkDeleteModelsMock = vi.fn();
|
||||
|
||||
const loadingManagerStub = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
};
|
||||
|
||||
const stateStub = {
|
||||
currentPageType: 'loras',
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
loadingManager: loadingManagerStub,
|
||||
virtualScroller: { removeItemByFilePath: vi.fn() },
|
||||
global: { settings: {} },
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: stateStub,
|
||||
getCurrentPageState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
showActionToast: showActionToastMock,
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
sendEmbeddingToWorkflow: vi.fn(),
|
||||
buildLoraSyntax: vi.fn(),
|
||||
getNSFWLevelName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||
handleUndoDelete: handleUndoDeleteMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => ({ bulkDeleteModels: bulkDeleteModelsMock })),
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
RecipeSidebarApiClient: class {
|
||||
constructor() {
|
||||
this.bulkDeleteModels = recipeBulkDeleteModelsMock;
|
||||
}
|
||||
},
|
||||
updateRecipeMetadata: vi.fn(),
|
||||
extractRecipeId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
||||
MODEL_CONFIG: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
updateCardsForBulkMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((key) => key),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
|
||||
getPriorityTagSuggestions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
||||
getNsfwLevelSelector: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('BulkManager.confirmBulkDelete undo flows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stateStub.currentPageType = 'loras';
|
||||
stateStub.bulkMode = false;
|
||||
stateStub.selectedModels.clear();
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
stateStub.selectedModels.add('/models/b.safetensors');
|
||||
handleUndoDeleteMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.recipeManager;
|
||||
delete window.modelDuplicatesManager;
|
||||
});
|
||||
|
||||
async function createBulkManager() {
|
||||
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||
return new BulkManager();
|
||||
}
|
||||
|
||||
function lastActionToastOptions() {
|
||||
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
|
||||
return call[3];
|
||||
}
|
||||
|
||||
it('shows one action toast for the merged batch id and undoes it with a model refresh', async () => {
|
||||
bulkDeleteModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
deleted_count: 2,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: 'merged-1',
|
||||
batch_ids: null,
|
||||
});
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||
'toast.undo.deletedBulk',
|
||||
{ count: 2 },
|
||||
'success',
|
||||
expect.objectContaining({
|
||||
actionText: 'toast.undo.action',
|
||||
onAction: expect.any(Function),
|
||||
})
|
||||
);
|
||||
// The legacy success and cancelled toasts must NOT fire
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.models.deletedSuccessfully',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.api.operationCancelled',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
lastActionToastOptions().onAction();
|
||||
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledWith('merged-1', expect.any(Function));
|
||||
|
||||
// The undo refresh targets the model library
|
||||
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||
refreshFn();
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('keeps the legacy success toast when both batch fields are null', async () => {
|
||||
bulkDeleteModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
deleted_count: 2,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: null,
|
||||
batch_ids: null,
|
||||
});
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
|
||||
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.models.deletedSuccessfully',
|
||||
{ count: 2, type: 'model' },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('undoes the batch_ids fallback sequentially with exactly one final refresh and restored toast', async () => {
|
||||
bulkDeleteModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
deleted_count: 2,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: null,
|
||||
batch_ids: ['id-1', 'id-2'],
|
||||
});
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||
'toast.undo.deletedBulk',
|
||||
{ count: 2 },
|
||||
'success',
|
||||
expect.objectContaining({ onAction: expect.any(Function) })
|
||||
);
|
||||
|
||||
await lastActionToastOptions().onAction();
|
||||
|
||||
// Sequential suppressed undos in order
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['id-1', null, { showToast: false, refresh: false }]);
|
||||
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['id-2', null, { showToast: false, refresh: false }]);
|
||||
|
||||
// Exactly ONE final refresh and ONE restored toast
|
||||
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||
});
|
||||
|
||||
it('stops the fallback loop on the first failure and skips the final refresh', async () => {
|
||||
bulkDeleteModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
deleted_count: 2,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: null,
|
||||
batch_ids: ['id-1', 'id-2', 'id-3'],
|
||||
});
|
||||
handleUndoDeleteMock
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
await lastActionToastOptions().onAction();
|
||||
|
||||
// The loop stops at the failing second id — the third is never attempted
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||
expect(handleUndoDeleteMock.mock.calls[1][0]).toBe('id-2');
|
||||
|
||||
// The suppressed undo shows no error toast itself — the loop re-shows it
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: '' }, 'error');
|
||||
|
||||
// No final refresh, no restored toast
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).not.toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||
});
|
||||
|
||||
it('shows the action toast for a cancelled bulk that staged a subset (batch_id)', async () => {
|
||||
bulkDeleteModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
deleted_count: 1,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: 'partial-1',
|
||||
batch_ids: null,
|
||||
});
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||
'toast.undo.deletedBulk',
|
||||
{ count: 1 },
|
||||
'success',
|
||||
expect.objectContaining({ onAction: expect.any(Function) })
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.api.operationCancelled',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the action toast for a cancelled bulk with the batch_ids fallback', async () => {
|
||||
bulkDeleteModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
deleted_count: 1,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: null,
|
||||
batch_ids: ['partial-1'],
|
||||
});
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.api.operationCancelled',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the cancelled toast when the user aborted and nothing was staged', async () => {
|
||||
bulkDeleteModelsMock.mockResolvedValue({ success: false, cancelled: true });
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
|
||||
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes recipes through window.recipeManager when undoing a recipe bulk delete', async () => {
|
||||
stateStub.currentPageType = 'recipes';
|
||||
stateStub.selectedModels.clear();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
const loadRecipesMock = vi.fn();
|
||||
window.recipeManager = { loadRecipes: loadRecipesMock };
|
||||
|
||||
recipeBulkDeleteModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
deleted_count: 1,
|
||||
failed_count: 0,
|
||||
errors: [],
|
||||
batch_id: 'recipe-batch-1',
|
||||
batch_ids: null,
|
||||
});
|
||||
|
||||
const bulk = await createBulkManager();
|
||||
await bulk.confirmBulkDelete();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
lastActionToastOptions().onAction();
|
||||
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
|
||||
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||
refreshFn();
|
||||
expect(loadRecipesMock).toHaveBeenCalledWith(true);
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BulkManager.showBulkDeleteModal delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
stateStub.currentPageType = 'loras';
|
||||
stateStub.selectedModels.clear();
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
document.body.innerHTML = `
|
||||
<div id="bulkDeleteModal" class="modal delete-modal">
|
||||
<h2></h2>
|
||||
<p class="delete-message"></p>
|
||||
<div class="delete-model-info"><p></p></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after 1500ms', async () => {
|
||||
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||
const bulk = new BulkManager();
|
||||
bulk.showBulkDeleteModal();
|
||||
|
||||
const deleteBtn = document.querySelector('#bulkDeleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(bulkDeleteModelsMock).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
|
||||
|
||||
function setupDom() {
|
||||
document.body.innerHTML = `
|
||||
<button id="triggerBtn">Trigger</button>
|
||||
<div id="deleteModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="excludeModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="exclude-btn">Exclude</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plainModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('ModalManager delete-modal focus handling', () => {
|
||||
let ModalManager;
|
||||
let manager;
|
||||
|
||||
beforeEach(async () => {
|
||||
setupDom();
|
||||
window.scrollTo = vi.fn();
|
||||
({ ModalManager } = await import(MODAL_MANAGER_MODULE));
|
||||
manager = new ModalManager();
|
||||
for (const id of ['deleteModal', 'excludeModal', 'plainModal']) {
|
||||
manager.registerModal(id, {
|
||||
element: document.getElementById(id),
|
||||
onClose: () => {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('focuses the cancel button when a delete-type modal opens', () => {
|
||||
const trigger = document.getElementById('triggerBtn');
|
||||
trigger.focus();
|
||||
|
||||
manager.showModal('deleteModal');
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
document.querySelector('#deleteModal .cancel-btn')
|
||||
);
|
||||
});
|
||||
|
||||
it('restores focus to the previously focused element on close', () => {
|
||||
const trigger = document.getElementById('triggerBtn');
|
||||
trigger.focus();
|
||||
|
||||
manager.showModal('deleteModal');
|
||||
manager.closeModal('deleteModal');
|
||||
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('does not touch focus for a non-delete modal', () => {
|
||||
const trigger = document.getElementById('triggerBtn');
|
||||
trigger.focus();
|
||||
|
||||
manager.showModal('plainModal');
|
||||
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
manager.closeModal('plainModal');
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('does not treat delete-modal-styled modals without a delete button as delete modals', () => {
|
||||
const trigger = document.getElementById('triggerBtn');
|
||||
trigger.focus();
|
||||
|
||||
manager.showModal('excludeModal');
|
||||
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
manager.closeModal('excludeModal');
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('skips the focus restore when the previously focused element is gone', () => {
|
||||
const trigger = document.getElementById('triggerBtn');
|
||||
trigger.focus();
|
||||
|
||||
manager.showModal('deleteModal');
|
||||
trigger.remove();
|
||||
|
||||
expect(() => manager.closeModal('deleteModal')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: {
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => {
|
||||
const settings = {};
|
||||
return {
|
||||
state: {
|
||||
global: {
|
||||
settings,
|
||||
},
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
delete_undo_enabled: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DOWNLOAD_PATH_TEMPLATES: {},
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: [],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||
lora: 'character, style',
|
||||
checkpoint: 'base, guide',
|
||||
embedding: 'hint',
|
||||
},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (_key, _params, fallback) => fallback ?? '',
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/i18n/index.js', () => ({
|
||||
i18n: {
|
||||
getCurrentLocale: () => 'en',
|
||||
setLanguage: vi.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||
import { showToast } from '../../../static/js/utils/uiHelpers.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
const createManager = () => {
|
||||
state.global.settings = {};
|
||||
const initSettingsSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initializeSettings')
|
||||
.mockResolvedValue();
|
||||
const initializeSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initialize')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const manager = new SettingsManager();
|
||||
|
||||
initSettingsSpy.mockRestore();
|
||||
initializeSpy.mockRestore();
|
||||
|
||||
return manager;
|
||||
};
|
||||
|
||||
const appendDeleteUndoCheckbox = () => {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = 'deleteUndoEnabled';
|
||||
document.body.appendChild(checkbox);
|
||||
return checkbox;
|
||||
};
|
||||
|
||||
const stubLoadSettingsSubloaders = (manager) => {
|
||||
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
describe('SettingsManager delete undo toggle', () => {
|
||||
it('renders the checkbox markup with a resolvable i18n label', () => {
|
||||
const template = readFileSync(
|
||||
resolve(repoRoot, 'templates/components/modals/settings_modal.html'),
|
||||
'utf8',
|
||||
);
|
||||
const locales = JSON.parse(
|
||||
readFileSync(resolve(repoRoot, 'locales/en.json'), 'utf8'),
|
||||
);
|
||||
|
||||
// The label must resolve to real text, not fall back to the raw key.
|
||||
expect(locales.settings.deleteUndoEnabled).toBe(
|
||||
'Keep deleted items recoverable for 30 seconds (undo)',
|
||||
);
|
||||
expect(template).toContain('id="deleteUndoEnabled"');
|
||||
expect(template).toContain("t('settings.deleteUndoEnabled')");
|
||||
expect(template).toContain(
|
||||
"settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')",
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the checkbox as unchecked when the saved setting is false', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
checkbox.checked = true;
|
||||
|
||||
stubLoadSettingsSubloaders(manager);
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
state.global.settings = { delete_undo_enabled: false };
|
||||
|
||||
await manager.loadSettingsToUI();
|
||||
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('restores the checkbox as checked when the saved setting is true or absent', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
|
||||
stubLoadSettingsSubloaders(manager);
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
state.global.settings = { delete_undo_enabled: true };
|
||||
await manager.loadSettingsToUI();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
checkbox.checked = false;
|
||||
state.global.settings = {};
|
||||
await manager.loadSettingsToUI();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('saves delete_undo_enabled to the backend when the checkbox is toggled', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
checkbox.checked = false;
|
||||
|
||||
state.global.settings = { delete_undo_enabled: true };
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
|
||||
|
||||
expect(state.global.settings.delete_undo_enabled).toBe(false);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ delete_undo_enabled: false }),
|
||||
});
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'toast.settings.settingsUpdated',
|
||||
{ setting: 'delete undo enabled' },
|
||||
'success',
|
||||
);
|
||||
});
|
||||
|
||||
it('saves delete_undo_enabled as true when re-enabled', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
checkbox.checked = true;
|
||||
|
||||
state.global.settings = { delete_undo_enabled: false };
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
|
||||
|
||||
expect(state.global.settings.delete_undo_enabled).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ delete_undo_enabled: true }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
closeDeleteModal: closeDeleteModalMock,
|
||||
confirmExclude: confirmExcludeMock,
|
||||
closeExcludeModal: closeExcludeModalMock,
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
closeDeleteModal: closeDeleteModalMock,
|
||||
confirmExclude: confirmExcludeMock,
|
||||
closeExcludeModal: closeExcludeModalMock,
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
closeDeleteModal: closeDeleteModalMock,
|
||||
confirmExclude: confirmExcludeMock,
|
||||
closeExcludeModal: closeExcludeModalMock,
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, afterEach, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { translate } from '../../../static/js/utils/i18nHelpers.js';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
/**
|
||||
* Real en.json translations backed by a minimal `window.i18n` stub that mirrors
|
||||
* the nested lookup + {param} interpolation of static/js/i18n/index.js, so the
|
||||
* assertions exercise actual key resolution rather than key presence.
|
||||
*/
|
||||
const loadEnTranslations = () =>
|
||||
JSON.parse(readFileSync(resolve(repoRoot, 'locales/en.json'), 'utf8'));
|
||||
|
||||
const installWindowI18n = () => {
|
||||
const translations = loadEnTranslations();
|
||||
|
||||
const interpolate = (str, params) =>
|
||||
str.replace(/\{\{?(\w+)\}?\}/g, (match, key) =>
|
||||
params[key] !== undefined ? params[key] : match,
|
||||
);
|
||||
|
||||
window.i18n = {
|
||||
t: (key, params = {}) => {
|
||||
const keys = key.split('.');
|
||||
let value = translations;
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
console.warn(`Translation key not found: ${key}`);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`Translation key is not a string: ${key}`);
|
||||
return key;
|
||||
}
|
||||
return interpolate(value, params);
|
||||
},
|
||||
getCurrentLocale: () => 'en',
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete window.i18n;
|
||||
});
|
||||
|
||||
describe('translate() with real en.json locale', () => {
|
||||
it('resolves undo toast keys with interpolation', () => {
|
||||
installWindowI18n();
|
||||
|
||||
expect(translate('toast.undo.deleted', { name: 'x' })).toBe('Deleted x');
|
||||
expect(translate('toast.undo.deletedBulk', { count: 3 })).toBe('Deleted 3 item(s)');
|
||||
expect(translate('toast.undo.action')).toBe('Undo');
|
||||
expect(translate('toast.undo.restored')).toBe('Item restored');
|
||||
expect(translate('toast.undo.expired')).toBe(
|
||||
'Undo window expired. The item was permanently deleted.',
|
||||
);
|
||||
expect(translate('toast.undo.failed', { error: 'boom' })).toBe('Undo failed: boom');
|
||||
});
|
||||
|
||||
it('resolves delete-model modal keys with interpolation', () => {
|
||||
installWindowI18n();
|
||||
|
||||
expect(translate('modals.deleteModel.permanentWarning')).toBe(
|
||||
'This will permanently delete the file from disk.',
|
||||
);
|
||||
expect(translate('modals.deleteModel.recoverableWarning')).toBe(
|
||||
'This will permanently delete the file after 30 seconds unless you undo.',
|
||||
);
|
||||
expect(translate('modals.deleteModel.freesSpace', { size: '1.2 MB' })).toBe(
|
||||
'Frees 1.2 MB',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the delete-undo settings label', () => {
|
||||
installWindowI18n();
|
||||
|
||||
expect(translate('settings.deleteUndoEnabled')).toBe(
|
||||
'Keep deleted items recoverable for 30 seconds (undo)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the raw key when no translation exists (fallback contract)', () => {
|
||||
installWindowI18n();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(translate('toast.undo.nonExistentKey')).toBe('toast.undo.nonExistentKey');
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
MODAL_UTILS_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const deleteModelMock = vi.fn();
|
||||
const resetAndReloadMock = vi.fn();
|
||||
const showActionToastMock = vi.fn();
|
||||
const handleUndoDeleteMock = vi.fn();
|
||||
const translateMock = vi.fn((key) => key);
|
||||
const closeModalMock = vi.fn();
|
||||
const showModalMock = vi.fn();
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
getModal: vi.fn((id) => ({ element: document.getElementById(id) })),
|
||||
showModal: showModalMock,
|
||||
closeModal: closeModalMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(() => ({ deleteModel: deleteModelMock })),
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showActionToast: showActionToastMock,
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||
handleUndoDelete: handleUndoDeleteMock,
|
||||
}));
|
||||
|
||||
describe('modalUtils confirmDelete undo flow', () => {
|
||||
beforeEach(() => {
|
||||
deleteModelMock.mockReset();
|
||||
resetAndReloadMock.mockReset();
|
||||
showActionToastMock.mockReset();
|
||||
handleUndoDeleteMock.mockReset();
|
||||
translateMock.mockClear();
|
||||
closeModalMock.mockReset();
|
||||
showModalMock.mockReset();
|
||||
document.body.innerHTML = `
|
||||
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
||||
<div id="deleteModal"><div class="delete-model-info"></div></div>
|
||||
`;
|
||||
window.modelDuplicatesManager = undefined;
|
||||
});
|
||||
|
||||
it('shows the undo action toast and wires undo to handleUndoDelete + resetAndReload', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-9' });
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(deleteModelMock).toHaveBeenCalledWith('/models/foo.safetensors');
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [key, params, type, options] = showActionToastMock.mock.calls[0];
|
||||
expect(key).toBe('toast.undo.deleted');
|
||||
expect(params).toEqual({ name: 'Foo Model' });
|
||||
expect(type).toBe('success');
|
||||
expect(options.actionText).toBe('toast.undo.action');
|
||||
expect(translateMock).toHaveBeenCalledWith('toast.undo.action');
|
||||
|
||||
// Clicking Undo posts the batch and refreshes the model list
|
||||
options.onAction();
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||
const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
|
||||
expect(batchId).toBe('batch-9');
|
||||
expect(typeof refreshFn).toBe('function');
|
||||
|
||||
refreshFn();
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not show the action toast when the delete was not staged', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: null });
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
});
|
||||
|
||||
it('falls back to the file name when no card is present', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-10' });
|
||||
document.querySelector('.model-card').remove();
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/bar.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToastMock.mock.calls[0][1]).toEqual({ name: 'bar.safetensors' });
|
||||
});
|
||||
|
||||
it('refreshes the duplicates badge when the manager is available', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-11' });
|
||||
const updateBadge = vi.fn();
|
||||
window.modelDuplicatesManager = { updateDuplicatesBadgeAfterRefresh: updateBadge };
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(updateBadge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modalUtils armDeleteButton delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
deleteModelMock.mockReset();
|
||||
showModalMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
document.body.innerHTML = `
|
||||
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
||||
<div id="deleteModal">
|
||||
<div class="delete-model-info"></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after exactly 1500ms', async () => {
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clicking the disabled delete button fires nothing', async () => {
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
deleteBtn.click();
|
||||
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
expect(deleteModelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => {
|
||||
const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
|
||||
vi.advanceTimersByTime(1400);
|
||||
closeDeleteModal();
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
|
||||
// Reopen — the stale timer must not enable the button early
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modalUtils showDeleteModal warning copy and size line', () => {
|
||||
beforeEach(() => {
|
||||
showModalMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
translateMock.mockClear();
|
||||
translateMock.mockImplementation((key) => key);
|
||||
document.body.innerHTML = `
|
||||
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model" data-file_size="2147483648"></div>
|
||||
<div id="deleteModal">
|
||||
<div class="delete-model-info"></div>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = true;
|
||||
});
|
||||
|
||||
function modelInfoHtml() {
|
||||
return document.querySelector('#deleteModal .delete-model-info').innerHTML;
|
||||
}
|
||||
|
||||
it('shows the recoverable warning when delete_undo_enabled is truthy', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = true;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.recoverableWarning');
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
|
||||
});
|
||||
|
||||
it('shows the permanent warning when delete_undo_enabled is falsy', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = false;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning');
|
||||
});
|
||||
|
||||
it('falls back to the neutral permanent warning when the setting is unavailable', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
delete state.global.settings.delete_undo_enabled;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
|
||||
});
|
||||
|
||||
it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
|
||||
translateMock.mockImplementation((key, params) =>
|
||||
params && params.size ? `${key} ${params.size}` : key
|
||||
);
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.freesSpace 2.0 GB');
|
||||
});
|
||||
|
||||
it('omits the size line when the card has no file size dataset', async () => {
|
||||
document.querySelector('.model-card').removeAttribute('data-file_size');
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.freesSpace');
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,142 @@ describe('UI helper DOM utilities', () => {
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders an action button and countdown span for action toasts', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', { name: 'Demo Model' }, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
});
|
||||
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
expect(toast).not.toBeNull();
|
||||
expect(toast.classList.contains('toast-success')).toBe(true);
|
||||
expect(translateMock).toHaveBeenCalledWith('toast.undo.deleted', { name: 'Demo Model' });
|
||||
|
||||
const button = toast.querySelector('.toast-action-btn');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button.textContent).toBe('Undo');
|
||||
|
||||
const countdown = toast.querySelector('.toast-countdown');
|
||||
expect(countdown).not.toBeNull();
|
||||
expect(countdown.textContent).toBe('(30s)');
|
||||
|
||||
// Ticking one second updates the countdown text
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(countdown.textContent).toBe('(29s)');
|
||||
|
||||
// Drain remaining timers so no state leaks into other tests
|
||||
vi.advanceTimersByTime(30000);
|
||||
});
|
||||
|
||||
it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
});
|
||||
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
toast.querySelector('.toast-action-btn').click();
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
|
||||
// Dismissal removes the element after the transition ends
|
||||
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
expect(document.querySelector('.toast-container .toast')).toBeNull();
|
||||
expect(document.querySelector('.toast-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onAction exactly once when the button is double-clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
});
|
||||
|
||||
const button = document.querySelector('.toast-action-btn');
|
||||
button.click();
|
||||
button.click();
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dismisses the toast when the countdown reaches zero', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
// Async RAF mirrors production ordering: the countdown interval is
|
||||
// registered before the dismiss timeout, so the final tick displays (0s)
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction: vi.fn(),
|
||||
durationMs: 3000,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(0); // Flush the RAF callback
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
const countdown = toast.querySelector('.toast-countdown');
|
||||
expect(countdown.textContent).toBe('(3s)');
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(countdown.textContent).toBe('(1s)');
|
||||
expect(toast.classList.contains('show')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(countdown.textContent).toBe('(0s)');
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
|
||||
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
expect(document.querySelector('.toast-container .toast')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the countdown interval when dismissed via the action button', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
durationMs: 30000,
|
||||
});
|
||||
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
const countdown = toast.querySelector('.toast-countdown');
|
||||
toast.querySelector('.toast-action-btn').click();
|
||||
|
||||
// Advancing past the full duration must not tick the countdown further,
|
||||
// throw, or re-dismiss the already-dismissed toast
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(countdown.textContent).toBe('(30s)');
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
|
||||
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
expect(document.querySelector('.toast-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('toggles the persisted theme and updates DOM attributes', async () => {
|
||||
getStorageItemMock.mockReturnValue('light');
|
||||
document.body.innerHTML = '<button class="theme-toggle"></button>';
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
UI_HELPERS_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
}));
|
||||
|
||||
describe('handleUndoDelete', () => {
|
||||
beforeEach(() => {
|
||||
showToastMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('posts the batch id, refreshes once, and shows the restored toast on 200', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, restored: ['/models/foo.safetensors'] }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-1', refreshFn);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/undo-delete', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ batch_id: 'batch-1' }),
|
||||
}));
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||
});
|
||||
|
||||
it('shows the expired toast for a 404 whose error body mentions expired', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Undo batch expired and was purged' }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-gone', refreshFn);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.expired', {}, 'error');
|
||||
});
|
||||
|
||||
it('shows the failed toast with the server message for a 404 occupied path', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Target path occupied' }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const result = await handleUndoDelete('batch-occupied', vi.fn());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Target path occupied' }, 'error');
|
||||
});
|
||||
|
||||
it('shows the failed toast when the error body is not parseable', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => { throw new Error('invalid json'); },
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const result = await handleUndoDelete('batch-malformed', vi.fn());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Not Found' }, 'error');
|
||||
});
|
||||
|
||||
it('shows the failed toast on network errors', async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-net', refreshFn);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'connection reset' }, 'error');
|
||||
});
|
||||
|
||||
it('suppresses the toast and refresh when the options disable them', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-quiet', refreshFn, { showToast: false, refresh: false });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect(showToastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -207,6 +207,10 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path:
|
||||
task_names = {task.get_name() for task in scheduled_tasks}
|
||||
assert {"lora_cache_init", "checkpoint_cache_init", "embedding_cache_init", "recipe_cache_init", "post_init_tasks", "cleanup_bak_files"}.issubset(task_names)
|
||||
|
||||
# Startup sweep: an expired pending-delete purge task is spawned during
|
||||
# service initialization (covers both plugin and standalone modes).
|
||||
assert "pending_delete_startup_sweep" in task_names
|
||||
|
||||
for scanner in scanners.values():
|
||||
assert scanner.initialized is True
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
|
||||
from py.services.pending_delete_service import PENDING_DELETE_DIR_NAME, _reset_pending_delete_service
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
@@ -901,3 +906,167 @@ async def test_extract_model_id_handles_string_values():
|
||||
|
||||
payload = {"civitai": {"modelId": "54321"}}
|
||||
assert service._extract_model_id_from_payload(payload) == 54321
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for delete_model undo staging
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_pending_delete_singleton() -> Iterator[None]:
|
||||
"""Reset the pending-delete singleton around every test in this module.
|
||||
|
||||
The singleton keeps an in-process list of staging roots across tests;
|
||||
resetting avoids cross-test pollution (a stale root from one tmp_path
|
||||
leaking into the next test's opportunistic purge enumeration).
|
||||
"""
|
||||
_reset_pending_delete_service()
|
||||
yield
|
||||
_reset_pending_delete_service()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_scanner_registry_getters(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Prevent purge enumeration from instantiating real scanner singletons.
|
||||
|
||||
``stage_model_delete`` triggers an opportunistic purge whose root
|
||||
enumeration queries the ServiceRegistry scanner getters; stubbing them to
|
||||
``None`` keeps tests fast and isolated (mirrors test_pending_delete_service).
|
||||
"""
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
|
||||
async def _none(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
|
||||
|
||||
|
||||
def _make_delete_service(scanner: Any) -> ModelLifecycleService:
|
||||
return ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=DummyMetadataManager({"civitai": {"modelId": 1}}),
|
||||
metadata_loader=_empty_metadata_loader,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
|
||||
"""Undo enabled (the default): artifacts are renamed into a
|
||||
``.lm-pending-delete/<batch_id>/`` staging dir under the model root, the
|
||||
response carries the batch_id, the cache entry is removed and the cache
|
||||
is persisted (``_persist_calls`` tracked by ``ScannerForDelete``)."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
metadata_path = root / "model.metadata.json"
|
||||
metadata_path.write_text(json.dumps({}))
|
||||
preview_path = root / "model.preview.png"
|
||||
preview_path.write_bytes(b"preview")
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[
|
||||
{
|
||||
"file_path": str(model_path),
|
||||
"civitai": {"modelId": 1, "id": 10},
|
||||
"sha256": "abc123",
|
||||
}
|
||||
],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = _make_delete_service(scanner)
|
||||
|
||||
result = await service.delete_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
batch_id = result["batch_id"]
|
||||
assert isinstance(batch_id, str)
|
||||
|
||||
assert not model_path.exists()
|
||||
assert not metadata_path.exists()
|
||||
assert not preview_path.exists()
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "model.safetensors").read_bytes() == b"content"
|
||||
assert (batch_dir / "model.metadata.json").exists()
|
||||
assert (batch_dir / "model.preview.png").exists()
|
||||
|
||||
assert scanner.cache.raw_data == []
|
||||
assert scanner._hash_index.removed == [str(model_path)]
|
||||
assert scanner._persist_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_hard_deletes_when_undo_disabled(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""delete_undo_enabled=false: old os.remove behavior, batch_id is None and
|
||||
no staging directory is ever created."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
monkeypatch.setattr(
|
||||
settings_manager,
|
||||
"get",
|
||||
lambda key, default=None: False
|
||||
if key == "delete_undo_enabled"
|
||||
else default,
|
||||
)
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[{"file_path": str(model_path)}],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = _make_delete_service(scanner)
|
||||
|
||||
result = await service.delete_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["batch_id"] is None
|
||||
assert result["deleted_files"]
|
||||
assert not model_path.exists()
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_falls_back_when_staging_fails(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Staging rename failure: delete_model_artifacts fallback removes the
|
||||
files, batch_id is None and no staged data is left behind."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
real_rename = os.rename
|
||||
|
||||
def _failing_rename(src: str, dst: str) -> None:
|
||||
if PENDING_DELETE_DIR_NAME in dst:
|
||||
raise OSError("simulated staging failure")
|
||||
real_rename(src, dst)
|
||||
|
||||
monkeypatch.setattr(os, "rename", _failing_rename)
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[{"file_path": str(model_path)}],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = _make_delete_service(scanner)
|
||||
|
||||
result = await service.delete_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["batch_id"] is None
|
||||
assert result["deleted_files"]
|
||||
assert not model_path.exists()
|
||||
# No staged batch files remain (the batch dir is rolled back; an empty
|
||||
# staging parent, if left behind by the rollback, holds no data).
|
||||
staging_parent = root / PENDING_DELETE_DIR_NAME
|
||||
assert not staging_parent.exists() or not any(staging_parent.iterdir())
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from types import MethodType
|
||||
@@ -11,7 +16,13 @@ from py.services import model_scanner
|
||||
from py.services.model_cache import ModelCache
|
||||
from py.services.model_hash_index import ModelHashIndex
|
||||
from py.services.model_scanner import CacheBuildResult, ModelScanner
|
||||
from py.services.pending_delete_service import (
|
||||
PENDING_DELETE_DIR_NAME,
|
||||
PENDING_DELETE_TTL_SECONDS,
|
||||
_reset_pending_delete_service,
|
||||
)
|
||||
from py.services.persistent_model_cache import PersistentModelCache, DEFAULT_LICENSE_FLAGS
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils.civitai_utils import build_license_flags
|
||||
from py.utils.models import BaseModelMetadata
|
||||
|
||||
@@ -104,6 +115,27 @@ def stub_register_service(monkeypatch):
|
||||
monkeypatch.setattr(model_scanner.ServiceRegistry, "register_service", noop)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_pending_delete_singleton() -> Iterator[None]:
|
||||
"""Reset the pending-delete singleton before and after each test."""
|
||||
_reset_pending_delete_service()
|
||||
yield
|
||||
_reset_pending_delete_service()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_service_registry_getters(monkeypatch) -> None:
|
||||
"""Prevent pending-delete purge enumeration from building real scanners."""
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
|
||||
async def _none(*_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
|
||||
|
||||
|
||||
def _create_files(root: Path) -> tuple[Path, Path, Path]:
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
@@ -974,3 +1006,235 @@ async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypat
|
||||
)
|
||||
assert changed is True
|
||||
assert resort_calls == 1
|
||||
|
||||
|
||||
# ── bulk_delete_models staging (undo-delete feature, todo 3) ───────────────
|
||||
|
||||
|
||||
def _make_bulk_scanner(root: Path, file_paths: List[Path]) -> DummyScanner:
|
||||
"""Build a DummyScanner whose cache mirrors the given files on disk."""
|
||||
scanner = DummyScanner(root)
|
||||
raw_data = []
|
||||
for path in file_paths:
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
raw_data.append(
|
||||
{
|
||||
"file_path": str(path),
|
||||
"folder": "",
|
||||
"sha256": f"hash-{name}",
|
||||
"tags": ["alpha"] if "one" in name else ["beta"],
|
||||
"model_name": name,
|
||||
"file_name": name,
|
||||
"size": 1,
|
||||
"modified": 1.0,
|
||||
}
|
||||
)
|
||||
scanner._cache = ModelCache(
|
||||
raw_data=raw_data, folders=[], name_display_mode="model_name"
|
||||
)
|
||||
scanner._tags_count = {"alpha": 1, "beta": 1}
|
||||
for entry in raw_data:
|
||||
scanner._hash_index.add_entry(entry["sha256"], entry["file_path"])
|
||||
return scanner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_stages_two_files_into_single_batch(tmp_path: Path):
|
||||
"""Two-file bulk delete -> one merged batch id with both files staged."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "success"
|
||||
assert result["total_deleted"] == 2
|
||||
assert result["cache_updated"] is True
|
||||
|
||||
# ONE batch id, no batch_ids array, and both files staged in its dir.
|
||||
assert "batch_id" in result
|
||||
assert "batch_ids" not in result
|
||||
batch_id = result["batch_id"]
|
||||
assert batch_id is not None
|
||||
staging = root / PENDING_DELETE_DIR_NAME
|
||||
batch_dir = staging / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "one.txt").read_bytes() == b"one"
|
||||
assert (batch_dir / "two.txt").read_bytes() == b"two"
|
||||
|
||||
# Loser batch dirs are removed by the merge - exactly one batch remains.
|
||||
batch_dirs = [d.name for d in staging.iterdir() if d.is_dir()]
|
||||
assert batch_dirs == [batch_id]
|
||||
|
||||
# The manifest carries the winner's cache snapshot for later undo.
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["model_snapshot"]["file_path"] == str(first)
|
||||
|
||||
# Originals gone; cache entries removed.
|
||||
assert not first.exists()
|
||||
assert not second.exists()
|
||||
cached_paths = {item["file_path"] for item in scanner._cache.raw_data}
|
||||
assert str(first) not in cached_paths
|
||||
assert str(second) not in cached_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_merged_manifest_reanchors_expiry(tmp_path: Path):
|
||||
"""Merged manifest expires_at is re-anchored to now+TTL at merge time."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
before = int(time.time())
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
after = int(time.time())
|
||||
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / result["batch_id"]
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
# expires_at >= staging completion time + TTL (re-anchor assertion).
|
||||
assert manifest["expires_at"] >= after + PENDING_DELETE_TTL_SECONDS - 2
|
||||
assert manifest["expires_at"] >= before + PENDING_DELETE_TTL_SECONDS
|
||||
# Both files are entries of the merged manifest.
|
||||
assert len(manifest["entries"]) == 2
|
||||
assert (batch_dir / "one.txt").exists()
|
||||
assert (batch_dir / "two.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_merge_failure_falls_back_to_batch_ids(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Merge move failure -> batch_ids array of the intact constituent batches."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
real_rename = os.rename
|
||||
fail_next = {"enabled": True}
|
||||
|
||||
def flaky_merge_rename(src: str, dst: str) -> None:
|
||||
# Fail only when moving between batch dirs (merge), never during
|
||||
# staging (src is then the original path, outside .lm-pending-delete).
|
||||
if (
|
||||
fail_next["enabled"]
|
||||
and PENDING_DELETE_DIR_NAME in src
|
||||
and PENDING_DELETE_DIR_NAME in dst
|
||||
):
|
||||
fail_next["enabled"] = False
|
||||
raise OSError("simulated merge failure")
|
||||
return real_rename(src, dst)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.pending_delete_service.os.rename", flaky_merge_rename
|
||||
)
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["total_deleted"] == 2
|
||||
# No single batch id - the constituent ids are returned instead.
|
||||
assert "batch_id" not in result
|
||||
assert "batch_ids" in result
|
||||
assert len(result["batch_ids"]) == 2
|
||||
|
||||
# Both constituent batches are intact: dirs + manifests + staged files.
|
||||
staging = root / PENDING_DELETE_DIR_NAME
|
||||
batch_dirs = sorted(d.name for d in staging.iterdir() if d.is_dir())
|
||||
assert sorted(result["batch_ids"]) == batch_dirs
|
||||
for bid in result["batch_ids"]:
|
||||
batch_dir = staging / bid
|
||||
assert (batch_dir / "manifest.json").exists()
|
||||
staged_files = [
|
||||
f.name
|
||||
for bid in result["batch_ids"]
|
||||
for f in (staging / bid).iterdir()
|
||||
if f.is_file() and f.name != "manifest.json"
|
||||
]
|
||||
assert sorted(staged_files) == ["one.txt", "two.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_undo_disabled_hard_deletes(tmp_path: Path):
|
||||
"""delete_undo_enabled=false -> old hard delete, no batch, no staging dirs."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
get_settings_manager().settings["delete_undo_enabled"] = False
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "success"
|
||||
assert result["total_deleted"] == 2
|
||||
assert result.get("batch_id") is None
|
||||
assert "batch_ids" not in result
|
||||
|
||||
# Old hard-delete behavior: files removed, zero staging dirs created.
|
||||
assert not first.exists()
|
||||
assert not second.exists()
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_cancelled_after_one_staged_batch_present(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Cancelled mid-way -> status='cancelled' AND the staged subset undoable."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
real_rename = os.rename
|
||||
rename_count = {"n": 0}
|
||||
|
||||
def cancelling_rename(src: str, dst: str) -> None:
|
||||
rename_count["n"] += 1
|
||||
result = real_rename(src, dst)
|
||||
# After the first file is staged, request cancellation so the loop
|
||||
# stops before the second file is processed.
|
||||
if rename_count["n"] == 1:
|
||||
scanner.cancel_task()
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.pending_delete_service.os.rename", cancelling_rename
|
||||
)
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "cancelled"
|
||||
assert result["total_deleted"] == 1
|
||||
assert "batch_id" in result
|
||||
assert result["batch_id"] is not None
|
||||
assert "batch_ids" not in result
|
||||
|
||||
# The staged subset is merged into one undoable batch.
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / result["batch_id"]
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "one.txt").read_bytes() == b"one"
|
||||
assert not first.exists()
|
||||
# The second file was never touched.
|
||||
assert second.exists()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
||||
"""Tests for recipe delete staging in :mod:`py.services.recipes.persistence_service`.
|
||||
|
||||
Covers the delete-undo wiring (plan todo 4): ``delete_recipe`` and
|
||||
``bulk_delete`` stage recipe JSON + preview image into the global pending-delete
|
||||
staging dir when undo is enabled, fall back to the existing hard delete when it
|
||||
is disabled, and expose the batch field(s) in the result payload. Merge failure
|
||||
falls back to a ``batch_ids`` array (same no-merge contract as the model bulk
|
||||
path).
|
||||
|
||||
Deterministic time control: no real sleeps - the re-anchored ``expires_at`` is
|
||||
compared against a loose before/after window instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.pending_delete_service import (
|
||||
PENDING_DELETE_DIR_NAME,
|
||||
PENDING_DELETE_TTL_SECONDS,
|
||||
_reset_pending_delete_service,
|
||||
)
|
||||
from py.services.recipes.persistence_service import (
|
||||
PersistenceResult,
|
||||
RecipePersistenceService,
|
||||
)
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils import settings_paths
|
||||
|
||||
|
||||
class DummyExifUtils:
|
||||
"""Exif double matching the persistence service constructor contract."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.appended = None
|
||||
self.optimized_calls = 0
|
||||
|
||||
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
|
||||
self.optimized_calls += 1
|
||||
return image_data, ".webp"
|
||||
|
||||
def append_recipe_metadata(self, image_path, recipe_data):
|
||||
self.appended = (image_path, recipe_data)
|
||||
|
||||
def extract_image_metadata(self, path):
|
||||
return {}
|
||||
|
||||
|
||||
class RecipeScannerStub:
|
||||
"""Scanner double exposing the persistence methods used by delete flows."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.recipes_dir = str(root)
|
||||
self.removed: List[str] = []
|
||||
self.bulk_removed: List[str] = []
|
||||
self._json_paths: Dict[str, str] = {}
|
||||
|
||||
def register_recipe(self, recipe_id: str, json_path: Path) -> None:
|
||||
self._json_paths[str(recipe_id)] = str(json_path)
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||
return self._json_paths.get(str(recipe_id))
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||
self.removed.append(str(recipe_id))
|
||||
return True
|
||||
|
||||
async def bulk_remove(self, recipe_ids) -> int:
|
||||
self.bulk_removed.extend(str(recipe_id) for recipe_id in recipe_ids)
|
||||
return len(list(recipe_ids))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_service_singleton() -> Iterator[None]:
|
||||
"""Reset the pending-delete singleton before and after each test."""
|
||||
_reset_pending_delete_service()
|
||||
yield
|
||||
_reset_pending_delete_service()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_scanner_registry(monkeypatch) -> None:
|
||||
"""Prevent purge enumeration from instantiating real scanner singletons."""
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
|
||||
async def _none(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
|
||||
|
||||
|
||||
def _make_service() -> RecipePersistenceService:
|
||||
return RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
def _write_recipe(root: Path, recipe_id: str) -> tuple[Path, Path, Dict[str, Any]]:
|
||||
"""Write a recipe JSON + preview image; return (json_path, image_path, data)."""
|
||||
recipes_dir = root / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||
image_path.write_bytes(f"{recipe_id}-image".encode())
|
||||
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_data: Dict[str, Any] = {
|
||||
"id": recipe_id,
|
||||
"title": f"Recipe {recipe_id}",
|
||||
"file_path": str(image_path),
|
||||
"loras": [],
|
||||
}
|
||||
json_path.write_text(json.dumps(recipe_data), encoding="utf-8")
|
||||
return json_path, image_path, recipe_data
|
||||
|
||||
|
||||
def _write_json_only_recipe(root: Path, recipe_id: str) -> tuple[Path, Dict[str, Any]]:
|
||||
"""Write a recipe JSON whose preview image does NOT exist."""
|
||||
recipes_dir = root / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_data: Dict[str, Any] = {
|
||||
"id": recipe_id,
|
||||
"title": f"Recipe {recipe_id}",
|
||||
"file_path": str(recipes_dir / f"{recipe_id}.missing.webp"),
|
||||
"loras": [],
|
||||
}
|
||||
json_path.write_text(json.dumps(recipe_data), encoding="utf-8")
|
||||
return json_path, recipe_data
|
||||
|
||||
|
||||
def _staging_parent() -> Path:
|
||||
# Resolve through the module namespace so the conftest settings-dir
|
||||
# isolation patch takes effect at call time.
|
||||
return Path(settings_paths.get_settings_dir()) / PENDING_DELETE_DIR_NAME
|
||||
|
||||
|
||||
def _batch_dirs() -> List[Path]:
|
||||
parent = _staging_parent()
|
||||
if not parent.is_dir():
|
||||
return []
|
||||
return [p for p in parent.iterdir() if p.is_dir()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (1) delete_recipe with undo enabled -> staged JSON + image, originals gone,
|
||||
# payload batch_id set, manifest recipe_snapshot present
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_recipe_stages_json_and_image(tmp_path: Path) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_path, image_path, recipe_data = _write_recipe(tmp_path, "r1")
|
||||
scanner.register_recipe("r1", json_path)
|
||||
json_bytes = json_path.read_bytes()
|
||||
image_bytes = image_path.read_bytes()
|
||||
|
||||
result = await _make_service().delete_recipe(
|
||||
recipe_scanner=scanner, recipe_id="r1"
|
||||
)
|
||||
|
||||
assert isinstance(result, PersistenceResult)
|
||||
batch_id = result.payload["batch_id"]
|
||||
assert batch_id is not None
|
||||
|
||||
# JSON + image exist in the global staging dir; originals removed.
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert not json_path.exists()
|
||||
assert not image_path.exists()
|
||||
|
||||
# QA: staged copies match the original bytes.
|
||||
assert (batch_dir / json_path.name).read_bytes() == json_bytes
|
||||
assert (batch_dir / image_path.name).read_bytes() == image_bytes
|
||||
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["batch_id"] == batch_id
|
||||
assert manifest["kind"] == "recipe"
|
||||
assert manifest["model_type"] is None
|
||||
assert manifest["state"] == "staged"
|
||||
assert manifest["recipe_snapshot"] == recipe_data
|
||||
assert manifest["model_snapshot"] is None
|
||||
assert len(manifest["entries"]) == 2
|
||||
originals = {entry["original"] for entry in manifest["entries"]}
|
||||
assert originals == {str(json_path), str(image_path)}
|
||||
|
||||
# Scanner cache removal still runs.
|
||||
assert scanner.removed == ["r1"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (2) recipe with missing preview image -> only JSON staged, no crash
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_recipe_skips_missing_preview_image(tmp_path: Path) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_path, recipe_data = _write_json_only_recipe(tmp_path, "r2")
|
||||
scanner.register_recipe("r2", json_path)
|
||||
|
||||
result = await _make_service().delete_recipe(
|
||||
recipe_scanner=scanner, recipe_id="r2"
|
||||
)
|
||||
|
||||
batch_id = result.payload["batch_id"]
|
||||
assert batch_id is not None
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "r2.recipe.json").read_text(encoding="utf-8") == json.dumps(
|
||||
recipe_data
|
||||
)
|
||||
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert len(manifest["entries"]) == 1
|
||||
assert manifest["recipe_snapshot"] == recipe_data
|
||||
|
||||
assert not json_path.exists()
|
||||
assert scanner.removed == ["r2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (3) undo disabled -> no staging, payload batch_id None, existing behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_recipe_undo_disabled_no_staging(tmp_path: Path) -> None:
|
||||
get_settings_manager().settings["delete_undo_enabled"] = False
|
||||
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_path, image_path, _recipe_data = _write_recipe(tmp_path, "r3")
|
||||
scanner.register_recipe("r3", json_path)
|
||||
|
||||
result = await _make_service().delete_recipe(
|
||||
recipe_scanner=scanner, recipe_id="r3"
|
||||
)
|
||||
|
||||
assert result.payload["batch_id"] is None
|
||||
# No staging leftovers when undo is disabled.
|
||||
assert not _staging_parent().exists()
|
||||
# Existing hard delete behavior unchanged.
|
||||
assert not json_path.exists()
|
||||
assert not image_path.exists()
|
||||
assert scanner.removed == ["r3"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (4) bulk_delete with 2 ids -> single batch_id, one batch dir with both
|
||||
# recipes, re-anchored expires_at in the merged manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_bulk_delete_merges_into_single_batch(tmp_path: Path) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_a, img_a, data_a = _write_recipe(tmp_path, "ra")
|
||||
json_b, img_b, data_b = _write_recipe(tmp_path, "rb")
|
||||
scanner.register_recipe("ra", json_a)
|
||||
scanner.register_recipe("rb", json_b)
|
||||
json_a_bytes = json_a.read_bytes()
|
||||
image_a_bytes = img_a.read_bytes()
|
||||
json_b_bytes = json_b.read_bytes()
|
||||
image_b_bytes = img_b.read_bytes()
|
||||
before = int(time.time())
|
||||
|
||||
result = await _make_service().bulk_delete(
|
||||
recipe_scanner=scanner, recipe_ids=["ra", "rb"]
|
||||
)
|
||||
|
||||
batch_id = result.payload["batch_id"]
|
||||
assert batch_id is not None
|
||||
assert "batch_ids" not in result.payload
|
||||
assert len(_batch_dirs()) == 1, "loser batch dir must be removed after merge"
|
||||
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["batch_id"] == batch_id
|
||||
assert len(manifest["entries"]) == 4
|
||||
|
||||
# Re-anchored expires_at: now + TTL at merge time (not the earlier of the
|
||||
# two staged expiries). Loose window avoids any timing flakiness.
|
||||
assert (
|
||||
before + PENDING_DELETE_TTL_SECONDS - 2
|
||||
<= manifest["expires_at"]
|
||||
<= int(time.time()) + PENDING_DELETE_TTL_SECONDS + 2
|
||||
)
|
||||
|
||||
# Both recipes' files live under ONE batch dir, byte-identical to originals.
|
||||
assert (batch_dir / "ra.recipe.json").read_bytes() == json_a_bytes
|
||||
assert (batch_dir / "ra.webp").read_bytes() == image_a_bytes
|
||||
assert (batch_dir / "rb.recipe.json").read_bytes() == json_b_bytes
|
||||
assert (batch_dir / "rb.webp").read_bytes() == image_b_bytes
|
||||
|
||||
# Originals removed; both snapshots present.
|
||||
# Originals removed; merged manifest holds the winner's recipe snapshot.
|
||||
assert not json_a.exists()
|
||||
assert not json_b.exists()
|
||||
assert manifest["recipe_snapshot"] in (data_a, data_b)
|
||||
assert all(entry["restored"] is False for entry in manifest["entries"])
|
||||
|
||||
assert scanner.bulk_removed == ["ra", "rb"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (5) merge failure fallback -> batch_ids array of length 2, batches intact
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_bulk_delete_merge_failure_falls_back_to_batch_ids(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_a, _img_a, _data_a = _write_recipe(tmp_path, "ra")
|
||||
json_b, _img_b, _data_b = _write_recipe(tmp_path, "rb")
|
||||
scanner.register_recipe("ra", json_a)
|
||||
scanner.register_recipe("rb", json_b)
|
||||
|
||||
def failing_rename(src: str, dst: str) -> None:
|
||||
raise OSError("simulated merge move failure")
|
||||
|
||||
monkeypatch.setattr("py.services.pending_delete_service.os.rename", failing_rename)
|
||||
|
||||
result = await _make_service().bulk_delete(
|
||||
recipe_scanner=scanner, recipe_ids=["ra", "rb"]
|
||||
)
|
||||
|
||||
batch_ids = result.payload["batch_ids"]
|
||||
assert "batch_id" not in result.payload
|
||||
assert len(batch_ids) == 2
|
||||
assert len(_batch_dirs()) == 2, "both constituent batches stay intact"
|
||||
|
||||
# Each constituent batch is complete and individually undoable.
|
||||
for batch_id in batch_ids:
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "manifest.json").exists()
|
||||
assert any(entry["original"] == str(json_a) or entry["original"] == str(json_b) for entry in json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))["entries"])
|
||||
|
||||
assert not json_a.exists()
|
||||
assert not json_b.exists()
|
||||
assert scanner.bulk_removed == ["ra", "rb"]
|
||||
@@ -18,6 +18,16 @@ from py.utils.models import BaseModelMetadata
|
||||
from py.utils.utils import calculate_recipe_fingerprint
|
||||
|
||||
|
||||
async def _wait_for_resort(scanner: RecipeScanner) -> None:
|
||||
"""Deterministically await any pending background cache resort.
|
||||
|
||||
Resort runs in a thread-pool executor; a bare sleep(0) races it.
|
||||
"""
|
||||
tasks = list(getattr(scanner, "_resort_tasks", None) or [])
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
class StubHashIndex:
|
||||
def __init__(self) -> None:
|
||||
self._hash_to_path: dict[str, str] = {}
|
||||
@@ -342,6 +352,7 @@ async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner):
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
result = await scanner.get_paginated_data(page=1, page_size=5)
|
||||
|
||||
@@ -587,6 +598,7 @@ async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
result = await scanner.get_paginated_data(
|
||||
page=1,
|
||||
@@ -619,6 +631,7 @@ async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
result = await scanner.get_paginated_data(page=1, page_size=10)
|
||||
item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing")
|
||||
@@ -899,6 +912,7 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||
|
||||
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
# Test without filter (should return both)
|
||||
result_all = await scanner.get_paginated_data(page=1, page_size=10)
|
||||
@@ -950,6 +964,7 @@ async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
# Test search in prompt
|
||||
result_prompt = await scanner.get_paginated_data(
|
||||
@@ -1009,6 +1024,7 @@ async def test_get_paginated_data_sorting(recipe_scanner):
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
# Test Name DESC: Gamma, Beta, Alpha
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc")
|
||||
|
||||
@@ -1178,3 +1178,8 @@ def test_skip_previously_downloaded_model_versions_coerces_string_input(manager)
|
||||
|
||||
assert manager.get_skip_previously_downloaded_model_versions() is True
|
||||
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
||||
|
||||
|
||||
def test_delete_undo_enabled_defaults_true(manager):
|
||||
assert settings_manager_module.DEFAULT_SETTINGS.get("delete_undo_enabled") is True
|
||||
assert manager.get("delete_undo_enabled") is True
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_metadata_fills_missing_file_facts(tmp_path) -> None:
|
||||
"""A payload missing file_name/size/modified is healed on write."""
|
||||
model_path = tmp_path / "MyModel.safetensors"
|
||||
model_path.write_bytes(b"fake model data")
|
||||
payload = {"file_path": str(model_path), "model_name": "My Model"}
|
||||
|
||||
result = await MetadataManager.save_metadata(str(model_path), payload)
|
||||
assert result is True
|
||||
|
||||
metadata_path = tmp_path / "MyModel.metadata.json"
|
||||
saved = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
stat_result = model_path.stat()
|
||||
assert saved["file_name"] == "MyModel"
|
||||
assert saved["size"] == stat_result.st_size
|
||||
assert saved["modified"] == stat_result.st_mtime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_metadata_keeps_existing_file_facts(tmp_path) -> None:
|
||||
"""Existing file facts are never overwritten on write."""
|
||||
model_path = tmp_path / "MyModel.safetensors"
|
||||
model_path.write_bytes(b"fake model data")
|
||||
payload = {
|
||||
"file_path": str(model_path),
|
||||
"file_name": "CustomName",
|
||||
"size": 123,
|
||||
"modified": 456.0,
|
||||
}
|
||||
|
||||
result = await MetadataManager.save_metadata(str(model_path), payload)
|
||||
assert result is True
|
||||
|
||||
metadata_path = tmp_path / "MyModel.metadata.json"
|
||||
saved = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
assert saved["file_name"] == "CustomName"
|
||||
assert saved["size"] == 123
|
||||
assert saved["modified"] == 456.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_metadata_payload_restores_file_facts_when_sidecar_missing(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Missing sidecar: the payload is rebuilt with local file facts."""
|
||||
model_path = tmp_path / "MyModel.safetensors"
|
||||
model_path.write_bytes(b"fake model data")
|
||||
|
||||
payload = await MetadataManager.load_metadata_payload(str(model_path))
|
||||
|
||||
stat_result = model_path.stat()
|
||||
assert payload["file_path"] == str(model_path)
|
||||
assert payload["file_name"] == "MyModel"
|
||||
assert payload["size"] == stat_result.st_size
|
||||
assert payload["modified"] == stat_result.st_mtime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hydrate_model_data_restores_required_fields_when_sidecar_missing(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Self-heal repro: sidecar deleted, cache entry hydrated, base fields kept."""
|
||||
model_path = tmp_path / "MyModel.safetensors"
|
||||
model_path.write_bytes(b"fake model data")
|
||||
model_data = {
|
||||
"file_path": str(model_path),
|
||||
"folder": "extra_loras",
|
||||
"file_name": "MyModel",
|
||||
"model_name": "My Model",
|
||||
"size": 10,
|
||||
"modified": 100.0,
|
||||
"sha256": "abc123",
|
||||
"base_model": "Illustrious",
|
||||
"preview_url": "",
|
||||
"civitai": {"id": 123},
|
||||
}
|
||||
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
|
||||
stat_result = model_path.stat()
|
||||
assert model_data["file_name"] == "MyModel"
|
||||
assert model_data["model_name"] == "My Model"
|
||||
assert model_data["size"] == stat_result.st_size
|
||||
# `modified` is the import timestamp per schema; the cache value wins.
|
||||
assert model_data["modified"] == 100.0
|
||||
assert model_data["sha256"] == "abc123"
|
||||
assert model_data["base_model"] == "Illustrious"
|
||||
assert model_data["folder"] == "extra_loras"
|
||||
assert model_data["civitai"] == {"id": 123}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hydrate_model_data_disk_wins_when_sidecar_exists(tmp_path) -> None:
|
||||
"""Existing sidecar stays authoritative; no cache key is resurrected."""
|
||||
model_path = tmp_path / "MyModel.safetensors"
|
||||
model_path.write_bytes(b"fake model data")
|
||||
stat_result = model_path.stat()
|
||||
sidecar = {
|
||||
"file_path": str(model_path),
|
||||
"file_name": "MyModel",
|
||||
"model_name": "Disk Name",
|
||||
"size": stat_result.st_size,
|
||||
"modified": stat_result.st_mtime,
|
||||
"sha256": "diskhash",
|
||||
"base_model": "SDXL",
|
||||
"preview_url": "",
|
||||
}
|
||||
(tmp_path / "MyModel.metadata.json").write_text(
|
||||
json.dumps(sidecar), encoding="utf-8"
|
||||
)
|
||||
model_data = {
|
||||
"file_path": str(model_path),
|
||||
"folder": "extra_loras",
|
||||
"model_name": "Cache Name",
|
||||
"sha256": "cachehash",
|
||||
"civitai": {"id": 999},
|
||||
}
|
||||
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
|
||||
assert model_data["model_name"] == "Disk Name"
|
||||
assert model_data["sha256"] == "diskhash"
|
||||
# civitai is present only as the dataclass default {}; the cache's value
|
||||
# ({"id": 999}) is not resurrected.
|
||||
assert model_data["civitai"] == {}
|
||||
assert model_data["folder"] == "extra_loras"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hydrate_model_data_keeps_sha256_missing_for_caller_persist_fix(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Sidecar exists but lacks sha256: hydrate leaves it missing so the
|
||||
caller's self-heal persist block still has work to do."""
|
||||
model_path = tmp_path / "MyModel.safetensors"
|
||||
model_path.write_bytes(b"fake model data")
|
||||
stat_result = model_path.stat()
|
||||
sidecar = {
|
||||
"file_path": str(model_path),
|
||||
"file_name": "MyModel",
|
||||
"model_name": "My Model",
|
||||
"size": stat_result.st_size,
|
||||
"modified": stat_result.st_mtime,
|
||||
"base_model": "SDXL",
|
||||
"preview_url": "",
|
||||
# sha256 deliberately absent
|
||||
}
|
||||
(tmp_path / "MyModel.metadata.json").write_text(
|
||||
json.dumps(sidecar), encoding="utf-8"
|
||||
)
|
||||
model_data = {
|
||||
"file_path": str(model_path),
|
||||
"sha256": "cachehash",
|
||||
"model_name": "Cache Name",
|
||||
}
|
||||
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
|
||||
assert "sha256" not in model_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_metadata_payload_tolerates_missing_model_file(tmp_path) -> None:
|
||||
"""A nonexistent model file must not crash payload loading."""
|
||||
missing_path = tmp_path / "Ghost.safetensors"
|
||||
payload = await MetadataManager.load_metadata_payload(str(missing_path))
|
||||
assert payload["file_path"] == str(missing_path)
|
||||
assert "file_name" not in payload
|
||||
assert "size" not in payload
|
||||
assert "modified" not in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_healed_sidecar_is_parseable(tmp_path) -> None:
|
||||
"""End-to-end repro: deleted sidecar + refresh recreates a parseable file."""
|
||||
model_path = tmp_path / "MyModel.safetensors"
|
||||
model_path.write_bytes(b"fake model data")
|
||||
model_data = {
|
||||
"file_path": str(model_path),
|
||||
"folder": "extra_loras",
|
||||
"file_name": "MyModel",
|
||||
"model_name": "My Model",
|
||||
"size": 10,
|
||||
"modified": 100.0,
|
||||
"sha256": "abc123",
|
||||
"base_model": "Illustrious",
|
||||
"preview_url": "",
|
||||
"civitai": {"id": 123},
|
||||
}
|
||||
|
||||
# Simulate the self-heal flow: hydrate from (missing) sidecar, then persist.
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
data_to_save = model_data.copy()
|
||||
data_to_save.pop("folder", None)
|
||||
await MetadataManager.save_metadata(str(model_path), data_to_save)
|
||||
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
str(model_path), LoraMetadata
|
||||
)
|
||||
assert should_skip is False
|
||||
assert metadata is not None
|
||||
assert metadata.file_name == "MyModel"
|
||||
assert metadata.model_name == "My Model"
|
||||
assert metadata.sha256 == "abc123"
|
||||
Reference in New Issue
Block a user