diff --git a/locales/de.json b/locales/de.json index 80bc5e9f..e9fe6402 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "Stammverzeichnis", "collapseAll": "Alle Ordner einklappen", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden", "showSidebar": "Seitenleiste anzeigen", "sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet", - "switchToListView": "Zur Listenansicht wechseln", - "switchToTreeView": "Zur Baumansicht wechseln", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "Unterordner einbeziehen", - "recursiveOff": "Nur aktueller Ordner", - "recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", - "collapseAllDisabled": "Im Listenmodus nicht verfügbar", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation 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.", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "Keine Ordner gefunden", - "dragHint": "Elemente hierher ziehen, um Ordner zu erstellen" + "dragHint": "Elemente hierher ziehen, um Ordner zu erstellen", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "Auf Updates in diesem Ordner prüfen", diff --git a/locales/en.json b/locales/en.json index 1331bde6..183ccd12 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "Root", "collapseAll": "Collapse All Folders", + "collapseAllDisabled": "Not available in list view", "hideOnThisPage": "Hide sidebar on this page", "showSidebar": "Show sidebar", "sidebarHiddenNotification": "Folder sidebar hidden on {page} page", - "switchToListView": "Switch to List View", - "switchToTreeView": "Switch to Tree View", + "viewOptions": "View options", + "treeView": "Tree view", + "listView": "List view", "recursiveOn": "Include subfolders", - "recursiveOff": "Current folder only", - "recursiveUnavailable": "Recursive search is available in tree view only", - "collapseAllDisabled": "Not available in list view", + "createFolder": "New folder", + "newSubfolder": "New subfolder", + "showEmptyFolders": "Show empty folders", + "createFolderResult": { + "success": "Folder \"{name}\" created", + "failed": "Failed to create folder: {message}", + "unsupported": "Folder creation 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.", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "No folders found", - "dragHint": "Drag items here to create folders" + "dragHint": "Drag items here to create folders", + "createHint": "Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "Check for updates in this folder", diff --git a/locales/es.json b/locales/es.json index 3dc25c5e..71d7be61 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "Raíz", "collapseAll": "Colapsar todas las carpetas", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "Ocultar barra lateral en esta página", "showSidebar": "Mostrar barra lateral", "sidebarHiddenNotification": "Barra lateral oculta en la página {page}", - "switchToListView": "Cambiar a vista de lista", - "switchToTreeView": "Cambiar a vista de árbol", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "Incluir subcarpetas", - "recursiveOff": "Solo carpeta actual", - "recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", - "collapseAllDisabled": "No disponible en vista de lista", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation 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.", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "No se encontraron carpetas", - "dragHint": "Arrastra elementos aquí para crear carpetas" + "dragHint": "Arrastra elementos aquí para crear carpetas", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "Buscar actualizaciones en esta carpeta", diff --git a/locales/fr.json b/locales/fr.json index 2da2366a..35d5bcba 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "Racine", "collapseAll": "Réduire tous les dossiers", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "Masquer la barre latérale sur cette page", "showSidebar": "Afficher la barre latérale", "sidebarHiddenNotification": "Barre latérale masquée sur la page {page}", - "switchToListView": "Passer en vue liste", - "switchToTreeView": "Passer en vue arborescence", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "Inclure les sous-dossiers", - "recursiveOff": "Dossier actuel uniquement", - "recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", - "collapseAllDisabled": "Non disponible en vue liste", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation 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.", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "Aucun dossier trouvé", - "dragHint": "Faites glisser des éléments ici pour créer des dossiers" + "dragHint": "Faites glisser des éléments ici pour créer des dossiers", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "Vérifier les mises à jour dans ce dossier", diff --git a/locales/he.json b/locales/he.json index 1acef3ff..5a7b91e2 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "שורש", "collapseAll": "כווץ את כל התיקיות", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "הסתר סרגל צד בדף זה", "showSidebar": "הצג סרגל צד", "sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}", - "switchToListView": "עבור לתצוגת רשימה", - "switchToTreeView": "תצוגת עץ", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "כלול תיקיות משנה", - "recursiveOff": "רק התיקייה הנוכחית", - "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", - "collapseAllDisabled": "לא זמין בתצוגת רשימה", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "לא נמצאו תיקיות", - "dragHint": "גרור פריטים לכאן כדי ליצור תיקיות" + "dragHint": "גרור פריטים לכאן כדי ליצור תיקיות", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "בדוק עדכונים בתיקייה זו", diff --git a/locales/ja.json b/locales/ja.json index eb637b7f..e39c2fa1 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "ルート", "collapseAll": "すべてのフォルダを折りたたむ", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "このページでサイドバーを非表示", "showSidebar": "サイドバーを表示", "sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています", - "switchToListView": "リストビューに切り替え", - "switchToTreeView": "ツリー表示に切り替え", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "サブフォルダーを含める", - "recursiveOff": "現在のフォルダーのみ", - "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", - "collapseAllDisabled": "リストビューでは利用できません", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "移動先のパスを特定できません。", "moveUnsupported": "この項目の移動はサポートされていません。", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "フォルダが見つかりません", - "dragHint": "ここへアイテムをドラッグしてフォルダを作成します" + "dragHint": "ここへアイテムをドラッグしてフォルダを作成します", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "このフォルダのアップデートを確認", diff --git a/locales/ko.json b/locales/ko.json index 96b6dcd4..7c224c20 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "루트", "collapseAll": "모든 폴더 접기", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "이 페이지에서 사이드바 숨기기", "showSidebar": "사이드바 표시", "sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다", - "switchToListView": "목록 보기로 전환", - "switchToTreeView": "트리 보기로 전환", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "하위 폴더 포함", - "recursiveOff": "현재 폴더만", - "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", - "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "폴더를 찾을 수 없습니다", - "dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다" + "dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "이 폴더의 업데이트 확인", diff --git a/locales/ru.json b/locales/ru.json index cf146742..5a4c18ba 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "Корень", "collapseAll": "Свернуть все папки", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "Скрыть боковую панель на этой странице", "showSidebar": "Показать боковую панель", "sidebarHiddenNotification": "Боковая панель скрыта на странице {page}", - "switchToListView": "Переключить на вид списка", - "switchToTreeView": "Переключить на древовидный вид", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "Включать вложенные папки", - "recursiveOff": "Только текущая папка", - "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", - "collapseAllDisabled": "Недоступно в виде списка", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "moveUnsupported": "Перемещение этого элемента не поддерживается.", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "Папки не найдены", - "dragHint": "Перетащите элементы сюда, чтобы создать папки" + "dragHint": "Перетащите элементы сюда, чтобы создать папки", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "Проверить обновления в этой папке", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b348519b..03893ba9 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "根目录", "collapseAll": "折叠所有文件夹", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "隐藏此页面侧边栏", "showSidebar": "显示侧边栏", "sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏", - "switchToListView": "切换到列表视图", - "switchToTreeView": "切换到树状视图", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "包含子文件夹", - "recursiveOff": "仅当前文件夹", - "recursiveUnavailable": "仅在树形视图中可使用递归搜索", - "collapseAllDisabled": "列表视图下不可用", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "无法确定移动的目标路径。", "moveUnsupported": "此条目不支持移动。", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "未找到文件夹", - "dragHint": "拖拽项目到此处以创建文件夹" + "dragHint": "拖拽项目到此处以创建文件夹", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "检查此文件夹的更新", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 114ae125..9ac896e3 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1246,15 +1246,23 @@ "sidebar": { "modelRoot": "根目錄", "collapseAll": "全部摺疊資料夾", + "collapseAllDisabled": "[TODO: Translate] Not available in list view", "hideOnThisPage": "隱藏此頁面側邊欄", "showSidebar": "顯示側邊欄", "sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏", - "switchToListView": "切換至列表檢視", - "switchToTreeView": "切換到樹狀檢視", + "viewOptions": "[TODO: Translate] View options", + "treeView": "[TODO: Translate] Tree view", + "listView": "[TODO: Translate] List view", "recursiveOn": "包含子資料夾", - "recursiveOff": "僅目前資料夾", - "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", - "collapseAllDisabled": "列表檢視下不可用", + "createFolder": "[TODO: Translate] New folder", + "newSubfolder": "[TODO: Translate] New subfolder", + "showEmptyFolders": "[TODO: Translate] Show empty folders", + "createFolderResult": { + "success": "[TODO: Translate] Folder \"{name}\" created", + "failed": "[TODO: Translate] Failed to create folder: {message}", + "unsupported": "[TODO: Translate] Folder creation is not supported on this page", + "noRoot": "[TODO: Translate] No model root is configured" + }, "dragDrop": { "unableToResolveRoot": "無法確定移動的目標路徑。", "moveUnsupported": "此項目不支援移動。", @@ -1267,7 +1275,8 @@ }, "empty": { "noFolders": "未找到資料夾", - "dragHint": "將項目拖到此處以建立資料夾" + "dragHint": "將項目拖到此處以建立資料夾", + "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" }, "folderUpdateCheck": { "label": "檢查此資料夾的更新", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 71fd7e26..beb4e9c7 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2479,6 +2479,26 @@ class ModelMoveHandler: self._move_service = move_service self._logger = logger + async def create_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 + ) + result = await self._move_service.create_folder(folder_path) + status = 200 if result.get("success") else 400 + return web.json_response(result, status=status) + except Exception as exc: + self._logger.error("Error creating 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() @@ -3429,6 +3449,7 @@ class ModelHandlerSet: "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "move_model": self.move.move_model, "move_models_bulk": self.move.move_models_bulk, + "create_folder": self.move.create_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 8796d3f9..7fa536c7 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -40,6 +40,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"), 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("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 9a7a5848..88ddc15a 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -473,17 +473,69 @@ class ModelFileService: class ModelMoveService: """Service for handling individual model moves""" - + def __init__(self, scanner, model_type: str): """Initialize the service - + Args: scanner: Model scanner instance model_type: Type of model (e.g., 'lora', 'checkpoint') """ self.scanner = scanner self.model_type = model_type - + + async def create_folder(self, folder_path: str) -> Dict[str, Any]: + """Create a directory inside the model library roots. + + Args: + folder_path: Absolute path of the directory to create (business + path — symlinks are not resolved) + + Returns: + Dictionary with success flag, the created path and the + library-relative folder name used by folder trees. + """ + 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) + already_exists = os.path.isdir(absolute_path) + os.makedirs(absolute_path, exist_ok=True) + + relative_folder = self._calculate_relative_folder(absolute_path) + if relative_folder: + await self.scanner.add_known_folder(relative_folder) + + return { + "success": True, + "folder_path": absolute_path.replace(os.sep, "/"), + "folder": relative_folder, + "created": not already_exists, + } + except ValueError as exc: + return {"success": False, "error": str(exc)} + except Exception as exc: + logger.error(f"Error creating folder: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + def _calculate_relative_folder(self, absolute_path: str) -> str: + """Return the library-relative folder for an absolute directory path.""" + normalized = os.path.abspath(absolute_path) + for root in self.scanner.get_model_roots(): + abs_root = os.path.abspath(root) + try: + rel = os.path.relpath(normalized, abs_root) + except ValueError: + continue + if rel == ".": + return "" + if not rel.startswith(".."): + return rel.replace(os.sep, "/") + return "" + 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 00ba5585..919ccc98 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -1477,6 +1477,34 @@ class ModelScanner: return sorted(folders, key=lambda x: x.lower()) + async def add_known_folder(self, folder: str) -> None: + """Record a folder (and its parents) in the known folder list. + + Called when a directory is created between scans (e.g. via the + create-folder API) so folder trees reflect it immediately without + waiting for the next reconciliation. When ``all_folders`` has not + been recorded yet (legacy snapshot), this is a no-op — the scheduled + backfill walk discovers the directory from disk instead. + """ + normalized = folder.replace("\\", "/").strip("/") + parts = [part for part in normalized.split("/") if part] + if not parts: + return + cache = self._cache + if cache is None: + return + recorded = getattr(cache, "all_folders", None) + if recorded is None: + return + known = set(recorded) + for i in range(1, len(parts) + 1): + known.add("/".join(parts[:i])) + updated = sorted(known, key=lambda x: x.lower()) + if updated != list(recorded): + cache.all_folders = updated + await self._persist_current_cache() + self.bump_cache_version() + 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/css/components/sidebar.css b/static/css/components/sidebar.css index 69fe411d..e52232a0 100644 --- a/static/css/components/sidebar.css +++ b/static/css/components/sidebar.css @@ -228,6 +228,18 @@ border-left-color: var(--lora-accent); } +/* Empty folders (no models) shown when the empty-folders toggle is on */ +.sidebar-tree-node-content.empty .sidebar-tree-folder-name, +.sidebar-node-content.empty .sidebar-folder-name { + opacity: 0.55; + font-style: italic; +} + +.sidebar-tree-node-content.empty .sidebar-tree-folder-icon, +.sidebar-node-content.empty .sidebar-folder-icon { + opacity: 0.45; +} + .sidebar-tree-node-content.drop-target .sidebar-tree-folder-icon, .sidebar-node-content.drop-target .sidebar-folder-icon { color: var(--lora-accent); @@ -666,10 +678,11 @@ /* Create folder input container */ .sidebar-create-folder-input-container { - position: absolute; - bottom: 16px; - left: 16px; - right: 16px; + /* Sticky footer inside the scroll container: always visible at the + bottom of the viewport regardless of tree scroll position */ + position: sticky; + bottom: 8px; + margin: 8px 16px 0; padding: 12px; background: var(--bg-color); border: 1px solid var(--border-color); @@ -703,6 +716,7 @@ .sidebar-create-folder-input { flex: 1; + min-width: 0; /* allow the input to shrink below its intrinsic width */ padding: 6px 10px; border: 1px solid var(--border-color); border-radius: var(--border-radius-xs); diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 41e38f77..15b5bde0 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -22,6 +22,7 @@ export const MODEL_CONFIG = { supportsLetterFilter: true, supportsBulkOperations: true, supportsMove: true, + supportsFolderManagement: true, templateName: 'loras.html' }, [MODEL_TYPES.CHECKPOINT]: { @@ -31,6 +32,7 @@ export const MODEL_CONFIG = { supportsLetterFilter: false, supportsBulkOperations: true, supportsMove: true, + supportsFolderManagement: true, templateName: 'checkpoints.html' }, [MODEL_TYPES.EMBEDDING]: { @@ -40,6 +42,7 @@ export const MODEL_CONFIG = { supportsLetterFilter: true, supportsBulkOperations: true, supportsMove: true, + supportsFolderManagement: true, templateName: 'embeddings.html' }, [MODEL_TYPES.OTHER]: { @@ -49,6 +52,7 @@ export const MODEL_CONFIG = { supportsLetterFilter: false, supportsBulkOperations: true, supportsMove: true, + supportsFolderManagement: true, templateName: 'other.html' } }; @@ -79,6 +83,7 @@ export function getApiEndpoints(modelType) { // Move operations (now common for all model types that support move) moveModel: `/api/lm/${modelType}/move_model`, moveBulk: `/api/lm/${modelType}/move_models_bulk`, + createFolder: `/api/lm/${modelType}/create-folder`, // CivitAI integration fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 6b3333a6..b495fedc 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1295,9 +1295,13 @@ export class BaseModelApiClient { } } - async fetchModelFolders() { + async fetchModelFolders(options = {}) { try { - const response = await fetch(this.apiConfig.endpoints.folders); + const { includeEmpty = false } = options || {}; + const url = includeEmpty + ? `${this.apiConfig.endpoints.folders}?include_empty=1` + : this.apiConfig.endpoints.folders; + const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`); } @@ -1308,6 +1312,24 @@ export class BaseModelApiClient { } } + async createFolder(folderPath) { + const response = await fetch(this.apiConfig.endpoints.createFolder, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ folder_path: folderPath }) + }); + + const result = await response.json().catch(() => ({})); + + if (!response.ok || result.success === false) { + throw new Error(result.error || `Failed to create folder`); + } + + 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 19cda3f1..c87ce7d9 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -44,6 +44,9 @@ export class SidebarManager { this.initializationPromise = null; this.isCreatingFolder = false; this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态 + this.showEmptyFolders = false; + this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes + this._createFolderBasePath = null; // Bind methods this.handleTreeClick = this.handleTreeClick.bind(this); @@ -56,6 +59,9 @@ export class SidebarManager { this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this); this.handleFolderListClick = this.handleFolderListClick.bind(this); this.handleRecursiveToggle = this.handleRecursiveToggle.bind(this); + this.handleEmptyFoldersToggle = this.handleEmptyFoldersToggle.bind(this); + this.handleCreateFolderButton = this.handleCreateFolderButton.bind(this); + this.handleViewOptionsButton = this.handleViewOptionsButton.bind(this); this.handleCardDragStart = this.handleCardDragStart.bind(this); this.handleCardDragEnd = this.handleCardDragEnd.bind(this); this.handleFolderDragEnter = this.handleFolderDragEnter.bind(this); @@ -138,6 +144,9 @@ export class SidebarManager { this.apiClient = null; this.isInitialized = false; this.recursiveSearchEnabled = true; + this.showEmptyFolders = false; + this.nonEmptyFolders = null; + this._createFolderBasePath = null; // Reset container margin const container = document.querySelector('.container'); @@ -153,16 +162,10 @@ export class SidebarManager { } removeEventHandlers() { - const collapseAllBtn = document.getElementById('sidebarCollapseAll'); const folderTree = document.getElementById('sidebarFolderTree'); const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav'); const sidebarHeader = document.getElementById('sidebarHeader'); - const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle'); - const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle'); - if (collapseAllBtn) { - collapseAllBtn.removeEventListener('click', this.handleCollapseAll); - } if (folderTree) { folderTree.removeEventListener('click', this.handleTreeClick); folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu); @@ -181,11 +184,20 @@ export class SidebarManager { // Remove resize event handler window.removeEventListener('resize', this.updateContainerMargin); - if (displayModeToggleBtn) { - displayModeToggleBtn.removeEventListener('click', this.handleDisplayModeToggle); + const viewOptionsBtn = document.getElementById('sidebarViewOptions'); + if (viewOptionsBtn) { + viewOptionsBtn.removeEventListener('click', this.handleViewOptionsButton); } - if (recursiveToggleBtn) { - recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle); + this._closeViewOptionsMenu(); + + const createFolderBtn = document.getElementById('sidebarCreateFolder'); + if (createFolderBtn) { + createFolderBtn.removeEventListener('click', this.handleCreateFolderButton); + } + + const collapseAllBtn = document.getElementById('sidebarCollapseAll'); + if (collapseAllBtn) { + collapseAllBtn.removeEventListener('click', this.handleCollapseAll); } const hideToggle = document.getElementById('sidebarHideToggle'); @@ -773,16 +785,20 @@ export class SidebarManager { } } - showCreateFolderInput() { + showCreateFolderInput(basePath = null) { console.log('[SidebarManager] showCreateFolderInput called'); this.isCreatingFolder = true; - + + // The folder is created under the given base path; falls back to the + // currently selected folder (drag-and-drop flow) or the root. + this._createFolderBasePath = basePath !== null ? basePath : (this.selectedPath || ''); + // 立即保存拖拽状态,防止后续事件(如blur)清空状态 - this._pendingDragState = { - filePaths: this.draggedFilePaths ? [...this.draggedFilePaths] : null, + this._pendingDragState = this.draggedFilePaths && this.draggedFilePaths.length > 0 ? { + filePaths: [...this.draggedFilePaths], rootPath: this.draggedRootPath, fromBulk: this.draggedFromBulk - }; + } : null; console.log('[SidebarManager] saved pending drag state:', this._pendingDragState); this.hideCreateFolderZone(); @@ -907,24 +923,25 @@ export class SidebarManager { return; } - // Build target path - use selected path as parent, or root if none selected - const parentPath = this.selectedPath || ''; + // Build target path - use the base path captured when the input was + // opened (context-menu folder or current selection), or root + const parentPath = this._createFolderBasePath || ''; const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName; console.log('[SidebarManager] targetRelativePath:', targetRelativePath); // 使用 showCreateFolderInput 时保存的拖拽状态 const pendingState = this._pendingDragState; console.log('[SidebarManager] using pending drag state:', pendingState); - - if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) { - console.log('[SidebarManager] no pending drag state found, cannot proceed'); - showToast('sidebar.dragDrop.noDragState', {}, 'error'); - this.hideCreateFolderInput(); - return; - } this.hideCreateFolderInput(); + if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) { + // Standalone creation (header button / context menu): create an + // empty folder on disk instead of moving dragged models into it. + await this._createFolder(targetRelativePath, parentPath); + return; + } + // Perform the move with saved state console.log('[SidebarManager] calling performDragMove with pending state'); const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk); @@ -947,6 +964,65 @@ export class SidebarManager { this.clearAllDropHighlights(); } + async _createFolder(targetRelativePath, parentPath) { + if (!this._supportsFolderManagement() || typeof this.apiClient.createFolder !== 'function') { + showToast('sidebar.createFolderResult.unsupported', {}, 'error'); + return false; + } + + try { + const rootsData = await this.apiClient.fetchModelRoots(); + const roots = rootsData?.roots || []; + const root = this._resolveDefaultRoot(roots); + if (!root) { + showToast('sidebar.createFolderResult.noRoot', {}, 'error'); + return false; + } + + const absolutePath = this.combineRootAndRelativePath(root, targetRelativePath); + const result = await this.apiClient.createFolder(absolutePath); + + // The new folder has no models yet — enable empty-folder display + // so it shows up in the tree immediately. + if (!this.showEmptyFolders) { + this.showEmptyFolders = true; + setStorageItem(`${this.pageType}_showEmptyFolders`, true); + this.updateViewOptionsMenu(); + } + + // Expand the parent folder to reveal the new node + if (parentPath) { + this.expandedNodes.add(parentPath); + this.saveExpandedState(); + } + + await this.refresh(); + + showToast('sidebar.createFolderResult.success', { name: result.folder || targetRelativePath }, 'success'); + return true; + } catch (error) { + console.error('[SidebarManager] Error creating folder:', error); + showToast('sidebar.createFolderResult.failed', { message: error.message || 'Unknown error' }, 'error'); + return false; + } + } + + _resolveDefaultRoot(roots) { + if (!roots || roots.length === 0) { + return ''; + } + + const singularName = this.apiClient?.apiConfig?.config?.singularName; + const defaultRoot = singularName + ? state.global?.settings?.[`default_${singularName}_root`] + : ''; + if (defaultRoot && roots.includes(defaultRoot)) { + return defaultRoot; + } + + return roots[0]; + } + handleCreateFolderCancel() { this.hideCreateFolderInput(); // 清理待处理的拖拽状态 @@ -979,18 +1055,37 @@ export class SidebarManager { sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick); } + // View options menu button + const viewOptionsBtn = document.getElementById('sidebarViewOptions'); + if (viewOptionsBtn) { + viewOptionsBtn.addEventListener('click', this.handleViewOptionsButton); + } + + // View options menu items + const viewOptionsMenu = document.getElementById('sidebarViewOptionsMenu'); + if (viewOptionsMenu) { + viewOptionsMenu.addEventListener('click', (e) => { + const item = e.target.closest('.context-menu-item'); + if (!item || item.classList.contains('disabled')) return; + const action = item.dataset.action; + if (action) { + this.handleViewOptionsAction(action); + } + }); + } + + // Create folder button + const createFolderBtn = document.getElementById('sidebarCreateFolder'); + if (createFolderBtn) { + createFolderBtn.addEventListener('click', this.handleCreateFolderButton); + } + // Collapse all button const collapseAllBtn = document.getElementById('sidebarCollapseAll'); if (collapseAllBtn) { collapseAllBtn.addEventListener('click', this.handleCollapseAll); } - // Recursive toggle button - const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle'); - if (recursiveToggleBtn) { - recursiveToggleBtn.addEventListener('click', this.handleRecursiveToggle); - } - // Tree click handler const folderTree = document.getElementById('sidebarFolderTree'); if (folderTree) { @@ -1025,12 +1120,6 @@ export class SidebarManager { // Add dedicated resize listener for container margin updates window.addEventListener('resize', this.updateContainerMargin); - // Display mode toggle button - const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle'); - if (displayModeToggleBtn) { - displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle); - } - // Sidebar folder context menu click handler const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu'); if (sidebarFolderMenu) { @@ -1071,7 +1160,7 @@ export class SidebarManager { } handleCollapseAll(event) { - event.stopPropagation(); + event?.stopPropagation(); this.expandedNodes.clear(); this.renderFolderDisplay(); this.saveExpandedState(); @@ -1173,12 +1262,35 @@ export class SidebarManager { async loadFolderTree() { try { + const includeEmpty = this.showEmptyFolders && this._supportsFolderManagement(); if (this.displayMode === 'tree') { - const response = await this.apiClient.fetchUnifiedFolderTree(); - this.treeData = response.tree || {}; + if (includeEmpty) { + // Fetch the models-only folder list alongside so empty + // directories can be dimmed in the tree. + const [treeResponse, foldersResponse] = await Promise.all([ + this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true }), + this.apiClient.fetchModelFolders(), + ]); + this.treeData = treeResponse.tree || {}; + this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []); + } else { + const response = await this.apiClient.fetchUnifiedFolderTree(); + this.treeData = response.tree || {}; + this.nonEmptyFolders = null; + } } else { - const response = await this.apiClient.fetchModelFolders(); - this.foldersList = response.folders || []; + if (includeEmpty) { + const [allFoldersResponse, foldersResponse] = await Promise.all([ + this.apiClient.fetchModelFolders({ includeEmpty: true }), + this.apiClient.fetchModelFolders(), + ]); + this.foldersList = allFoldersResponse.folders || []; + this.nonEmptyFolders = this._buildNonEmptyFolderSet(foldersResponse.folders || []); + } else { + const response = await this.apiClient.fetchModelFolders(); + this.foldersList = response.folders || []; + this.nonEmptyFolders = null; + } } this.folderTreeLoaded = true; this.renderFolderDisplay(); @@ -1189,6 +1301,27 @@ export class SidebarManager { } } + _supportsFolderManagement() { + return Boolean(this.apiClient?.apiConfig?.config?.supportsFolderManagement); + } + + // The models-only folder list only contains directories that directly + // hold model files. Expand it with every ancestor prefix so a folder + // whose subtree contains models (e.g. "a" with models in "a/b") is not + // dimmed as empty — the "empty" style means "no models anywhere below". + _buildNonEmptyFolderSet(folders) { + const set = new Set(); + for (const folder of folders) { + set.add(folder); + if (!folder) continue; + const parts = folder.split('/'); + for (let i = 1; i < parts.length; i++) { + set.add(parts.slice(0, i).join('/')); + } + } + return set; + } + folderExistsInTree(path) { if (!path) return true; @@ -1236,6 +1369,7 @@ export class SidebarManager { const hasChildren = Object.keys(children).length > 0; const isExpanded = this.expandedNodes.has(currentPath); const isSelected = this.selectedPath === currentPath; + const isEmpty = this.nonEmptyFolders ? !this.nonEmptyFolders.has(currentPath) : false; const escapedPath = escapeAttribute(currentPath); const escapedFolderName = escapeHtml(folderName); @@ -1243,12 +1377,12 @@ export class SidebarManager { return `