Compare commits

...
7 Commits
Author SHA1 Message Date
Will Miao f1d3ac0cdc fix(metadata): fill local file facts when self-heal recreates sidecar
Refresh after manual .metadata.json deletion rebuilds the payload without
file_name/size/modified, which are required by BaseModelMetadata.from_dict.
The recreated sidecar then fails to parse and the scanner skips the model.

- load_metadata_payload fills missing file facts from os.stat
- hydrate_model_data restores every missing key from the cache snapshot
  only when the sidecar is missing entirely (disk stays authoritative
  otherwise), preferring the cached import timestamp for modified
- save_metadata fills file facts on write so no write path can produce
  an unparseable sidecar
2026-08-11 14:57:23 +08:00
Will Miao e2c45905f0 test(recipe): await background resort deterministically in pagination tests 2026-08-11 14:09:24 +08:00
Will Miao b2c68e6a65 feat(delete): add undo toasts and harden delete modals 2026-08-11 14:09:10 +08:00
Will Miao eb0f6dd3b6 feat(settings): add delete_undo_enabled toggle 2026-08-11 14:08:55 +08:00
Will Miao 0bf87f9092 chore(i18n): add undo-delete and delete-confirmation strings 2026-08-11 14:08:41 +08:00
Will Miao 1da2433bb2 feat(delete): add undo-delete endpoint and purge scheduling 2026-08-11 14:08:28 +08:00
Will Miao 2d6cf545b9 feat(delete): stage model and recipe deletes for 30s undo 2026-08-11 14:08:15 +08:00
63 changed files with 8234 additions and 83 deletions
+13 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "載入更多項目失敗",
+14
View File
@@ -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"]
+25
View File
@@ -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"]
+3 -2
View File
@@ -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
+23 -3
View File
@@ -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]:
+73 -5
View File
@@ -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:
+974
View File
@@ -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()
+56 -10
View File
@@ -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,
+1
View File
@@ -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": "",
+62 -1
View File
@@ -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)
+3 -1
View File
@@ -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:
+2
View File
@@ -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)
+27
View File
@@ -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 {
+14 -4
View File
@@ -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`);
+4
View File
@@ -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();
+32 -3
View File
@@ -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) {
+33 -3
View File
@@ -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) {
+21 -2
View File
@@ -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);
+36 -5
View File
@@ -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);
+23
View File
@@ -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) {
+6
View File
@@ -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) {
+1
View File
@@ -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: '',
+59 -7
View File
@@ -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
View File
@@ -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() {
+55
View File
@@ -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'
);
});
});
+40
View File
@@ -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', () => ({
+1
View File
@@ -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', () => ({
+96
View File
@@ -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();
});
});
+283
View File
@@ -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');
});
});
+136
View File
@@ -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>';
+133
View File
@@ -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())
+264
View File
@@ -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
+338
View File
@@ -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"]
+16
View File
@@ -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")
+5
View File
@@ -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
+214
View File
@@ -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"