diff --git a/locales/de.json b/locales/de.json index f7058d01..94ee7bb1 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", diff --git a/locales/en.json b/locales/en.json index db841f3b..b2c5f4cb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1263,6 +1263,26 @@ "unsupported": "Folder creation is not supported on this page", "noRoot": "No model root is configured" }, + "deleteFolder": "Delete folder", + "deleteFolderModal": { + "title": "Delete folder?", + "message": "The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "Folder", + "emptyNote": "This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "Folder is not empty", + "notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "Delete folder" + }, + "deleteFolderResult": { + "success": "Folder \"{name}\" deleted", + "successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "Folder restored", + "failed": "Failed to delete folder: {message}", + "notEmpty": "This folder still contains models. Refresh the sidebar and try again.", + "busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "Folder deletion is not supported on this page", + "noRoot": "No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Unable to determine destination path for move.", "moveUnsupported": "Move is not supported for this item.", diff --git a/locales/es.json b/locales/es.json index 28193ecd..4ead463a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "moveUnsupported": "El movimiento no es compatible con este elemento.", diff --git a/locales/fr.json b/locales/fr.json index 0ee41ce8..37634a55 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.", diff --git a/locales/he.json b/locales/he.json index dbff4651..ee6308c5 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", diff --git a/locales/ja.json b/locales/ja.json index 385564c5..fbb7dc3d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "移動先のパスを特定できません。", "moveUnsupported": "この項目の移動はサポートされていません。", diff --git a/locales/ko.json b/locales/ko.json index e86ff47b..248802e1 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", diff --git a/locales/ru.json b/locales/ru.json index 4359e40d..bbe986ae 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "moveUnsupported": "Перемещение этого элемента не поддерживается.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 4abc41c0..20f60d4f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "无法确定移动的目标路径。", "moveUnsupported": "此条目不支持移动。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a85a7081..b84ea0d2 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1263,6 +1263,26 @@ "unsupported": "[TODO: Translate] Folder creation is not supported on this page", "noRoot": "[TODO: Translate] No model root is configured" }, + "deleteFolder": "[TODO: Translate] Delete folder", + "deleteFolderModal": { + "title": "[TODO: Translate] Delete folder?", + "message": "[TODO: Translate] The folder and everything inside it will be permanently removed from disk.", + "folderLabel": "[TODO: Translate] Folder", + "emptyNote": "[TODO: Translate] This folder contains no models. Any other files it holds will be deleted too.", + "notEmptyTitle": "[TODO: Translate] Folder is not empty", + "notEmptyMessage": "[TODO: Translate] This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.", + "confirm": "[TODO: Translate] Delete folder" + }, + "deleteFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" deleted", + "successWithFiles": "[TODO: Translate] Folder \"{name}\" deleted along with {count} other item(s)", + "restored": "[TODO: Translate] Folder restored", + "failed": "[TODO: Translate] Failed to delete folder: {message}", + "notEmpty": "[TODO: Translate] This folder still contains models. Refresh the sidebar and try again.", + "busy": "[TODO: Translate] A deletion is still pending inside this folder. Wait for the undo window to expire.", + "unsupported": "[TODO: Translate] Folder deletion is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "無法確定移動的目標路徑。", "moveUnsupported": "此項目不支援移動。", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index beb4e9c7..68b68c9b 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2499,6 +2499,37 @@ class ModelMoveHandler: self._logger.error("Error creating folder: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def delete_folder(self, request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) + try: + folder_path = data.get("folder_path") + if not folder_path: + return web.json_response( + {"success": False, "error": "Folder path is required"}, status=400 + ) + dry_run = bool(data.get("dry_run")) + result = await self._move_service.delete_folder( + folder_path, dry_run=dry_run + ) + if result.get("success"): + if not dry_run: + _broadcast_models_changed() + return web.json_response(result, status=200) + + # "not_empty" / "busy" are conflicts between the tree the client + # rendered and the on-disk truth; everything else is a bad request. + code = result.get("code") + status = 409 if code in ("not_empty", "busy") else 400 + return web.json_response(result, status=status) + except Exception as exc: + self._logger.error("Error deleting folder: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def move_model(self, request: web.Request) -> web.Response: try: data = await request.json() @@ -3450,6 +3481,7 @@ class ModelHandlerSet: "move_model": self.move.move_model, "move_models_bulk": self.move.move_models_bulk, "create_folder": self.move.create_folder, + "delete_folder": self.move.delete_folder, "auto_organize_models": self.auto_organize.auto_organize_models, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_model_notes": self.query.get_model_notes, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index 7fa536c7..c00472da 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -41,6 +41,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"), + RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition( diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 88ddc15a..87d12828 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -2,13 +2,15 @@ import asyncio import fnmatch import os import logging +import shutil from typing import Any, Dict, List, Optional, Sequence, Set from abc import ABC, abstractmethod from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs -from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE +from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS from ..services.settings_manager import get_settings_manager from ..services.model_lifecycle_service import _require_path_in_library_roots +from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME logger = logging.getLogger(__name__) @@ -536,6 +538,180 @@ class ModelMoveService: return rel.replace(os.sep, "/") return "" + async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]: + """Delete a model-free directory inside the model library roots. + + Only directories whose subtree holds no model weight files can be + removed: a folder-level cascade would bypass the per-model lifecycle + bookkeeping (metadata sidecars, previews, cache entries, pending-delete + staging and recipe references), so it is deliberately refused. Leftover + non-model files (stray previews, sidecars, ``.bak`` files) are reported + in the manifest before they are removed. + + Args: + folder_path: Absolute path of the directory to remove (business + path — symlinks are not resolved) + dry_run: When true, only report what would be removed + + Returns: + Dictionary with the success flag plus a removal manifest + (``model_count``/``file_count``/``dir_count``/``symlink_count``/ + ``total_bytes``/``restorable``) on success. + """ + try: + if not folder_path or not str(folder_path).strip(): + return {"success": False, "error": "Folder path is required"} + + _require_path_in_library_roots(folder_path, self.scanner, label="Folder path") + + absolute_path = os.path.abspath(folder_path) + if os.path.islink(absolute_path): + # shutil.rmtree refuses symlinked roots, and silently deleting + # the link (leaving the real directory behind) is a separate + # decision we do not make here. + return { + "success": False, + "error": "Symlinked folders cannot be deleted", + } + if not os.path.isdir(absolute_path): + return {"success": False, "error": "Folder no longer exists"} + + if self._is_model_root(absolute_path): + return { + "success": False, + "error": "The library root itself cannot be deleted", + } + + manifest = self._collect_folder_manifest(absolute_path) + + if manifest["pending_delete_job"]: + return { + "success": False, + "code": "busy", + "error": ( + "A staged delete is still pending inside this folder; " + "wait for the undo window to expire" + ), + "manifest": manifest, + } + + if manifest["model_count"] > 0: + return { + "success": False, + "code": "not_empty", + "error": ( + f"Folder still contains {manifest['model_count']} model " + "file(s); delete or move them first" + ), + "manifest": manifest, + } + + relative_folder = self._calculate_relative_folder(absolute_path) + + if dry_run: + return { + "success": True, + "dry_run": True, + "folder_path": absolute_path.replace(os.sep, "/"), + "folder": relative_folder, + **manifest, + } + + shutil.rmtree(absolute_path) + + await self._forget_folder(relative_folder) + + return { + "success": True, + "dry_run": False, + "folder_path": absolute_path.replace(os.sep, "/"), + "folder": relative_folder, + **manifest, + } + except ValueError as exc: + return {"success": False, "error": str(exc)} + except Exception as exc: + logger.error(f"Error deleting folder: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + def _is_model_root(self, absolute_path: str) -> bool: + """Return True when the path *is* one of the configured library roots.""" + normalized = os.path.normpath(absolute_path) + for root in self.scanner.get_model_roots(): + if os.path.normpath(os.path.abspath(root)) == normalized: + return True + return False + + @staticmethod + def _is_model_file(file_name: str) -> bool: + """Return True when the file name carries a model weight extension.""" + return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS + + def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]: + """Describe everything a recursive delete of *absolute_path* removes. + + Walking is intentional: the scanner cache can be stale, and a model file + that appeared on disk since the last scan must still block the delete. + Symbolic links are never followed (``os.walk`` default) and are counted + separately — ``shutil.rmtree`` unlinks them without touching their + targets. + """ + model_count = 0 + file_count = 0 + dir_count = 0 + symlink_count = 0 + total_bytes = 0 + pending_delete_job = False + + for dirpath, dirnames, filenames in os.walk(absolute_path): + if PENDING_DELETE_DIR_NAME in dirnames: + pending_delete_job = True + + for name in dirnames: + if os.path.islink(os.path.join(dirpath, name)): + symlink_count += 1 + else: + dir_count += 1 + + for name in filenames: + full_path = os.path.join(dirpath, name) + if os.path.islink(full_path): + symlink_count += 1 + continue + if self._is_model_file(name): + model_count += 1 + else: + file_count += 1 + try: + total_bytes += os.path.getsize(full_path) + except OSError: # pragma: no cover - defensive + pass + + return { + "model_count": model_count, + "file_count": file_count, + "dir_count": dir_count, + "symlink_count": symlink_count, + "total_bytes": total_bytes, + "pending_delete_job": pending_delete_job, + # A truly empty directory is the only case an "undo" can restore by + # simply recreating it; a folder holding stray files is gone for good. + "restorable": ( + model_count == 0 + and file_count == 0 + and dir_count == 0 + and symlink_count == 0 + ), + } + + async def _forget_folder(self, relative_folder: str) -> None: + """Drop a removed directory from the scanner's folder/cache records.""" + if not relative_folder: + return + remove_known_folder = getattr(self.scanner, "remove_known_folder", None) + if callable(remove_known_folder): + await remove_known_folder(relative_folder) + async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]: """Move a single model file diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 919ccc98..3864e9e5 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -1505,6 +1505,63 @@ class ModelScanner: await self._persist_current_cache() self.bump_cache_version() + async def remove_known_folder(self, folder: str) -> None: + """Forget a folder (and its subtree) that no longer exists on disk. + + Counterpart of :meth:`add_known_folder`, called after a directory is + removed between scans (e.g. via the delete-folder API) so folder trees + and the move/download destination pickers stop offering it without a + full rescan. Ancestors are kept on purpose: every recorded ancestor + exists on disk in its own right, so only the removed subtree is dropped. + + Cache entries that referenced the now-missing directory are purged as + well, which keeps a stale (phantom) model card from surviving the + deletion. When ``all_folders`` has not been recorded yet (legacy + snapshot) only the cache purge runs — the scheduled backfill walk + rebuilds the folder list from disk. + """ + normalized = folder.replace("\\", "/").strip("/") + if not normalized: + return + cache = self._cache + if cache is None: + return + + prefix = f"{normalized}/" + + folders_changed = False + recorded = getattr(cache, "all_folders", None) + if recorded is not None: + updated = [ + entry + for entry in recorded + if entry != normalized and not entry.startswith(prefix) + ] + if updated != list(recorded): + cache.all_folders = updated + folders_changed = True + + stale_paths = [ + item.get("file_path") + for item in (cache.raw_data or []) + if self._folder_within(item.get("folder", ""), normalized) + ] + if stale_paths: + # The purge persists the cache — including the already updated + # all_folders list — and bumps the version itself. + await self._batch_update_cache_for_deleted_models(stale_paths) + folders = set(item.get("folder", "") for item in cache.raw_data) + cache.folders = sorted(folders, key=lambda x: x.lower()) + elif folders_changed: + await self._persist_current_cache() + + self.bump_cache_version() + + @staticmethod + def _folder_within(candidate: str, target: str) -> bool: + """Return True when *candidate* is *target* or lives below it.""" + return candidate == target or candidate.startswith(f"{target}/") + def _schedule_all_folders_backfill(self) -> None: """Kick off a one-shot background folder walk if none is running.""" if self._all_folders_backfill_running: diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 15b5bde0..d6aa33b5 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -84,6 +84,7 @@ export function getApiEndpoints(modelType) { moveModel: `/api/lm/${modelType}/move_model`, moveBulk: `/api/lm/${modelType}/move_models_bulk`, createFolder: `/api/lm/${modelType}/create-folder`, + deleteFolder: `/api/lm/${modelType}/delete-folder`, // CivitAI integration fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index b495fedc..682de035 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1330,6 +1330,40 @@ export class BaseModelApiClient { return result; } + /** + * Delete a model-free folder inside the library roots. + * + * Only model-free folders can be removed; the backend answers with a 409 + * `not_empty`/`busy` conflict otherwise. Those codes are attached to the + * thrown Error (`code`, `manifest`) so callers can explain the refusal + * instead of showing a bare message. + * + * @param {string} folderPath Absolute business path of the folder + * @param {{dryRun?: boolean}} [options] + */ + async deleteFolder(folderPath, options = {}) { + const { dryRun = false } = options || {}; + + const response = await fetch(this.apiConfig.endpoints.deleteFolder, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ folder_path: folderPath, dry_run: dryRun }) + }); + + const result = await response.json().catch(() => ({})); + + if (!response.ok || result.success === false) { + const error = new Error(result.error || `Failed to delete folder`); + error.code = result.code || null; + error.manifest = result.manifest || null; + throw error; + } + + return result; + } + async fetchUnifiedFolderTree(options = {}) { try { const { includeEmpty = false } = options; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 08af10a0..95daadf7 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -6,7 +6,8 @@ import { getModelApiClient } from '../api/modelApiFactory.js'; import { translate } from '../utils/i18nHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { bulkManager } from '../managers/BulkManager.js'; -import { showToast } from '../utils/uiHelpers.js'; +import { modalManager } from '../managers/ModalManager.js'; +import { showToast, showActionToast } from '../utils/uiHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { escapeHtml, escapeAttribute } from './shared/utils.js'; import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js'; @@ -46,6 +47,8 @@ export class SidebarManager { this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes this._createFolderBasePath = null; this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation + this._pendingDeleteFolderPath = null; + this._deleteFolderModalWired = false; // Bind methods this.handleTreeClick = this.handleTreeClick.bind(this); @@ -133,6 +136,7 @@ export class SidebarManager { this.nonEmptyFolders = null; this._createFolderBasePath = null; this._createFolderTempChildren = null; + this._pendingDeleteFolderPath = null; // Reset container margin const container = document.querySelector('.container'); @@ -759,6 +763,188 @@ export class SidebarManager { this.hideCreateFolderInput(); } + /** + * Open the folder delete modal for *path*. + * + * The tree already knows whether the subtree holds models (the same + * models-only set that dims empty nodes), so the modal opens in one of two + * states without a round trip: a confirmation for a model-free folder, or + * an explanation when models would have to be cascaded over — a + * folder-level cascade would bypass the per-model lifecycle bookkeeping, + * so the backend refuses it and the UI says why. + */ + showDeleteFolderModal(path) { + const modal = document.getElementById('deleteFolderModal'); + if (!modal) return; + + // Defensive: the modal may have been absent when listeners were wired. + this._wireDeleteFolderModal(); + + const title = modal.querySelector('[data-role="title"]'); + const message = modal.querySelector('[data-role="message"]'); + const info = modal.querySelector('[data-role="info"]'); + const confirmBtn = modal.querySelector('[data-action="confirm-delete-folder"]'); + + const holdsModels = this.nonEmptyFolders ? this.nonEmptyFolders.has(path) : false; + + const pathLine = `${escapeHtml(translate('sidebar.deleteFolderModal.folderLabel', {}, 'Folder'))}: ${escapeHtml(path)}`; + + if (holdsModels) { + this._pendingDeleteFolderPath = null; + title.textContent = translate( + 'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty' + ); + message.textContent = translate( + 'sidebar.deleteFolderModal.notEmptyMessage', {}, + 'This folder still contains models. Delete or move them first.' + ); + info.innerHTML = pathLine; + confirmBtn.style.display = 'none'; + modal.dataset.state = 'blocked'; + } else { + this._pendingDeleteFolderPath = path; + title.textContent = translate( + 'sidebar.deleteFolderModal.title', {}, 'Delete folder?' + ); + message.textContent = translate( + 'sidebar.deleteFolderModal.message', {}, + 'The folder and everything inside it will be permanently removed from disk.' + ); + info.innerHTML = `${pathLine}
${escapeHtml(translate( + 'sidebar.deleteFolderModal.emptyNote', {}, 'This folder contains no models.' + ))}`; + confirmBtn.style.display = ''; + modal.dataset.state = 'confirm'; + } + + modalManager.showModal('deleteFolderModal'); + } + + hideDeleteFolderModal() { + this._pendingDeleteFolderPath = null; + modalManager.closeModal('deleteFolderModal'); + } + + async handleDeleteFolderConfirm() { + const path = this._pendingDeleteFolderPath; + this.hideDeleteFolderModal(); + + if (!path) return false; + + return this._deleteFolder(path); + } + + async _deleteFolder(relativePath) { + if (!this._supportsFolderManagement() || typeof this.apiClient.deleteFolder !== 'function') { + showToast('sidebar.deleteFolderResult.unsupported', {}, 'error'); + return false; + } + + try { + const rootsData = await this.apiClient.fetchModelRoots(); + const roots = rootsData?.roots || []; + const root = this._resolveDefaultRoot(roots); + if (!root) { + showToast('sidebar.deleteFolderResult.noRoot', {}, 'error'); + return false; + } + + const absolutePath = this.combineRootAndRelativePath(root, relativePath); + const result = await this.apiClient.deleteFolder(absolutePath); + + // Drop the node (and its subtree) from the persisted expand state + // before refreshing, otherwise stale keys accumulate forever. A + // selection inside the removed subtree is left to + // restoreSelectedFolder(), which falls back to the root and + // reloads the grid when the folder is gone from the fresh tree. + this._forgetRemovedFolder(relativePath); + + await this.refresh(); + + const name = result.folder || relativePath; + if (result.restorable) { + // A truly empty folder is reproducible one-for-one, so offer + // the same 20s undo affordance the model delete flow uses. + showActionToast('sidebar.deleteFolderResult.success', { name }, 'success', { + actionText: translate('toast.undo.action', {}, 'Undo'), + onAction: () => this._restoreDeletedFolder(absolutePath, relativePath), + }); + } else { + showToast( + 'sidebar.deleteFolderResult.successWithFiles', + { name, count: (result.file_count || 0) + (result.dir_count || 0) }, + 'success' + ); + } + + return true; + } catch (error) { + console.error('[SidebarManager] Error deleting folder:', error); + if (error?.code === 'not_empty') { + showToast('sidebar.deleteFolderResult.notEmpty', {}, 'warning'); + } else if (error?.code === 'busy') { + showToast('sidebar.deleteFolderResult.busy', {}, 'warning'); + } else { + showToast( + 'sidebar.deleteFolderResult.failed', + { message: error?.message || 'Unknown error' }, + 'error' + ); + } + return false; + } + } + + async _restoreDeletedFolder(absolutePath, relativePath) { + try { + await this.apiClient.createFolder(absolutePath); + this._forgetRemovedFolder(relativePath); + await this.refresh(); + showToast('sidebar.deleteFolderResult.restored', {}, 'success'); + return true; + } catch (error) { + console.error('[SidebarManager] Error restoring deleted folder:', error); + showToast('toast.undo.failed', { error: error?.message || '' }, 'error'); + return false; + } + } + + _forgetRemovedFolder(folderPath) { + if (!folderPath) return; + + const prefix = `${folderPath}/`; + let changed = false; + for (const node of Array.from(this.expandedNodes)) { + if (node === folderPath || node.startsWith(prefix)) { + this.expandedNodes.delete(node); + changed = true; + } + } + if (changed) { + this.saveExpandedState(); + } + } + + _wireDeleteFolderModal() { + if (this._deleteFolderModalWired) return; + + const modal = document.getElementById('deleteFolderModal'); + if (!modal) return; + + modal.addEventListener('click', (event) => { + const item = event.target.closest('[data-action]'); + if (!item) return; + const action = item.dataset.action; + if (action === 'cancel-delete-folder') { + this.hideDeleteFolderModal(); + } else if (action === 'confirm-delete-folder') { + this.handleDeleteFolderConfirm(); + } + }); + + this._deleteFolderModalWired = true; + } + saveSelectedFolder() { setStorageItem(`${this.pageType}_activeFolder`, this.selectedPath); } @@ -866,6 +1052,9 @@ export class SidebarManager { if (hideToggle) { hideToggle.addEventListener('click', this.handleHideToggle); } + + // Folder delete confirmation modal buttons + this._wireDeleteFolderModal(); } handleDocumentClick(event) { @@ -1240,6 +1429,12 @@ export class SidebarManager { createItem.style.display = this._supportsFolderManagement() ? '' : 'none'; } + // Deletion is gated the same way: recipes have virtual folders only. + const deleteItem = menu.querySelector('[data-action="delete-folder"]'); + if (deleteItem) { + deleteItem.style.display = this._supportsFolderManagement() ? '' : 'none'; + } + menu.style.left = `${x}px`; menu.style.top = `${y}px`; menu.style.display = 'block'; @@ -1288,6 +1483,9 @@ export class SidebarManager { case 'create-subfolder': this.showCreateFolderInput(path); break; + case 'delete-folder': + this.showDeleteFolderModal(path); + break; case 'check-folder-updates': try { await performFolderUpdateCheck(path); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 45ad2cc3..20d5eda3 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -243,6 +243,18 @@ export class ModalManager { }); } + // Add deleteFolderModal registration + const deleteFolderModal = document.getElementById('deleteFolderModal'); + if (deleteFolderModal) { + this.registerModal('deleteFolderModal', { + element: deleteFolderModal, + onClose: () => { + this.getModal('deleteFolderModal').element.classList.remove('show'); + document.body.classList.remove('modal-open'); + } + }); + } + // Add helpModal registration const helpModal = document.getElementById('helpModal'); if (helpModal) { @@ -441,6 +453,7 @@ export class ModalManager { id === "clearCacheModal" || id === "bulkDeleteModal" || id === "checkUpdatesConfirmModal" || + id === "deleteFolderModal" || id === "resolveFilenameConflictsModal" ) { modal.element.classList.add("show"); diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 41f7e4ae..d2e1d7ae 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -212,6 +212,10 @@
{{ t('sidebar.folderUpdateCheck.label') }}
+
+
+ {{ t('sidebar.deleteFolder') }} +
diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index d613fffd..f49d3aa2 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -82,6 +82,21 @@ + + + +