mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
Compare commits
7 Commits
9734df15b4
...
c6c44b741a
| Author | SHA1 | Date | |
|---|---|---|---|
| c6c44b741a | |||
| 5095b23eb2 | |||
| cc25bb3dc2 | |||
| 3b54a13cae | |||
| 9bbe57ee85 | |||
| 4938faa049 | |||
| cc8eedcff7 |
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||
`locales/`.
|
||||
|
||||
Source of truth: `locales/en.json` (10 locales, 1982 leaf keys; all locales share the exact
|
||||
Source of truth: `locales/en.json` (10 locales, 2022 leaf keys; all locales share the exact
|
||||
same key structure).
|
||||
|
||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
@@ -42,6 +42,13 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> which had hardcoded "HF" for a button that now also enriches ModelScope models. The
|
||||
> `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL,
|
||||
> the §6 exception). Terminology in §2, "Model source feature".
|
||||
>
|
||||
> **Status (2026-09, folder sidebar):** the model-root sidebar gained on-disk folder management
|
||||
> (create / rename / delete folders, show empty folders, tree vs list view) plus its `...`
|
||||
> view-options menu, adding 35 `sidebar.*` keys. Those were the only `[TODO: Translate]`
|
||||
> placeholders left behind by the feature series, and all 35 are now translated in all 9
|
||||
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
|
||||
> "Folder sidebar feature".
|
||||
|
||||
---
|
||||
|
||||
@@ -330,6 +337,24 @@ in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale m
|
||||
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
|
||||
(the key names keep the historical `Hf`; only the values changed).
|
||||
|
||||
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
|
||||
|
||||
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
|
||||
(the `folder key` row); the rest is new surface:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| folder | zh-CN 文件夹 · zh-TW 資料夾 · ja フォルダ · ko 폴더 · fr dossier · de Ordner · es carpeta · ru папка · he תיקייה |
|
||||
| model root (as in "no model root is configured") | zh-CN 模型根目录 · zh-TW 模型根目錄 · ja モデルルート · ko 모델 루트 · fr racine de modèle · de Modell-Stammverzeichnis · es raíz de modelo · ru корневая папка моделей · he שורש מודלים — note `sidebar.modelRoot` alone is the shorter 根目录 / 根目錄 / ルート / 루트 / Racine / Stammverzeichnis / Raíz / Корень / שורש |
|
||||
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
|
||||
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
|
||||
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and
|
||||
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
|
||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "In der Listenansicht nicht verfügbar",
|
||||
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
||||
"showSidebar": "Seitenleiste anzeigen",
|
||||
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "Ansichtsoptionen",
|
||||
"treeView": "Baumansicht",
|
||||
"listView": "Listenansicht",
|
||||
"recursiveOn": "Unterordner einbeziehen",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "Neuer Ordner",
|
||||
"newSubfolder": "Neuer Unterordner",
|
||||
"showEmptyFolders": "Leere Ordner anzeigen",
|
||||
"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"
|
||||
"success": "Ordner \"{name}\" erstellt",
|
||||
"failed": "Ordner konnte nicht erstellt werden: {message}",
|
||||
"unsupported": "Das Erstellen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
},
|
||||
"deleteFolder": "Ordner löschen",
|
||||
"deleteFolderModal": {
|
||||
"title": "Ordner löschen?",
|
||||
"message": "Der Ordner und sein gesamter Inhalt werden endgültig vom Datenträger gelöscht.",
|
||||
"folderLabel": "Ordner",
|
||||
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
|
||||
"notEmptyTitle": "Ordner ist nicht leer",
|
||||
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"confirm": "Ordner löschen"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Ordner \"{name}\" gelöscht",
|
||||
"successWithFiles": "Ordner \"{name}\" sowie {count} weitere(s) Element(e) gelöscht",
|
||||
"restored": "Ordner wiederhergestellt",
|
||||
"failed": "Ordner konnte nicht gelöscht werden: {message}",
|
||||
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
},
|
||||
"renameFolder": "Ordner umbenennen",
|
||||
"renameFolderResult": {
|
||||
"success": "Ordner umbenannt in \"{name}\"",
|
||||
"failed": "Ordner konnte nicht umbenannt werden: {message}",
|
||||
"targetExists": "Ein Ordner mit diesem Namen ist hier bereits vorhanden",
|
||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||
"unsupported": "Das Umbenennen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
|
||||
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
|
||||
"newFolderName": "Neuer Ordnername",
|
||||
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
|
||||
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
|
||||
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
|
||||
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "Klicken Sie oben auf „Neuer Ordner“, um Ordner zu erstellen"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Auf Updates in diesem Ordner prüfen",
|
||||
|
||||
+30
-4
@@ -1263,20 +1263,46 @@
|
||||
"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"
|
||||
},
|
||||
"renameFolder": "Rename folder",
|
||||
"renameFolderResult": {
|
||||
"success": "Folder renamed to \"{name}\"",
|
||||
"failed": "Failed to rename folder: {message}",
|
||||
"targetExists": "A folder with that name already exists here",
|
||||
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||
"unsupported": "Folder renaming 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.",
|
||||
"createFolderHint": "Release to create new folder",
|
||||
"newFolderName": "New folder name",
|
||||
"folderNameHint": "Press Enter to confirm, Escape to cancel",
|
||||
"emptyFolderName": "Please enter a folder name",
|
||||
"invalidFolderName": "Folder name contains invalid characters",
|
||||
"noDragState": "No pending drag operation found"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "No folders found",
|
||||
"dragHint": "Drag items here to create folders",
|
||||
"createHint": "Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "Click the New Folder button above to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Check for updates in this folder",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "No disponible en la vista de lista",
|
||||
"hideOnThisPage": "Ocultar barra lateral en esta página",
|
||||
"showSidebar": "Mostrar barra lateral",
|
||||
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "Opciones de vista",
|
||||
"treeView": "Vista de árbol",
|
||||
"listView": "Vista de lista",
|
||||
"recursiveOn": "Incluir subcarpetas",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "Nueva carpeta",
|
||||
"newSubfolder": "Nueva subcarpeta",
|
||||
"showEmptyFolders": "Mostrar carpetas vacías",
|
||||
"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"
|
||||
"success": "Carpeta \"{name}\" creada",
|
||||
"failed": "Error al crear la carpeta: {message}",
|
||||
"unsupported": "La creación de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
},
|
||||
"deleteFolder": "Eliminar carpeta",
|
||||
"deleteFolderModal": {
|
||||
"title": "¿Eliminar carpeta?",
|
||||
"message": "La carpeta y todo su contenido se eliminarán permanentemente del disco.",
|
||||
"folderLabel": "Carpeta",
|
||||
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
|
||||
"notEmptyTitle": "La carpeta no está vacía",
|
||||
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"confirm": "Eliminar carpeta"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Carpeta \"{name}\" eliminada",
|
||||
"successWithFiles": "Carpeta \"{name}\" eliminada junto con {count} elemento(s) más",
|
||||
"restored": "Carpeta restaurada",
|
||||
"failed": "Error al eliminar la carpeta: {message}",
|
||||
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||
"unsupported": "La eliminación de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
},
|
||||
"renameFolder": "Cambiar nombre de la carpeta",
|
||||
"renameFolderResult": {
|
||||
"success": "Carpeta renombrada a \"{name}\"",
|
||||
"failed": "Error al cambiar el nombre de la carpeta: {message}",
|
||||
"targetExists": "Ya existe una carpeta con ese nombre aquí",
|
||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||
"unsupported": "El cambio de nombre de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||
"moveUnsupported": "El movimiento no es compatible con este elemento.",
|
||||
"createFolderHint": "Suelta para crear una nueva carpeta",
|
||||
"newFolderName": "Nombre de la nueva carpeta",
|
||||
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
|
||||
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
|
||||
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
|
||||
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "No se encontraron carpetas",
|
||||
"dragHint": "Arrastra elementos aquí para crear carpetas",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "Haz clic en el botón Nueva carpeta de arriba para crear carpetas"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Buscar actualizaciones en esta carpeta",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"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}",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "Options d’affichage",
|
||||
"treeView": "Vue arborescente",
|
||||
"listView": "Vue liste",
|
||||
"recursiveOn": "Inclure les sous-dossiers",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "Nouveau dossier",
|
||||
"newSubfolder": "Nouveau sous-dossier",
|
||||
"showEmptyFolders": "Afficher les dossiers vides",
|
||||
"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"
|
||||
"success": "Dossier \"{name}\" créé",
|
||||
"failed": "Échec de la création du dossier : {message}",
|
||||
"unsupported": "La création de dossiers n’est pas prise en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
},
|
||||
"deleteFolder": "Supprimer le dossier",
|
||||
"deleteFolderModal": {
|
||||
"title": "Supprimer le dossier ?",
|
||||
"message": "Le dossier et tout son contenu seront définitivement supprimés du disque.",
|
||||
"folderLabel": "Dossier",
|
||||
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers qu’il contient seront également supprimés.",
|
||||
"notEmptyTitle": "Le dossier n’est pas vide",
|
||||
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"confirm": "Supprimer le dossier"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Dossier \"{name}\" supprimé",
|
||||
"successWithFiles": "Dossier \"{name}\" supprimé, ainsi que {count} autre(s) élément(s)",
|
||||
"restored": "Dossier restauré",
|
||||
"failed": "Échec de la suppression du dossier : {message}",
|
||||
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
|
||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
},
|
||||
"renameFolder": "Renommer le dossier",
|
||||
"renameFolderResult": {
|
||||
"success": "Dossier renommé en \"{name}\"",
|
||||
"failed": "Échec du renommage du dossier : {message}",
|
||||
"targetExists": "Un dossier portant ce nom existe déjà ici",
|
||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||
"unsupported": "Le renommage de dossiers n’est pas pris en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
},
|
||||
"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.",
|
||||
"createFolderHint": "Relâcher pour créer un nouveau dossier",
|
||||
"newFolderName": "Nom du nouveau dossier",
|
||||
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
|
||||
"emptyFolderName": "Veuillez saisir un nom de dossier",
|
||||
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
|
||||
"noDragState": "Aucune opération de glissement en attente trouvée"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"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"
|
||||
"createHint": "Cliquez sur le bouton Nouveau dossier ci-dessus pour créer des dossiers"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Vérifier les mises à jour dans ce dossier",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||
"showSidebar": "הצג סרגל צד",
|
||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "אפשרויות תצוגה",
|
||||
"treeView": "תצוגת עץ",
|
||||
"listView": "תצוגת רשימה",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "תיקייה חדשה",
|
||||
"newSubfolder": "תיקיית משנה חדשה",
|
||||
"showEmptyFolders": "הצג תיקיות ריקות",
|
||||
"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"
|
||||
"success": "התיקייה \"{name}\" נוצרה",
|
||||
"failed": "יצירת התיקייה נכשלה: {message}",
|
||||
"unsupported": "יצירת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
},
|
||||
"deleteFolder": "מחק תיקייה",
|
||||
"deleteFolderModal": {
|
||||
"title": "למחוק את התיקייה?",
|
||||
"message": "התיקייה וכל תוכנה יימחקו לצמיתות מהדיסק.",
|
||||
"folderLabel": "תיקייה",
|
||||
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
|
||||
"notEmptyTitle": "התיקייה אינה ריקה",
|
||||
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"confirm": "מחק תיקייה"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "התיקייה \"{name}\" נמחקה",
|
||||
"successWithFiles": "התיקייה \"{name}\" נמחקה יחד עם {count} פריטים נוספים",
|
||||
"restored": "התיקייה שוחזרה",
|
||||
"failed": "מחיקת התיקייה נכשלה: {message}",
|
||||
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
},
|
||||
"renameFolder": "שנה שם תיקייה",
|
||||
"renameFolderResult": {
|
||||
"success": "שם התיקייה שונה ל-\"{name}\"",
|
||||
"failed": "שינוי שם התיקייה נכשל: {message}",
|
||||
"targetExists": "תיקייה בשם זה כבר קיימת כאן",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "שינוי שם תיקיות אינו נתמך בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
||||
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
|
||||
"newFolderName": "שם תיקייה חדשה",
|
||||
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
|
||||
"emptyFolderName": "אנא הזן שם תיקייה",
|
||||
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
|
||||
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "לא נמצאו תיקיות",
|
||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "לחץ על כפתור תיקייה חדשה למעלה כדי ליצור תיקיות"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "בדוק עדכונים בתיקייה זו",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "リスト表示では利用できません",
|
||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||
"showSidebar": "サイドバーを表示",
|
||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "表示オプション",
|
||||
"treeView": "ツリー表示",
|
||||
"listView": "リスト表示",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "新規フォルダ",
|
||||
"newSubfolder": "新規サブフォルダ",
|
||||
"showEmptyFolders": "空のフォルダを表示",
|
||||
"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"
|
||||
"success": "フォルダ \"{name}\" を作成しました",
|
||||
"failed": "フォルダの作成に失敗しました: {message}",
|
||||
"unsupported": "このページではフォルダを作成できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
},
|
||||
"deleteFolder": "フォルダを削除",
|
||||
"deleteFolderModal": {
|
||||
"title": "フォルダを削除しますか?",
|
||||
"message": "フォルダとその内容はすべてディスクから完全に削除されます。",
|
||||
"folderLabel": "フォルダ",
|
||||
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
|
||||
"notEmptyTitle": "フォルダが空ではありません",
|
||||
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"confirm": "フォルダを削除"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "フォルダ \"{name}\" を削除しました",
|
||||
"successWithFiles": "フォルダ \"{name}\" を削除し、他に {count} 件の項目も削除しました",
|
||||
"restored": "フォルダを復元しました",
|
||||
"failed": "フォルダの削除に失敗しました: {message}",
|
||||
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダを削除できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
},
|
||||
"renameFolder": "フォルダ名を変更",
|
||||
"renameFolderResult": {
|
||||
"success": "フォルダ名を \"{name}\" に変更しました",
|
||||
"failed": "フォルダ名の変更に失敗しました: {message}",
|
||||
"targetExists": "同じ名前のフォルダが既に存在します",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダ名を変更できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||
"moveUnsupported": "この項目の移動はサポートされていません。",
|
||||
"createFolderHint": "放して新しいフォルダを作成",
|
||||
"newFolderName": "新しいフォルダ名",
|
||||
"folderNameHint": "Enterで確定、Escでキャンセル",
|
||||
"emptyFolderName": "フォルダ名を入力してください",
|
||||
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
|
||||
"noDragState": "保留中のドラッグ操作が見つかりません"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "フォルダが見つかりません",
|
||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "上部の新規フォルダボタンからフォルダを作成できます"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "このフォルダのアップデートを確認",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||
"showSidebar": "사이드바 표시",
|
||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "보기 옵션",
|
||||
"treeView": "트리 보기",
|
||||
"listView": "목록 보기",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "새 폴더",
|
||||
"newSubfolder": "새 하위 폴더",
|
||||
"showEmptyFolders": "빈 폴더 표시",
|
||||
"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"
|
||||
"success": "\"{name}\" 폴더를 생성했습니다",
|
||||
"failed": "폴더 생성 실패: {message}",
|
||||
"unsupported": "이 페이지에서는 폴더를 만들 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
},
|
||||
"deleteFolder": "폴더 삭제",
|
||||
"deleteFolderModal": {
|
||||
"title": "폴더를 삭제할까요?",
|
||||
"message": "폴더와 그 안의 모든 내용이 디스크에서 영구적으로 삭제됩니다.",
|
||||
"folderLabel": "폴더",
|
||||
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
|
||||
"notEmptyTitle": "폴더가 비어 있지 않습니다",
|
||||
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"confirm": "폴더 삭제"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "\"{name}\" 폴더를 삭제했습니다",
|
||||
"successWithFiles": "\"{name}\" 폴더와 {count}개 항목을 함께 삭제했습니다",
|
||||
"restored": "폴더를 복원했습니다",
|
||||
"failed": "폴더 삭제 실패: {message}",
|
||||
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
},
|
||||
"renameFolder": "폴더 이름 바꾸기",
|
||||
"renameFolderResult": {
|
||||
"success": "폴더 이름을 \"{name}\"(으)로 변경했습니다",
|
||||
"failed": "폴더 이름 바꾸기 실패: {message}",
|
||||
"targetExists": "같은 이름의 폴더가 이미 있습니다",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
||||
"createFolderHint": "놓아서 새 폴더 만들기",
|
||||
"newFolderName": "새 폴더 이름",
|
||||
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
|
||||
"emptyFolderName": "폴더 이름을 입력하세요",
|
||||
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
|
||||
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "위의 새 폴더 버튼을 클릭하여 폴더를 만들 수 있습니다"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "이 폴더의 업데이트 확인",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||
"showSidebar": "Показать боковую панель",
|
||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "Параметры отображения",
|
||||
"treeView": "Дерево",
|
||||
"listView": "Список",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "Новая папка",
|
||||
"newSubfolder": "Новая вложенная папка",
|
||||
"showEmptyFolders": "Показывать пустые папки",
|
||||
"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"
|
||||
"success": "Папка \"{name}\" создана",
|
||||
"failed": "Не удалось создать папку: {message}",
|
||||
"unsupported": "Создание папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
},
|
||||
"deleteFolder": "Удалить папку",
|
||||
"deleteFolderModal": {
|
||||
"title": "Удалить папку?",
|
||||
"message": "Папка и всё её содержимое будут безвозвратно удалены с диска.",
|
||||
"folderLabel": "Папка",
|
||||
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
|
||||
"notEmptyTitle": "Папка не пуста",
|
||||
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"confirm": "Удалить папку"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Папка \"{name}\" удалена",
|
||||
"successWithFiles": "Папка \"{name}\" удалена вместе с ещё {count} элемент(ами)",
|
||||
"restored": "Папка восстановлена",
|
||||
"failed": "Не удалось удалить папку: {message}",
|
||||
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Удаление папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
},
|
||||
"renameFolder": "Переименовать папку",
|
||||
"renameFolderResult": {
|
||||
"success": "Папка переименована в \"{name}\"",
|
||||
"failed": "Не удалось переименовать папку: {message}",
|
||||
"targetExists": "Папка с таким именем уже существует здесь",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Переименование папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
||||
"createFolderHint": "Отпустите, чтобы создать новую папку",
|
||||
"newFolderName": "Имя новой папки",
|
||||
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
|
||||
"emptyFolderName": "Пожалуйста, введите имя папки",
|
||||
"invalidFolderName": "Имя папки содержит недопустимые символы",
|
||||
"noDragState": "Ожидающая операция перетаскивания не найдена"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Папки не найдены",
|
||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "Нажмите кнопку «Новая папка» вверху, чтобы создать папки"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Проверить обновления в этой папке",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||
"showSidebar": "显示侧边栏",
|
||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "视图选项",
|
||||
"treeView": "树形视图",
|
||||
"listView": "列表视图",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "新建文件夹",
|
||||
"newSubfolder": "新建子文件夹",
|
||||
"showEmptyFolders": "显示空文件夹",
|
||||
"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"
|
||||
"success": "已创建文件夹 \"{name}\"",
|
||||
"failed": "创建文件夹失败: {message}",
|
||||
"unsupported": "此页面不支持创建文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
},
|
||||
"deleteFolder": "删除文件夹",
|
||||
"deleteFolderModal": {
|
||||
"title": "删除文件夹?",
|
||||
"message": "该文件夹及其中所有内容都将从磁盘上永久删除。",
|
||||
"folderLabel": "文件夹",
|
||||
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
|
||||
"notEmptyTitle": "文件夹不为空",
|
||||
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"confirm": "删除文件夹"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "已删除文件夹 \"{name}\"",
|
||||
"successWithFiles": "已删除文件夹 \"{name}\",同时删除了另外 {count} 项内容",
|
||||
"restored": "文件夹已恢复",
|
||||
"failed": "删除文件夹失败: {message}",
|
||||
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持删除文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
},
|
||||
"renameFolder": "重命名文件夹",
|
||||
"renameFolderResult": {
|
||||
"success": "文件夹已重命名为 \"{name}\"",
|
||||
"failed": "重命名文件夹失败: {message}",
|
||||
"targetExists": "此处已存在同名文件夹",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持重命名文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||
"moveUnsupported": "此条目不支持移动。",
|
||||
"createFolderHint": "释放以创建新文件夹",
|
||||
"newFolderName": "新文件夹名称",
|
||||
"folderNameHint": "按 Enter 确认,Escape 取消",
|
||||
"emptyFolderName": "请输入文件夹名称",
|
||||
"invalidFolderName": "文件夹名称包含无效字符",
|
||||
"noDragState": "未找到待处理的拖放操作"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到文件夹",
|
||||
"dragHint": "拖拽项目到此处以创建文件夹",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "点击上方的新建文件夹按钮即可创建文件夹"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "检查此文件夹的更新",
|
||||
|
||||
+41
-15
@@ -1246,37 +1246,63 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"collapseAllDisabled": "[TODO: Translate] Not available in list view",
|
||||
"collapseAllDisabled": "清單檢視下無法使用",
|
||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||
"showSidebar": "顯示側邊欄",
|
||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||
"viewOptions": "[TODO: Translate] View options",
|
||||
"treeView": "[TODO: Translate] Tree view",
|
||||
"listView": "[TODO: Translate] List view",
|
||||
"viewOptions": "檢視選項",
|
||||
"treeView": "樹狀檢視",
|
||||
"listView": "清單檢視",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
"createFolder": "[TODO: Translate] New folder",
|
||||
"newSubfolder": "[TODO: Translate] New subfolder",
|
||||
"showEmptyFolders": "[TODO: Translate] Show empty folders",
|
||||
"createFolder": "新增資料夾",
|
||||
"newSubfolder": "新增子資料夾",
|
||||
"showEmptyFolders": "顯示空資料夾",
|
||||
"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"
|
||||
"success": "已建立資料夾 \"{name}\"",
|
||||
"failed": "建立資料夾失敗: {message}",
|
||||
"unsupported": "此頁面不支援建立資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
},
|
||||
"deleteFolder": "刪除資料夾",
|
||||
"deleteFolderModal": {
|
||||
"title": "刪除資料夾?",
|
||||
"message": "該資料夾及其中的所有內容都將從磁碟上永久刪除。",
|
||||
"folderLabel": "資料夾",
|
||||
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
|
||||
"notEmptyTitle": "資料夾不是空的",
|
||||
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"confirm": "刪除資料夾"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "已刪除資料夾 \"{name}\"",
|
||||
"successWithFiles": "已刪除資料夾 \"{name}\",同時刪除了另外 {count} 項內容",
|
||||
"restored": "資料夾已還原",
|
||||
"failed": "刪除資料夾失敗: {message}",
|
||||
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援刪除資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
},
|
||||
"renameFolder": "重新命名資料夾",
|
||||
"renameFolderResult": {
|
||||
"success": "資料夾已重新命名為 \"{name}\"",
|
||||
"failed": "重新命名資料夾失敗: {message}",
|
||||
"targetExists": "此處已存在同名資料夾",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援重新命名資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||
"moveUnsupported": "此項目不支援移動。",
|
||||
"createFolderHint": "放開以建立新資料夾",
|
||||
"newFolderName": "新資料夾名稱",
|
||||
"folderNameHint": "按 Enter 確認,Escape 取消",
|
||||
"emptyFolderName": "請輸入資料夾名稱",
|
||||
"invalidFolderName": "資料夾名稱包含無效字元",
|
||||
"noDragState": "未找到待處理的拖放操作"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到資料夾",
|
||||
"dragHint": "將項目拖到此處以建立資料夾",
|
||||
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
|
||||
"createHint": "點擊上方的新增資料夾按鈕即可建立資料夾"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "檢查此資料夾的更新",
|
||||
|
||||
@@ -2499,6 +2499,70 @@ 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 rename_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")
|
||||
new_name = data.get("new_name")
|
||||
if not folder_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Folder path is required"}, status=400
|
||||
)
|
||||
if not new_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "New folder name is required"}, status=400
|
||||
)
|
||||
result = await self._move_service.rename_folder(folder_path, new_name)
|
||||
if result.get("success"):
|
||||
if result.get("renamed"):
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result, status=200)
|
||||
|
||||
# A name collision or a staged delete inside the subtree is a
|
||||
# conflict with the state the client rendered, not a bad request.
|
||||
code = result.get("code")
|
||||
status = 409 if code in ("target_exists", "busy") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error renaming 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 +3514,8 @@ 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,
|
||||
"rename_folder": self.move.rename_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,
|
||||
|
||||
@@ -41,6 +41,8 @@ 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("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||
RouteDefinition(
|
||||
|
||||
@@ -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,305 @@ 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 rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
|
||||
"""Rename a directory inside the model library roots.
|
||||
|
||||
Unlike :meth:`delete_folder` this works on folders that hold models.
|
||||
A rename keeps every file, so no per-model lifecycle step is bypassed:
|
||||
the directory is renamed on disk and the affected folder, cache, hash
|
||||
index and metadata-sidecar records are re-keyed onto the new prefix by
|
||||
the scanner.
|
||||
|
||||
Args:
|
||||
folder_path: Absolute path of the directory to rename (business
|
||||
path — symlinks are not resolved)
|
||||
new_name: New leaf name; a single path segment, not a path
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag, the previous/next library-relative
|
||||
folder names and whether the directory actually moved.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
return {"success": False, "error": "Folder path is required"}
|
||||
|
||||
new_name = str(new_name or "").strip()
|
||||
if not new_name:
|
||||
return {"success": False, "error": "New folder name is required"}
|
||||
if new_name in (".", "..") or any(
|
||||
char in new_name for char in '/\\:*?"<>|'
|
||||
):
|
||||
return {"success": False, "error": "Invalid characters in folder name"}
|
||||
|
||||
_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):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Symlinked folders cannot be renamed",
|
||||
}
|
||||
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 renamed",
|
||||
}
|
||||
|
||||
previous_relative = self._calculate_relative_folder(absolute_path)
|
||||
target = os.path.join(os.path.dirname(absolute_path), new_name)
|
||||
|
||||
if os.path.normpath(target) == os.path.normpath(absolute_path):
|
||||
return {
|
||||
"success": True,
|
||||
"renamed": False,
|
||||
"folder": previous_relative,
|
||||
"previous_folder": previous_relative,
|
||||
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||
}
|
||||
|
||||
if os.path.exists(target):
|
||||
return {
|
||||
"success": False,
|
||||
"code": "target_exists",
|
||||
"error": f"A folder named \"{new_name}\" already exists here",
|
||||
}
|
||||
|
||||
# A staging manifest records absolute original/staged paths, so
|
||||
# moving a folder that holds one would break its undo and purge.
|
||||
if self._has_pending_delete_job(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": (
|
||||
"A staged delete is still pending inside this folder; "
|
||||
"wait for the undo window to expire"
|
||||
),
|
||||
}
|
||||
|
||||
os.rename(absolute_path, target)
|
||||
|
||||
new_relative = self._calculate_relative_folder(target)
|
||||
await self._rename_folder_records(
|
||||
previous_relative, new_relative, absolute_path, target
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"renamed": True,
|
||||
"folder": new_relative,
|
||||
"previous_folder": previous_relative,
|
||||
"folder_path": target.replace(os.sep, "/"),
|
||||
}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.error(f"Error renaming folder: {exc}", exc_info=True)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
@staticmethod
|
||||
def _has_pending_delete_job(absolute_path: str) -> bool:
|
||||
"""Return True when a staged-delete batch lives inside the subtree."""
|
||||
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _rename_folder_records(
|
||||
self,
|
||||
previous_relative: str,
|
||||
new_relative: str,
|
||||
previous_path: str,
|
||||
new_path: str,
|
||||
) -> None:
|
||||
"""Hand the rename to the scanner so folder/cache records follow it."""
|
||||
if not previous_relative or not new_relative:
|
||||
return
|
||||
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
|
||||
if callable(rename_known_folder):
|
||||
await rename_known_folder(
|
||||
previous_relative,
|
||||
new_relative,
|
||||
previous_path=previous_path,
|
||||
new_path=new_path,
|
||||
)
|
||||
|
||||
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||
"""Move a single model file
|
||||
|
||||
|
||||
@@ -1505,6 +1505,216 @@ 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}/")
|
||||
|
||||
@staticmethod
|
||||
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
|
||||
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
|
||||
if not value:
|
||||
return value
|
||||
normalized = value.replace("\\", "/")
|
||||
if normalized.startswith(old_prefix):
|
||||
return new_prefix + normalized[len(old_prefix):]
|
||||
return value
|
||||
|
||||
async def rename_known_folder(
|
||||
self,
|
||||
previous_folder: str,
|
||||
new_folder: str,
|
||||
*,
|
||||
previous_path: str,
|
||||
new_path: str,
|
||||
) -> bool:
|
||||
"""Re-key folder, cache and metadata records after a directory rename.
|
||||
|
||||
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
|
||||
A rename keeps every file, so nothing may be dropped: the recorded
|
||||
folder list, the affected cache entries (``file_path``/``folder``/
|
||||
``preview_url``), the hash index and the on-disk metadata sidecars are
|
||||
all rewritten onto the new prefix. That is what lets a folder full of
|
||||
models be renamed without a rescan and without breaking per-model
|
||||
bookkeeping.
|
||||
|
||||
Args:
|
||||
previous_folder: Library-relative folder name before the rename
|
||||
new_folder: Library-relative folder name after the rename
|
||||
previous_path: Absolute directory path before the rename
|
||||
new_path: Absolute directory path after the rename
|
||||
|
||||
Returns:
|
||||
True when any recorded data was rewritten.
|
||||
"""
|
||||
previous = previous_folder.replace("\\", "/").strip("/")
|
||||
current = new_folder.replace("\\", "/").strip("/")
|
||||
if not previous or not current or previous == current:
|
||||
return False
|
||||
|
||||
old_rel_prefix = f"{previous}/"
|
||||
new_rel_prefix = f"{current}/"
|
||||
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return False
|
||||
|
||||
changed = False
|
||||
|
||||
recorded = getattr(cache, "all_folders", None)
|
||||
if recorded is not None:
|
||||
rekeyed = sorted(
|
||||
(
|
||||
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
|
||||
for entry in recorded
|
||||
),
|
||||
key=lambda entry: entry.lower(),
|
||||
)
|
||||
if rekeyed != list(recorded):
|
||||
cache.all_folders = rekeyed
|
||||
changed = True
|
||||
|
||||
excluded = getattr(self, "_excluded_models", None)
|
||||
if excluded:
|
||||
rekeyed_excluded = [
|
||||
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
|
||||
for entry in excluded
|
||||
]
|
||||
if rekeyed_excluded != list(excluded):
|
||||
self._excluded_models = rekeyed_excluded
|
||||
changed = True
|
||||
|
||||
touched: List[Dict[str, Any]] = []
|
||||
for item in cache.raw_data or []:
|
||||
folder_value = item.get("folder", "") or self._calculate_folder(
|
||||
item.get("file_path", "")
|
||||
)
|
||||
if not self._folder_within(folder_value, previous):
|
||||
continue
|
||||
|
||||
old_file_path = item.get("file_path", "")
|
||||
if old_file_path:
|
||||
cache.remove_from_version_index(item)
|
||||
item["file_path"] = self._rekey_path(
|
||||
old_file_path, old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
hash_value = (item.get("sha256") or "").lower()
|
||||
if hash_value:
|
||||
self._hash_index.remove_by_path(old_file_path, hash_value)
|
||||
self._hash_index.add_entry(
|
||||
hash_value, item["file_path"], item.get("autov3") or None
|
||||
)
|
||||
|
||||
item["folder"] = self._rekey_folder_name(
|
||||
folder_value, previous, old_rel_prefix, new_rel_prefix
|
||||
)
|
||||
if item.get("preview_url"):
|
||||
item["preview_url"] = self._rekey_path(
|
||||
item["preview_url"], old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
touched.append(item)
|
||||
|
||||
if touched:
|
||||
changed = True
|
||||
await self._rewrite_sidecar_paths(touched)
|
||||
folders = set(item.get("folder", "") for item in cache.raw_data)
|
||||
cache.folders = sorted(folders, key=lambda x: x.lower())
|
||||
cache.rebuild_version_index()
|
||||
await cache.resort()
|
||||
|
||||
if changed:
|
||||
await self._persist_current_cache()
|
||||
|
||||
self.bump_cache_version()
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _rekey_folder_name(
|
||||
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
|
||||
) -> str:
|
||||
"""Move a library-relative folder name (and its subtree) under a new name."""
|
||||
if entry == previous:
|
||||
return new_rel_prefix.rstrip("/")
|
||||
if entry.startswith(old_rel_prefix):
|
||||
return new_rel_prefix + entry[len(old_rel_prefix):]
|
||||
return entry
|
||||
|
||||
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
|
||||
"""Point each model's metadata sidecar at its new location.
|
||||
|
||||
Sidecars travel with the renamed directory, so only the recorded
|
||||
``file_path``/``preview_url`` inside them need rewriting. Failures are
|
||||
logged and skipped — a stale sidecar is repaired by the next metadata
|
||||
refresh, and must not abort the rename.
|
||||
"""
|
||||
for item in entries:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
if not os.path.exists(metadata_path):
|
||||
continue
|
||||
try:
|
||||
await self._update_metadata_paths(metadata_path, file_path)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@@ -46,8 +46,20 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Destructive entries. The token used to be the nonexistent `--danger-color`,
|
||||
which made the declaration invalid at computed-value time: the colour then
|
||||
fell back to the menu's inherited text colour, so every "Delete …" entry in
|
||||
the folder and model-card context menus rendered plain. */
|
||||
.context-menu-item.delete-item {
|
||||
color: var(--danger-color);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* The shared .context-menu-item:hover paints the accent background, which the
|
||||
red label does not read against — destructive entries get their own wash. */
|
||||
.context-menu-item.delete-item:hover,
|
||||
.context-menu-item.delete-item:focus-visible {
|
||||
background-color: var(--lora-error-bg);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.context-menu-item i {
|
||||
@@ -55,6 +67,21 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Muted counter shown next to a menu label (e.g. how many empty folders the
|
||||
"Show empty folders" toggle would reveal) */
|
||||
.context-menu-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* The count keeps the label/tally muted even while the row is hovered, since
|
||||
the accent background would otherwise wash the muted colour out. */
|
||||
.context-menu-item:hover .context-menu-count,
|
||||
.context-menu-item:focus-visible .context-menu-count {
|
||||
color: var(--lora-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.context-menu-section-header {
|
||||
padding: 6px 12px 2px;
|
||||
|
||||
@@ -747,13 +747,13 @@
|
||||
}
|
||||
|
||||
.priority-tags-input.settings-input-error {
|
||||
border-color: var(--danger-color, #dc2626);
|
||||
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12);
|
||||
border-color: var(--lora-error);
|
||||
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.12);
|
||||
}
|
||||
|
||||
.settings-input-error-message {
|
||||
font-size: 0.8em;
|
||||
color: var(--danger-color, #dc2626);
|
||||
color: var(--lora-error);
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -639,90 +639,40 @@
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Create folder drop zone */
|
||||
.sidebar-create-folder-zone {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
padding: 16px;
|
||||
border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
|
||||
/* Create folder inline row: rendered inside the tree at the creation
|
||||
location, styled like a regular node row with a full-width input */
|
||||
.sidebar-create-folder-row {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content.sidebar-create-folder-row:hover,
|
||||
.sidebar-node-content.sidebar-create-folder-row:hover {
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-spacer {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: var(--transition-base);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-zone.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.sidebar-create-folder-row .sidebar-tree-folder-icon,
|
||||
.sidebar-create-folder-row .sidebar-folder-icon {
|
||||
color: var(--lora-accent);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-content i {
|
||||
font-size: 1.5em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Create folder input container */
|
||||
.sidebar-create-folder-input-container {
|
||||
/* 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);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 20;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input-wrapper > i {
|
||||
color: var(--lora-accent);
|
||||
font-size: 1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input {
|
||||
flex: 1;
|
||||
min-width: 0; /* allow the input to shrink below its intrinsic width */
|
||||
padding: 6px 10px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
font-size: 1em;
|
||||
outline: none;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
@@ -732,49 +682,6 @@
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-btn:hover,
|
||||
.sidebar-create-folder-btn:focus-visible {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-confirm:hover,
|
||||
.sidebar-create-folder-confirm:focus-visible {
|
||||
background: oklch(from var(--success-color) l c h / 0.15);
|
||||
color: var(--success-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-cancel:hover,
|
||||
.sidebar-create-folder-cancel:focus-visible {
|
||||
background: oklch(from var(--error-color) l c h / 0.15);
|
||||
color: var(--error-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Dragging state for sidebar */
|
||||
.folder-sidebar.dragging-active {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
|
||||
@@ -786,11 +693,6 @@
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
|
||||
}
|
||||
|
||||
/* Tree container positioning for create folder elements */
|
||||
.sidebar-tree-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Folder context menu - positioned relative to sidebar */
|
||||
#sidebarFolderContextMenu {
|
||||
z-index: var(--z-modal, 1002);
|
||||
|
||||
@@ -84,6 +84,8 @@ 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`,
|
||||
renameFolder: `/api/lm/${modelType}/rename-folder`,
|
||||
|
||||
// CivitAI integration
|
||||
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
|
||||
|
||||
@@ -1330,6 +1330,71 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a folder inside the library roots.
|
||||
*
|
||||
* Works on folders that hold models too — the backend re-keys the affected
|
||||
* cache records instead of cascading. A name collision or a staged delete
|
||||
* inside the subtree surfaces as a 409 conflict, attached to the thrown
|
||||
* Error as `code`.
|
||||
*
|
||||
* @param {string} folderPath Absolute business path of the folder
|
||||
* @param {string} newName New leaf name (a single path segment)
|
||||
*/
|
||||
async renameFolder(folderPath, newName) {
|
||||
const response = await fetch(this.apiConfig.endpoints.renameFolder, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ folder_path: folderPath, new_name: newName })
|
||||
});
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || result.success === false) {
|
||||
const error = new Error(result.error || `Failed to rename folder`);
|
||||
error.code = result.code || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchUnifiedFolderTree(options = {}) {
|
||||
try {
|
||||
const { includeEmpty = false } = options;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
|
||||
@@ -205,12 +205,24 @@
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Folder Context Menu -->
|
||||
<!-- Order: the content action (update check) first, then the folder operations
|
||||
as one group, then the destructive action behind its own divider. The
|
||||
dividers are collapsed by SidebarManager when a group is hidden on the
|
||||
current page (recipes keep only the update check). -->
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="check-folder-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="create-subfolder">
|
||||
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="check-folder-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||
<div class="context-menu-item" data-action="rename-folder">
|
||||
<i class="fas fa-i-cursor"></i> <span>{{ t('sidebar.renameFolder') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item delete-item" data-action="delete-folder">
|
||||
<i class="fas fa-trash"></i> <span>{{ t('sidebar.deleteFolder') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,6 +243,7 @@
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="toggle-empty-folders">
|
||||
<i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span>
|
||||
<span id="sidebarEmptyFoldersCount" class="context-menu-count"></span>
|
||||
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Folder Delete Confirmation Modal -->
|
||||
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
|
||||
subtree still holds models, so a cascade delete is refused). -->
|
||||
<div id="deleteFolderModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title">{{ t('sidebar.deleteFolderModal.title') }}</h2>
|
||||
<p class="delete-message" data-role="message">{{ t('sidebar.deleteFolderModal.message') }}</p>
|
||||
<div class="delete-model-info" data-role="info"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-delete-folder">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="delete-btn" data-action="confirm-delete-folder">{{ t('sidebar.deleteFolderModal.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
|
||||
<div id="bulkDownloadMissingLorasModal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
UI_HELPERS_MODULE,
|
||||
UPDATE_CHECK_MODULE,
|
||||
STATE_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
@@ -18,17 +19,23 @@ const {
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
|
||||
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
|
||||
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() }));
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
|
||||
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||
}));
|
||||
|
||||
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
|
||||
const { state } = await import(STATE_MODULE);
|
||||
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
|
||||
const { showToast, showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
const { modalManager } = await import(MODAL_MANAGER_MODULE);
|
||||
|
||||
function createApiClient(overrides = {}) {
|
||||
return {
|
||||
@@ -44,6 +51,20 @@ function createApiClient(overrides = {}) {
|
||||
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
|
||||
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
|
||||
deleteFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'empty',
|
||||
model_count: 0,
|
||||
file_count: 0,
|
||||
dir_count: 0,
|
||||
restorable: true,
|
||||
}),
|
||||
renameFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
renamed: true,
|
||||
folder: 'renamed',
|
||||
previous_folder: 'empty',
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -65,28 +86,33 @@ describe('SidebarManager empty folders toggle', () => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('requests the models-only tree by default', async () => {
|
||||
it('loads the full and models-only folder lists and counts the empty folders', async () => {
|
||||
const apiClient = createApiClient();
|
||||
apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } });
|
||||
apiClient.fetchModelFolders.mockResolvedValue({ folders: ['', 'full'] });
|
||||
const manager = createManager(apiClient);
|
||||
|
||||
await manager.loadFolderTree();
|
||||
|
||||
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
|
||||
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
|
||||
expect(manager.nonEmptyFolders).toBeNull();
|
||||
expect(manager.treeData).toEqual({ full: {}, empty: {} });
|
||||
});
|
||||
|
||||
it('includes empty folders and tracks the models-only set when enabled', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.showEmptyFolders = true;
|
||||
|
||||
await manager.loadFolderTree();
|
||||
|
||||
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
|
||||
expect(apiClient.fetchModelFolders).toHaveBeenCalledWith();
|
||||
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
|
||||
expect(manager.treeData).toEqual({ full: {}, empty: {} });
|
||||
expect(manager.emptyFolderCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the folder data loaded while empty folders are hidden', async () => {
|
||||
const apiClient = createApiClient();
|
||||
apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } });
|
||||
const manager = createManager(apiClient);
|
||||
manager.showEmptyFolders = false;
|
||||
|
||||
await manager.loadFolderTree();
|
||||
|
||||
// The data is still fetched so the menu can report the count; only the
|
||||
// rendering is gated by the preference.
|
||||
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
|
||||
expect(manager.emptyFolderCount).toBe(1);
|
||||
});
|
||||
|
||||
it('passes includeEmpty to the folder list in list display mode', async () => {
|
||||
@@ -95,41 +121,46 @@ describe('SidebarManager empty folders toggle', () => {
|
||||
.mockResolvedValueOnce({ folders: ['', 'full', 'empty'] })
|
||||
.mockResolvedValueOnce({ folders: ['', 'full'] });
|
||||
const manager = createManager(apiClient, { displayMode: 'list' });
|
||||
manager.showEmptyFolders = true;
|
||||
|
||||
await manager.loadFolderTree();
|
||||
|
||||
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true });
|
||||
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(2);
|
||||
expect(manager.foldersList).toEqual(['', 'full', 'empty']);
|
||||
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
|
||||
expect(manager.emptyFolderCount).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores the preference when the page does not support folder management', async () => {
|
||||
it('does not request empty folders when the page does not support folder management', async () => {
|
||||
const apiClient = createApiClient();
|
||||
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||
const manager = createManager(apiClient);
|
||||
manager.showEmptyFolders = true;
|
||||
|
||||
await manager.loadFolderTree();
|
||||
|
||||
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
|
||||
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
|
||||
expect(manager.nonEmptyFolders).toBeNull();
|
||||
expect(manager.emptyFolderCount).toBeNull();
|
||||
});
|
||||
|
||||
it('persists the toggle and reloads the tree', async () => {
|
||||
it('persists the toggle and re-renders without refetching', () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.showEmptyFolders = false;
|
||||
manager.loadFolderTree = vi.fn();
|
||||
|
||||
manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() });
|
||||
|
||||
expect(manager.showEmptyFolders).toBe(true);
|
||||
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
|
||||
expect(manager.loadFolderTree).toHaveBeenCalledTimes(1);
|
||||
expect(manager.loadFolderTree).not.toHaveBeenCalled();
|
||||
expect(manager.renderFolderDisplay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dims folders that contain no models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.showEmptyFolders = true;
|
||||
manager.treeData = { full: {}, empty: {} };
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
@@ -142,6 +173,7 @@ describe('SidebarManager empty folders toggle', () => {
|
||||
|
||||
it('does not dim folders whose subtree contains models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.showEmptyFolders = true;
|
||||
// Models live in "characters/anime" only; "characters" itself holds no
|
||||
// direct models but must not be dimmed.
|
||||
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']);
|
||||
@@ -156,6 +188,48 @@ describe('SidebarManager empty folders toggle', () => {
|
||||
expect(animeNode[0]).not.toContain('empty');
|
||||
expect(emptyNode[0]).toContain('empty');
|
||||
});
|
||||
|
||||
it('does not dim folders while the preference is off', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.showEmptyFolders = false;
|
||||
manager.treeData = { full: {}, empty: {} };
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
const html = manager.renderTreeNode(manager.treeData, '');
|
||||
|
||||
expect(html).not.toContain('sidebar-tree-node-content empty');
|
||||
});
|
||||
|
||||
describe('list view', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
});
|
||||
|
||||
it('hides empty folders from the flat list while the preference is off', () => {
|
||||
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
||||
manager.showEmptyFolders = false;
|
||||
manager.foldersList = ['', 'full', 'empty'];
|
||||
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']);
|
||||
|
||||
manager.renderFolderList();
|
||||
|
||||
const html = document.getElementById('sidebarFolderTree').innerHTML;
|
||||
expect(html).toContain('data-path="full"');
|
||||
expect(html).not.toContain('data-path="empty"');
|
||||
});
|
||||
|
||||
it('shows empty folders dimmed in the flat list when the preference is on', () => {
|
||||
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
||||
manager.showEmptyFolders = true;
|
||||
manager.foldersList = ['', 'full', 'empty'];
|
||||
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']);
|
||||
|
||||
manager.renderFolderList();
|
||||
|
||||
const html = document.getElementById('sidebarFolderTree').innerHTML;
|
||||
expect(html).toContain('sidebar-node-content empty" data-path="empty"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SidebarManager view options menu', () => {
|
||||
@@ -164,13 +238,17 @@ describe('SidebarManager view options menu', () => {
|
||||
<div class="context-menu-item" data-action="view-mode-tree"><i class="check-indicator" style="display:none"></i></div>
|
||||
<div class="context-menu-item" data-action="view-mode-list"><i class="check-indicator" style="display:none"></i></div>
|
||||
<div class="context-menu-item" data-action="toggle-recursive"><i class="check-indicator" style="display:none"></i></div>
|
||||
<div class="context-menu-item" data-action="toggle-empty-folders"><i class="check-indicator" style="display:none"></i></div>
|
||||
<div class="context-menu-item" data-action="toggle-empty-folders"><span id="sidebarEmptyFoldersCount"></span><i class="check-indicator" style="display:none"></i></div>
|
||||
</div>`;
|
||||
|
||||
function getCheck(action) {
|
||||
return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`);
|
||||
}
|
||||
|
||||
function getEmptyFoldersItem() {
|
||||
return document.querySelector('[data-action="toggle-empty-folders"]');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = MENU_HTML;
|
||||
@@ -206,7 +284,35 @@ describe('SidebarManager view options menu', () => {
|
||||
|
||||
manager.updateViewOptionsMenu();
|
||||
|
||||
expect(document.querySelector('[data-action="toggle-empty-folders"]').style.display).toBe('none');
|
||||
expect(getEmptyFoldersItem().style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('hides the empty-folders item when the library has no empty folders', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.emptyFolderCount = 0;
|
||||
|
||||
manager.updateViewOptionsMenu();
|
||||
|
||||
expect(getEmptyFoldersItem().style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps the empty-folders item visible while the count is unknown', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.emptyFolderCount = null;
|
||||
|
||||
manager.updateViewOptionsMenu();
|
||||
|
||||
expect(getEmptyFoldersItem().style.display).not.toBe('none');
|
||||
});
|
||||
|
||||
it('shows the empty-folder count next to the label', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.emptyFolderCount = 12;
|
||||
|
||||
manager.updateViewOptionsMenu();
|
||||
|
||||
expect(getEmptyFoldersItem().style.display).not.toBe('none');
|
||||
expect(document.getElementById('sidebarEmptyFoldersCount').textContent).toBe('(12)');
|
||||
});
|
||||
|
||||
it('switches display mode from the menu and closes it', () => {
|
||||
@@ -224,7 +330,7 @@ describe('SidebarManager view options menu', () => {
|
||||
|
||||
it('toggles empty folders from the menu and keeps it open', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.loadFolderTree = vi.fn();
|
||||
manager.showEmptyFolders = false;
|
||||
const menu = document.getElementById('sidebarViewOptionsMenu');
|
||||
menu.style.display = 'block';
|
||||
|
||||
@@ -233,6 +339,7 @@ describe('SidebarManager view options menu', () => {
|
||||
expect(manager.showEmptyFolders).toBe(true);
|
||||
expect(getCheck('toggle-empty-folders').style.display).toBe('block');
|
||||
expect(menu.style.display).toBe('block');
|
||||
expect(manager.renderFolderDisplay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('collapses all folders from the header button', () => {
|
||||
@@ -277,6 +384,29 @@ describe('SidebarManager view options menu', () => {
|
||||
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
|
||||
expect(menu.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('shows empty folders on a fresh library (default preference)', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.updateSearchRecursiveOption = vi.fn();
|
||||
manager.updateFolderManagementButtons = vi.fn();
|
||||
manager.updateCollapseAllButton = vi.fn();
|
||||
|
||||
manager.restoreSidebarState();
|
||||
|
||||
expect(manager.showEmptyFolders).toBe(true);
|
||||
});
|
||||
|
||||
it('honours a stored preference to hide empty folders', () => {
|
||||
setStorageItem('loras_showEmptyFolders', false);
|
||||
const manager = createManager(createApiClient());
|
||||
manager.updateSearchRecursiveOption = vi.fn();
|
||||
manager.updateFolderManagementButtons = vi.fn();
|
||||
manager.updateCollapseAllButton = vi.fn();
|
||||
|
||||
manager.restoreSidebarState();
|
||||
|
||||
expect(manager.showEmptyFolders).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SidebarManager folder creation', () => {
|
||||
@@ -306,19 +436,34 @@ describe('SidebarManager folder creation', () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.selectedPath = 'characters';
|
||||
manager.showEmptyFolders = false;
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._createFolder('characters/anime', 'characters');
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime');
|
||||
// Empty-folder display is enabled so the new folder shows up immediately
|
||||
// The new folder is empty, so creating it turns empty-folder display back
|
||||
// on to keep the folder visible in the tree.
|
||||
expect(manager.showEmptyFolders).toBe(true);
|
||||
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
|
||||
expect(manager.expandedNodes.has('characters')).toBe(true);
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-enables empty folders when creating while the preference is off', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.showEmptyFolders = false;
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._createFolder('new-folder', '');
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(manager.showEmptyFolders).toBe(true);
|
||||
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails gracefully when no model root is configured', async () => {
|
||||
const apiClient = createApiClient();
|
||||
apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });
|
||||
@@ -332,22 +477,124 @@ describe('SidebarManager folder creation', () => {
|
||||
expect(manager.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the create-folder input for a context-menu folder', () => {
|
||||
it('opens the create-folder input as an inline row under the context-menu folder', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
document.body.innerHTML = '<div class="sidebar-tree-container"></div>';
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
manager.treeData = { characters: {} };
|
||||
manager.renderTree();
|
||||
|
||||
manager._performFolderAction('create-subfolder', 'characters');
|
||||
|
||||
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
||||
expect(input).not.toBeNull();
|
||||
const row = document.getElementById('sidebarCreateFolderInput');
|
||||
expect(row).not.toBeNull();
|
||||
expect(row.classList.contains('sidebar-create-folder-node')).toBe(true);
|
||||
expect(manager._createFolderBasePath).toBe('characters');
|
||||
// The leaf parent is expanded and the row sits inside its children container
|
||||
expect(manager.expandedNodes.has('characters')).toBe(true);
|
||||
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
|
||||
expect(parentNode.querySelector(':scope > .sidebar-tree-children').contains(row)).toBe(true);
|
||||
});
|
||||
|
||||
it('inserts the row as the first child of an already-expanded parent', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.expandedNodes = new Set(['characters']);
|
||||
manager.renderTree();
|
||||
|
||||
manager.showCreateFolderInput('characters');
|
||||
|
||||
const children = document.querySelector('.sidebar-tree-node[data-path="characters"] > .sidebar-tree-children');
|
||||
expect(children.firstElementChild.id).toBe('sidebarCreateFolderInput');
|
||||
// Existing children container is reused, no temporary one is tracked
|
||||
expect(manager._createFolderTempChildren).toBeNull();
|
||||
});
|
||||
|
||||
it('appends the row at the top level for root creation', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
manager.treeData = { characters: {} };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showCreateFolderInput('');
|
||||
|
||||
const folderTree = document.getElementById('sidebarFolderTree');
|
||||
const row = document.getElementById('sidebarCreateFolderInput');
|
||||
expect(row.parentElement).toBe(folderTree);
|
||||
expect(folderTree.lastElementChild).toBe(row);
|
||||
});
|
||||
|
||||
it('inserts the row after the parent item in list mode', () => {
|
||||
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
manager.foldersList = ['characters', 'characters/anime'];
|
||||
manager.renderFolderList();
|
||||
|
||||
manager.showCreateFolderInput('characters');
|
||||
|
||||
const items = [...document.querySelectorAll('#sidebarFolderTree > div')];
|
||||
const parentIndex = items.findIndex(el => el.dataset.path === 'characters');
|
||||
expect(items[parentIndex + 1].id).toBe('sidebarCreateFolderInput');
|
||||
// List-mode rows use the list content styling, not the tree one
|
||||
expect(items[parentIndex + 1].querySelector('.sidebar-node-content')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('removes the temporary children container when creation is canceled', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
manager.treeData = { characters: {} };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showCreateFolderInput('characters');
|
||||
manager.handleCreateFolderCancel();
|
||||
|
||||
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
|
||||
expect(manager.isCreatingFolder).toBe(false);
|
||||
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
|
||||
expect(parentNode.querySelector(':scope > .sidebar-tree-children')).toBeNull();
|
||||
});
|
||||
|
||||
it('cancels creation when the input loses focus', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createManager(createApiClient());
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
|
||||
manager.showCreateFolderInput('');
|
||||
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
|
||||
expect(manager.isCreatingFolder).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores tree clicks and context menus on the create row', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
manager.treeData = { characters: {} };
|
||||
manager.renderTree();
|
||||
manager.selectFolder = vi.fn();
|
||||
const showMenu = vi.spyOn(manager, '_showFolderContextMenu').mockImplementation(() => {});
|
||||
|
||||
manager.showCreateFolderInput('characters');
|
||||
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
|
||||
|
||||
manager.handleTreeClick({ target: input });
|
||||
expect(manager.selectFolder).not.toHaveBeenCalled();
|
||||
|
||||
manager.handleTreeContextMenu({ target: input, preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
expect(showMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits a standalone folder creation when no drag is pending', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
document.body.innerHTML = '<div class="sidebar-tree-container"></div>';
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
|
||||
manager.showCreateFolderInput('characters');
|
||||
document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime';
|
||||
@@ -358,3 +605,440 @@ describe('SidebarManager folder creation', () => {
|
||||
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SidebarManager folder deletion', () => {
|
||||
const MODAL_HTML = `
|
||||
<div id="deleteFolderModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<div class="delete-model-info" data-role="info"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-delete-folder">Cancel</button>
|
||||
<button class="delete-btn" data-action="confirm-delete-folder">Delete folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
function confirmBtn() {
|
||||
return document.querySelector('#deleteFolderModal [data-action="confirm-delete-folder"]');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = MODAL_HTML;
|
||||
state.global.settings = {};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the confirm state for a folder whose subtree holds no models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('confirm');
|
||||
expect(confirmBtn().style.display).toBe('');
|
||||
expect(manager._pendingDeleteFolderPath).toBe('empty');
|
||||
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||
});
|
||||
|
||||
it('explains the refusal when the subtree still holds models', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.nonEmptyFolders = new Set(['', 'full']);
|
||||
|
||||
manager.showDeleteFolderModal('full');
|
||||
|
||||
const modal = document.getElementById('deleteFolderModal');
|
||||
expect(modal.dataset.state).toBe('blocked');
|
||||
expect(confirmBtn().style.display).toBe('none');
|
||||
expect(manager._pendingDeleteFolderPath).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an unknown folder as model-free when the models-only set is missing', () => {
|
||||
// nonEmptyFolders is null outside the include-empty tree; the server still
|
||||
// refuses a non-empty folder, so the client falls back to the confirm state.
|
||||
const manager = createManager(createApiClient());
|
||||
manager.nonEmptyFolders = null;
|
||||
|
||||
manager.showDeleteFolderModal('empty');
|
||||
|
||||
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
|
||||
});
|
||||
|
||||
it('deletes the folder and offers the undo affordance for an empty one', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('empty');
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/empty');
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToast).toHaveBeenCalledTimes(1);
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores a deleted empty folder through the create-folder API', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager._deleteFolder('empty');
|
||||
const undo = showActionToast.mock.calls[0][3].onAction;
|
||||
await undo();
|
||||
|
||||
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/empty');
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.restored', {}, 'success');
|
||||
});
|
||||
|
||||
it('skips the undo affordance when non-model leftovers were removed', async () => {
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
folder: 'empty',
|
||||
file_count: 2,
|
||||
dir_count: 1,
|
||||
restorable: false,
|
||||
}),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager._deleteFolder('empty');
|
||||
|
||||
expect(showActionToast).not.toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.deleteFolderResult.successWithFiles',
|
||||
{ name: 'empty', count: 3 },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the not_empty conflict when the tree was stale', async () => {
|
||||
const conflict = Object.assign(new Error('still contains models'), { code: 'not_empty' });
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._deleteFolder('full');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
|
||||
expect(manager.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a busy folder with a staged delete', async () => {
|
||||
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
|
||||
const apiClient = createApiClient({
|
||||
deleteFolder: vi.fn().mockRejectedValue(busy),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager._deleteFolder('full');
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.busy', {}, 'warning');
|
||||
});
|
||||
|
||||
it('drops the removed subtree from the persisted expand state', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.expandedNodes = new Set(['empty', 'empty/deep', 'other']);
|
||||
manager.saveExpandedState = vi.fn();
|
||||
|
||||
manager._forgetRemovedFolder('empty');
|
||||
|
||||
expect([...manager.expandedNodes]).toEqual(['other']);
|
||||
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves the expand state untouched when nothing matched', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.expandedNodes = new Set(['other']);
|
||||
manager.saveExpandedState = vi.fn();
|
||||
|
||||
manager._forgetRemovedFolder('empty');
|
||||
|
||||
expect([...manager.expandedNodes]).toEqual(['other']);
|
||||
expect(manager.saveExpandedState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes the modal buttons to cancel and confirm', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager._deleteFolder = vi.fn().mockResolvedValue(true);
|
||||
manager._pendingDeleteFolderPath = 'empty';
|
||||
manager._wireDeleteFolderModal();
|
||||
|
||||
confirmBtn().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(modalManager.closeModal).toHaveBeenCalledWith('deleteFolderModal');
|
||||
expect(manager._deleteFolder).toHaveBeenCalledWith('empty');
|
||||
});
|
||||
|
||||
it('routes the context-menu action to the delete modal', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.showDeleteFolderModal = vi.fn();
|
||||
|
||||
manager._performFolderAction('delete-folder', 'empty');
|
||||
|
||||
expect(manager.showDeleteFolderModal).toHaveBeenCalledWith('empty');
|
||||
});
|
||||
|
||||
it('hides the delete entry when folder management is unsupported', () => {
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="create-subfolder"></div>
|
||||
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
|
||||
</div>`);
|
||||
const apiClient = createApiClient();
|
||||
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||
const manager = createManager(apiClient);
|
||||
|
||||
manager._showFolderContextMenu(10, 10, 'empty');
|
||||
|
||||
const item = document.querySelector('#sidebarFolderContextMenu [data-action="delete-folder"]');
|
||||
expect(item.style.display).toBe('none');
|
||||
|
||||
manager._closeFolderContextMenu();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SidebarManager folder rename', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
|
||||
state.global.settings = {};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renameInput() {
|
||||
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
|
||||
}
|
||||
|
||||
it('turns the node into a prefilled inline row in tree mode', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
|
||||
const row = document.getElementById('sidebarRenameFolderInput');
|
||||
expect(row).not.toBeNull();
|
||||
expect(renameInput().value).toBe('anime');
|
||||
// The node is hidden in place, not removed: the row sits right before it
|
||||
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
||||
expect(node.style.display).toBe('none');
|
||||
expect(row.nextElementSibling).toBe(node);
|
||||
expect(manager._renameFolderPath).toBe('characters/anime');
|
||||
});
|
||||
|
||||
it('inserts the row in place in list mode', () => {
|
||||
const manager = createManager(createApiClient(), { displayMode: 'list' });
|
||||
manager.foldersList = ['characters', 'characters/anime'];
|
||||
manager.renderFolderList();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
|
||||
const row = document.getElementById('sidebarRenameFolderInput');
|
||||
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
|
||||
const item = document.querySelector('.sidebar-folder-item[data-path="characters/anime"]');
|
||||
expect(row.nextElementSibling).toBe(item);
|
||||
});
|
||||
|
||||
it('restores the node when the edit is canceled', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
manager.handleRenameFolderCancel();
|
||||
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||
expect(manager._renameFolderPath).toBeNull();
|
||||
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
|
||||
expect(node.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('renames through the API and re-keys the persisted selection', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
manager.selectedPath = 'characters/anime';
|
||||
manager.expandedNodes = new Set(['characters', 'characters/anime']);
|
||||
manager.pageControls = { pageState: { activeFolder: 'characters/anime' } };
|
||||
|
||||
const success = await manager._renameFolder('characters/anime', 'animation');
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/characters/anime', 'animation');
|
||||
expect(manager.selectedPath).toBe('renamed');
|
||||
expect(manager.pageControls.pageState.activeFolder).toBe('renamed');
|
||||
expect(getStorageItem('loras_activeFolder')).toBe('renamed');
|
||||
expect(manager.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'sidebar.renameFolderResult.success', { name: 'animation' }, 'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('re-keys the expanded subtree and the selection', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.expandedNodes = new Set(['a', 'a/b', 'a/b/c', 'x']);
|
||||
manager.selectedPath = 'a/b/c';
|
||||
manager.saveExpandedState = vi.fn();
|
||||
|
||||
manager._rekeyFolderPath('a/b', 'a/z');
|
||||
|
||||
expect([...manager.expandedNodes]).toEqual(['a', 'a/z', 'a/z/c', 'x']);
|
||||
expect(manager.selectedPath).toBe('a/z/c');
|
||||
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits the inline edit and skips the API for an unchanged name', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
renameInput().value = 'anime';
|
||||
await manager.handleRenameFolderSubmit();
|
||||
|
||||
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid names before calling the API', async () => {
|
||||
const apiClient = createApiClient();
|
||||
const manager = createManager(apiClient);
|
||||
manager.treeData = { characters: { anime: {} } };
|
||||
manager.renderTree();
|
||||
|
||||
manager.showRenameFolderInput('characters/anime');
|
||||
renameInput().value = 'bad/name';
|
||||
await manager.handleRenameFolderSubmit();
|
||||
|
||||
expect(apiClient.renameFolder).not.toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.dragDrop.invalidFolderName', {}, 'error');
|
||||
// The row stays open so the name can be corrected
|
||||
expect(document.getElementById('sidebarRenameFolderInput')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces a name collision', async () => {
|
||||
const conflict = Object.assign(new Error('already exists'), { code: 'target_exists' });
|
||||
const apiClient = createApiClient({
|
||||
renameFolder: vi.fn().mockRejectedValue(conflict),
|
||||
});
|
||||
const manager = createManager(apiClient);
|
||||
manager.refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const success = await manager._renameFolder('characters/anime', 'animation');
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.targetExists', {}, 'warning');
|
||||
expect(manager.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes the context-menu action to the inline rename row', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
manager.showRenameFolderInput = vi.fn();
|
||||
|
||||
manager._performFolderAction('rename-folder', 'characters/anime');
|
||||
|
||||
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
|
||||
});
|
||||
|
||||
it('hides the rename entry when folder management is unsupported', () => {
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="rename-folder"></div>
|
||||
</div>`);
|
||||
const apiClient = createApiClient();
|
||||
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||
const manager = createManager(apiClient);
|
||||
|
||||
manager._showFolderContextMenu(10, 10, 'empty');
|
||||
|
||||
const item = document.querySelector('#sidebarFolderContextMenu [data-action="rename-folder"]');
|
||||
expect(item.style.display).toBe('none');
|
||||
|
||||
manager._closeFolderContextMenu();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SidebarManager folder context menu layout', () => {
|
||||
// Mirrors templates/components/context_menu.html: the update check on top,
|
||||
// the folder operations as one group, delete last behind its own divider.
|
||||
const MENU_HTML = `
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="check-folder-updates"></div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="create-subfolder"></div>
|
||||
<div class="context-menu-item" data-action="rename-folder"></div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
|
||||
</div>`;
|
||||
|
||||
const separators = () => [...document.querySelectorAll('#sidebarFolderContextMenu .context-menu-separator')];
|
||||
const item = (action) => document.querySelector(`#sidebarFolderContextMenu [data-action="${action}"]`);
|
||||
const visible = (el) => el.style.display !== 'none';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = MENU_HTML;
|
||||
state.global.settings = {};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps both dividers on a library page', () => {
|
||||
const manager = createManager(createApiClient());
|
||||
|
||||
manager._showFolderContextMenu(10, 10, 'empty');
|
||||
|
||||
expect(separators().map(visible)).toEqual([true, true]);
|
||||
expect(visible(item('create-subfolder'))).toBe(true);
|
||||
expect(visible(item('rename-folder'))).toBe(true);
|
||||
expect(visible(item('delete-folder'))).toBe(true);
|
||||
|
||||
manager._closeFolderContextMenu();
|
||||
});
|
||||
|
||||
it('collapses both dividers when the page has no folder management', () => {
|
||||
const apiClient = createApiClient();
|
||||
apiClient.apiConfig.config.supportsFolderManagement = false;
|
||||
const manager = createManager(apiClient);
|
||||
|
||||
manager._showFolderContextMenu(10, 10, 'empty');
|
||||
|
||||
expect(visible(item('check-folder-updates'))).toBe(true);
|
||||
expect(visible(item('create-subfolder'))).toBe(false);
|
||||
expect(visible(item('rename-folder'))).toBe(false);
|
||||
expect(visible(item('delete-folder'))).toBe(false);
|
||||
// Nothing left to divide: the update check stands alone
|
||||
expect(separators().map(visible)).toEqual([false, false]);
|
||||
|
||||
manager._closeFolderContextMenu();
|
||||
});
|
||||
|
||||
it('drops leading, trailing and doubled separators', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="a"></div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="b"></div>
|
||||
<div class="context-menu-separator"></div>
|
||||
</div>`;
|
||||
const manager = createManager(createApiClient());
|
||||
|
||||
manager._updateContextMenuSeparators(document.getElementById('sidebarFolderContextMenu'));
|
||||
|
||||
expect(separators().map(visible)).toEqual([false, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Regression guard for the destructive context-menu entries.
|
||||
//
|
||||
// They were styled with `var(--danger-color)`, a token defined nowhere in the
|
||||
// stylesheet tree. A var() reference to an undefined property makes the
|
||||
// declaration invalid at computed-value time, so the colour silently fell back
|
||||
// to the menu's inherited text colour: every "Delete …" entry in the folder
|
||||
// sidebar menu and the model-card menus rendered plain. The first check below
|
||||
// keeps menu.css wired only to tokens that actually resolve.
|
||||
describe('Context menu design tokens', () => {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const cssDir = path.join(repoRoot, 'static/css');
|
||||
const menuCss = readFileSync(path.join(cssDir, 'components/menu.css'), 'utf-8');
|
||||
|
||||
const collectCss = (dir, files = []) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) collectCss(full, files);
|
||||
else if (entry.name.endsWith('.css')) files.push(readFileSync(full, 'utf-8'));
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const allCss = collectCss(cssDir).join('\n');
|
||||
const defined = new Set([...allCss.matchAll(/(--[\w-]+)\s*:/g)].map((match) => match[1]));
|
||||
|
||||
// var(--x) with no fallback: an undefined name is a silent no-op.
|
||||
const usedWithoutFallback = [...menuCss.matchAll(/var\(\s*(--[\w-]+)\s*\)/g)].map(
|
||||
(match) => match[1]
|
||||
);
|
||||
|
||||
it('resolves every custom property used by the context menu', () => {
|
||||
const unresolved = [...new Set(usedWithoutFallback)].filter((name) => !defined.has(name));
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it('paints destructive entries with the themed error colour', () => {
|
||||
const rule = menuCss.match(/\.context-menu-item\.delete-item\s*\{([^}]*)\}/);
|
||||
expect(rule).not.toBeNull();
|
||||
expect(rule[1]).toContain('var(--lora-error)');
|
||||
expect(defined.has('--lora-error')).toBe(true);
|
||||
});
|
||||
|
||||
it('gives destructive entries their own hover treatment', () => {
|
||||
// The shared hover paints the accent background, which a red label does
|
||||
// not read against.
|
||||
expect(menuCss).toMatch(/\.context-menu-item\.delete-item:hover[\s\S]*?\{/);
|
||||
expect(menuCss).toMatch(/\.context-menu-item\.delete-item:hover[\s\S]*?var\(--lora-error-bg\)/);
|
||||
});
|
||||
|
||||
it('never references the dead --danger-color token', () => {
|
||||
// Comments may name it; a var() argument may not.
|
||||
expect(allCss).not.toMatch(/var\(\s*--danger-color/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Regression guard for the sidebar folder context-menu layout: the update check
|
||||
// sits on top, the folder operations form a single group, and the destructive
|
||||
// entry stays last behind its own divider. SidebarManager gates those groups
|
||||
// per page and collapses the dividers when a group is hidden, so a reorder here
|
||||
// also changes what the recipes page shows.
|
||||
describe('Sidebar folder context menu layout', () => {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const html = readFileSync(
|
||||
path.join(repoRoot, 'templates/components/context_menu.html'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const menuHtml = html.slice(
|
||||
html.indexOf('id="sidebarFolderContextMenu"'),
|
||||
html.indexOf('<!-- Sidebar View Options Menu -->')
|
||||
);
|
||||
|
||||
const sequence = [...menuHtml.matchAll(/<div class="([^"]+)"([^>]*)>/g)].map(([, classes, rest]) => {
|
||||
if (classes.includes('context-menu-separator')) return 'separator';
|
||||
return /data-action="([^"]+)"/.exec(rest)?.[1] || null;
|
||||
});
|
||||
|
||||
it('keeps the update check first and the folder operations grouped', () => {
|
||||
expect(sequence).toEqual([
|
||||
'check-folder-updates',
|
||||
'separator',
|
||||
'create-subfolder',
|
||||
'rename-folder',
|
||||
'separator',
|
||||
'delete-folder',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the destructive entry last and visually marked', () => {
|
||||
const deleteEntry = menuHtml.match(/<div class="([^"]*)"\s+data-action="delete-folder"/);
|
||||
expect(deleteEntry).not.toBeNull();
|
||||
expect(deleteEntry[1]).toContain('delete-item');
|
||||
expect(sequence[sequence.length - 1]).toBe('delete-folder');
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,23 @@ class FakeMoveService:
|
||||
def __init__(self, result):
|
||||
self._result = result
|
||||
self.received_path = None
|
||||
self.received_dry_run = None
|
||||
self.received_new_name = None
|
||||
|
||||
async def create_folder(self, folder_path):
|
||||
self.received_path = folder_path
|
||||
return self._result
|
||||
|
||||
async def delete_folder(self, folder_path, dry_run=False):
|
||||
self.received_path = folder_path
|
||||
self.received_dry_run = dry_run
|
||||
return self._result
|
||||
|
||||
async def rename_folder(self, folder_path, new_name):
|
||||
self.received_path = folder_path
|
||||
self.received_new_name = new_name
|
||||
return self._result
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, payload):
|
||||
@@ -91,3 +103,231 @@ async def test_create_folder_invalid_json_body():
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_success():
|
||||
handler, service = _make_handler(
|
||||
{
|
||||
"success": True,
|
||||
"folder": "characters/anime",
|
||||
"model_count": 0,
|
||||
"restorable": True,
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.delete_folder(
|
||||
FakeRequest({"folder_path": "/library/characters/anime"})
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is True
|
||||
assert payload["restorable"] is True
|
||||
assert service.received_path == "/library/characters/anime"
|
||||
assert service.received_dry_run is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_forwards_dry_run():
|
||||
handler, service = _make_handler({"success": True, "dry_run": True})
|
||||
|
||||
response = await handler.delete_folder(
|
||||
FakeRequest({"folder_path": "/library/empty", "dry_run": True})
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert service.received_dry_run is True
|
||||
assert json.loads(response.text)["dry_run"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_missing_path():
|
||||
handler, service = _make_handler({"success": True})
|
||||
|
||||
response = await handler.delete_folder(FakeRequest({}))
|
||||
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert service.received_path is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_not_empty_maps_to_409():
|
||||
handler, _service = _make_handler(
|
||||
{
|
||||
"success": False,
|
||||
"code": "not_empty",
|
||||
"error": "Folder still contains 2 model file(s); delete or move them first",
|
||||
"manifest": {"model_count": 2},
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.delete_folder(
|
||||
FakeRequest({"folder_path": "/library/full"})
|
||||
)
|
||||
|
||||
assert response.status == 409
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert payload["code"] == "not_empty"
|
||||
assert payload["manifest"]["model_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_busy_maps_to_409():
|
||||
handler, _service = _make_handler(
|
||||
{"success": False, "code": "busy", "error": "staged delete pending"}
|
||||
)
|
||||
|
||||
response = await handler.delete_folder(
|
||||
FakeRequest({"folder_path": "/library/full"})
|
||||
)
|
||||
|
||||
assert response.status == 409
|
||||
assert json.loads(response.text)["code"] == "busy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_containment_failure_maps_to_400():
|
||||
handler, _service = _make_handler(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Folder path '/etc/evil' is outside configured library directories",
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.delete_folder(FakeRequest({"folder_path": "/etc/evil"}))
|
||||
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert "outside configured library" in payload["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_invalid_json_body():
|
||||
class BadJsonRequest:
|
||||
async def json(self):
|
||||
raise ValueError("bad json")
|
||||
|
||||
handler, _service = _make_handler({"success": True})
|
||||
|
||||
response = await handler.delete_folder(BadJsonRequest())
|
||||
|
||||
assert response.status == 400
|
||||
assert json.loads(response.text)["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_success():
|
||||
handler, service = _make_handler(
|
||||
{
|
||||
"success": True,
|
||||
"renamed": True,
|
||||
"folder": "characters/animation",
|
||||
"previous_folder": "characters/anime",
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.rename_folder(
|
||||
FakeRequest(
|
||||
{"folder_path": "/library/characters/anime", "new_name": "animation"}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is True
|
||||
assert payload["folder"] == "characters/animation"
|
||||
assert service.received_path == "/library/characters/anime"
|
||||
assert service.received_new_name == "animation"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_missing_path():
|
||||
handler, service = _make_handler({"success": True})
|
||||
|
||||
response = await handler.rename_folder(FakeRequest({"new_name": "animation"}))
|
||||
|
||||
assert response.status == 400
|
||||
assert json.loads(response.text)["success"] is False
|
||||
assert service.received_path is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_missing_name():
|
||||
handler, service = _make_handler({"success": True})
|
||||
|
||||
response = await handler.rename_folder(
|
||||
FakeRequest({"folder_path": "/library/characters/anime"})
|
||||
)
|
||||
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert service.received_new_name is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_target_exists_maps_to_409():
|
||||
handler, _service = _make_handler(
|
||||
{
|
||||
"success": False,
|
||||
"code": "target_exists",
|
||||
"error": 'A folder named "animation" already exists here',
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.rename_folder(
|
||||
FakeRequest(
|
||||
{"folder_path": "/library/characters/anime", "new_name": "animation"}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == 409
|
||||
payload = json.loads(response.text)
|
||||
assert payload["code"] == "target_exists"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_busy_maps_to_409():
|
||||
handler, _service = _make_handler(
|
||||
{"success": False, "code": "busy", "error": "staged delete pending"}
|
||||
)
|
||||
|
||||
response = await handler.rename_folder(
|
||||
FakeRequest({"folder_path": "/library/full", "new_name": "renamed"})
|
||||
)
|
||||
|
||||
assert response.status == 409
|
||||
assert json.loads(response.text)["code"] == "busy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_invalid_name_maps_to_400():
|
||||
handler, _service = _make_handler(
|
||||
{"success": False, "error": "Invalid characters in folder name"}
|
||||
)
|
||||
|
||||
response = await handler.rename_folder(
|
||||
FakeRequest({"folder_path": "/library/full", "new_name": "a/b"})
|
||||
)
|
||||
|
||||
assert response.status == 400
|
||||
assert json.loads(response.text)["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_invalid_json_body():
|
||||
class BadJsonRequest:
|
||||
async def json(self):
|
||||
raise ValueError("bad json")
|
||||
|
||||
handler, _service = _make_handler({"success": True})
|
||||
|
||||
response = await handler.rename_folder(BadJsonRequest())
|
||||
|
||||
assert response.status == 400
|
||||
assert json.loads(response.text)["success"] is False
|
||||
|
||||
@@ -14,6 +14,8 @@ class FakeScanner:
|
||||
def __init__(self, roots: List[Path]) -> None:
|
||||
self._roots = [str(root) for root in roots]
|
||||
self.known_folders: List[str] = []
|
||||
self.removed_folders: List[str] = []
|
||||
self.renamed_folders: List[tuple] = []
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
return list(self._roots)
|
||||
@@ -21,6 +23,12 @@ class FakeScanner:
|
||||
async def add_known_folder(self, folder: str) -> None:
|
||||
self.known_folders.append(folder)
|
||||
|
||||
async def remove_known_folder(self, folder: str) -> None:
|
||||
self.removed_folders.append(folder)
|
||||
|
||||
async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None:
|
||||
self.renamed_folders.append((previous, current, kwargs))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
|
||||
@@ -87,3 +95,334 @@ async def test_create_folder_requires_path(tmp_path: Path):
|
||||
result = await service.create_folder("")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def _make_nested(root: Path) -> Path:
|
||||
target = root / "characters" / "anime"
|
||||
target.mkdir(parents=True)
|
||||
return target
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["folder"] == "characters/anime"
|
||||
assert result["model_count"] == 0
|
||||
assert result["restorable"] is True
|
||||
assert not target.exists()
|
||||
assert scanner.removed_folders == ["characters/anime"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_reports_non_model_leftovers_as_not_restorable(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / "notes.txt").write_text("keep me?", encoding="utf-8")
|
||||
(target / "nested").mkdir()
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["file_count"] == 1
|
||||
assert result["dir_count"] == 1
|
||||
assert result["restorable"] is False
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
model_file = target / "model.safetensors"
|
||||
model_file.write_text("weights", encoding="utf-8")
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "not_empty"
|
||||
assert result["manifest"]["model_count"] == 1
|
||||
assert target.exists()
|
||||
assert model_file.exists()
|
||||
assert scanner.removed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / ".lm-pending-delete").mkdir()
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "busy"
|
||||
assert target.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(tmp_path))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "root" in result["error"].lower()
|
||||
assert tmp_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
|
||||
root = tmp_path / "library"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
scanner = FakeScanner([root])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(outside))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert outside.exists()
|
||||
assert scanner.removed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_reports_missing_directory(tmp_path: Path):
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(tmp_path / "gone"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "no longer exists" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_requires_path(tmp_path: Path):
|
||||
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
||||
|
||||
result = await service.delete_folder("")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_dry_run_reports_without_removing(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / "leftover.webp").write_text("preview", encoding="utf-8")
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target), dry_run=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["dry_run"] is True
|
||||
assert result["file_count"] == 1
|
||||
assert target.exists()
|
||||
assert scanner.removed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_refuses_symlinked_directory(tmp_path: Path):
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
link = tmp_path / "link"
|
||||
try:
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError): # pragma: no cover - platform guard
|
||||
pytest.skip("symlinks are not supported on this platform")
|
||||
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(link))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "symlink" in result["error"].lower()
|
||||
assert link.is_symlink()
|
||||
assert real.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
(real / "model.safetensors").write_text("weights", encoding="utf-8")
|
||||
try:
|
||||
(target / "linked").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError): # pragma: no cover - platform guard
|
||||
pytest.skip("symlinks are not supported on this platform")
|
||||
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.delete_folder(str(target))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["symlink_count"] == 1
|
||||
# The linked model is not part of the subtree being deleted
|
||||
assert result["model_count"] == 0
|
||||
assert (real / "model.safetensors").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_moves_directory_and_forwards_rekey(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / "model.safetensors").write_text("weights", encoding="utf-8")
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), "animation")
|
||||
|
||||
renamed = tmp_path / "characters" / "animation"
|
||||
assert result["success"] is True
|
||||
assert result["renamed"] is True
|
||||
assert result["folder"] == "characters/animation"
|
||||
assert result["previous_folder"] == "characters/anime"
|
||||
assert renamed.is_dir()
|
||||
assert (renamed / "model.safetensors").exists()
|
||||
assert not target.exists()
|
||||
|
||||
previous, current, kwargs = scanner.renamed_folders[0]
|
||||
assert previous == "characters/anime"
|
||||
assert current == "characters/animation"
|
||||
assert kwargs["previous_path"] == target.as_posix()
|
||||
assert kwargs["new_path"] == renamed.as_posix()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_noop_when_name_is_unchanged(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), "anime")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["renamed"] is False
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_refuses_existing_target(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(tmp_path / "characters" / "animation").mkdir()
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), "animation")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "target_exists"
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("new_name", ["", " ", "a/b", "..", ".", "bad:name", "back\\slash"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_rejects_invalid_names(tmp_path: Path, new_name: str):
|
||||
target = _make_nested(tmp_path)
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), new_name)
|
||||
|
||||
assert result["success"] is False
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_refuses_the_library_root_itself(tmp_path: Path):
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(tmp_path), "renamed-root")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "root" in result["error"].lower()
|
||||
assert tmp_path.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_rejects_paths_outside_roots(tmp_path: Path):
|
||||
root = tmp_path / "library"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
scanner = FakeScanner([root])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(outside), "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert outside.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_reports_missing_directory(tmp_path: Path):
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(tmp_path / "gone"), "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "no longer exists" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
link = tmp_path / "link"
|
||||
try:
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError): # pragma: no cover - platform guard
|
||||
pytest.skip("symlinks are not supported on this platform")
|
||||
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(link), "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "symlink" in result["error"].lower()
|
||||
assert link.is_symlink()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
|
||||
target = _make_nested(tmp_path)
|
||||
(target / ".lm-pending-delete").mkdir()
|
||||
scanner = FakeScanner([tmp_path])
|
||||
service = ModelMoveService(scanner, "lora")
|
||||
|
||||
result = await service.rename_folder(str(target), "animation")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "busy"
|
||||
assert target.is_dir()
|
||||
assert scanner.renamed_folders == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_requires_path(tmp_path: Path):
|
||||
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
|
||||
|
||||
result = await service.rename_folder("", "renamed")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
@@ -1547,6 +1547,223 @@ async def test_add_known_folder_ignores_empty_input(tmp_path: Path):
|
||||
assert cache.all_folders == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_known_folder_drops_subtree_and_keeps_ancestors(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
await scanner.add_known_folder("nested/deep/leaf")
|
||||
|
||||
await scanner.remove_known_folder("nested/deep")
|
||||
|
||||
assert "nested/deep" not in cache.all_folders
|
||||
assert "nested/deep/leaf" not in cache.all_folders
|
||||
# The ancestor directory still exists on disk in its own right
|
||||
assert "nested" in cache.all_folders
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_known_folder_purges_stale_cache_entries(tmp_path: Path):
|
||||
_, second, _ = _create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
assert "nested" in cache.folders
|
||||
|
||||
await scanner.remove_known_folder("nested")
|
||||
|
||||
assert "nested" not in cache.all_folders
|
||||
assert "nested" not in cache.folders
|
||||
assert _normalize_path(second) not in {
|
||||
item["file_path"] for item in cache.raw_data
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_known_folder_noop_without_recorded_folders(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
cache.all_folders = None
|
||||
|
||||
# Legacy snapshot without recorded folders: nothing to prune, and the
|
||||
# scheduled backfill walk rebuilds the list from disk.
|
||||
await scanner.remove_known_folder("nested")
|
||||
|
||||
assert cache.all_folders is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_known_folder_ignores_empty_input(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
before = list(cache.all_folders)
|
||||
|
||||
await scanner.remove_known_folder("")
|
||||
await scanner.remove_known_folder("/")
|
||||
|
||||
assert cache.all_folders == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_rekeys_folders_cache_and_sidecar(tmp_path: Path):
|
||||
_, second, _ = _create_files(tmp_path)
|
||||
nested = tmp_path / "nested"
|
||||
preview = nested / "two.preview.png"
|
||||
preview.write_text("png", encoding="utf-8")
|
||||
(nested / "two.metadata.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"file_path": _normalize_path(second),
|
||||
"preview_url": _normalize_path(preview),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
entry = next(item for item in cache.raw_data if item["model_name"] == "two")
|
||||
entry["preview_url"] = _normalize_path(preview)
|
||||
|
||||
renamed = tmp_path / "renamed"
|
||||
old_abs = _normalize_path(nested)
|
||||
new_abs = _normalize_path(renamed)
|
||||
os.rename(nested, renamed)
|
||||
|
||||
changed = await scanner.rename_known_folder(
|
||||
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
assert changed is True
|
||||
assert "renamed" in cache.all_folders
|
||||
assert "nested" not in cache.all_folders
|
||||
assert "renamed" in cache.folders
|
||||
assert "nested" not in cache.folders
|
||||
assert entry["folder"] == "renamed"
|
||||
assert entry["file_path"] == _normalize_path(renamed / "two.txt")
|
||||
assert entry["preview_url"] == _normalize_path(renamed / "two.preview.png")
|
||||
assert scanner._hash_index.get_path("hash-two") == _normalize_path(
|
||||
renamed / "two.txt"
|
||||
)
|
||||
|
||||
# The sidecar travelled with the directory and was re-pointed in place
|
||||
payload = json.loads(
|
||||
(renamed / "two.metadata.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert payload["file_path"] == _normalize_path(renamed / "two.txt")
|
||||
assert payload["preview_url"] == _normalize_path(renamed / "two.preview.png")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_handles_nested_targets(tmp_path: Path):
|
||||
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
|
||||
model = tmp_path / "a" / "b" / "c" / "m.txt"
|
||||
model.write_text("m", encoding="utf-8")
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
old_abs = _normalize_path(tmp_path / "a" / "b")
|
||||
new_abs = _normalize_path(tmp_path / "a" / "z")
|
||||
os.rename(tmp_path / "a" / "b", tmp_path / "a" / "z")
|
||||
|
||||
await scanner.rename_known_folder(
|
||||
"a/b", "a/z", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
assert "a/b" not in cache.all_folders
|
||||
assert "a/b/c" not in cache.all_folders
|
||||
assert "a/z" in cache.all_folders
|
||||
assert "a/z/c" in cache.all_folders
|
||||
# The parent is an untouched directory in its own right
|
||||
assert "a" in cache.all_folders
|
||||
|
||||
entry = next(item for item in cache.raw_data if item["model_name"] == "m")
|
||||
assert entry["folder"] == "a/z/c"
|
||||
assert entry["file_path"] == _normalize_path(tmp_path / "a" / "z" / "c" / "m.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_keeps_unrelated_entries(tmp_path: Path):
|
||||
first, _, _ = _create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
old_abs = _normalize_path(tmp_path / "nested")
|
||||
new_abs = _normalize_path(tmp_path / "renamed")
|
||||
os.rename(tmp_path / "nested", tmp_path / "renamed")
|
||||
|
||||
await scanner.rename_known_folder(
|
||||
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
root_entry = next(item for item in cache.raw_data if item["model_name"] == "one")
|
||||
assert root_entry["folder"] == ""
|
||||
assert root_entry["file_path"] == _normalize_path(first)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_rekeys_excluded_models(tmp_path: Path):
|
||||
nested = tmp_path / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "one.txt").write_text("one", encoding="utf-8")
|
||||
(nested / "skip-me.txt").write_text("skip", encoding="utf-8")
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
|
||||
assert scanner._excluded_models == [_normalize_path(nested / "skip-me.txt")]
|
||||
|
||||
old_abs = _normalize_path(nested)
|
||||
new_abs = _normalize_path(tmp_path / "renamed")
|
||||
os.rename(nested, tmp_path / "renamed")
|
||||
|
||||
await scanner.rename_known_folder(
|
||||
"nested", "renamed", previous_path=old_abs, new_path=new_abs
|
||||
)
|
||||
|
||||
assert scanner._excluded_models == [
|
||||
_normalize_path(tmp_path / "renamed" / "skip-me.txt")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_known_folder_ignores_unchanged_or_empty_names(tmp_path: Path):
|
||||
_create_files(tmp_path)
|
||||
scanner = DummyScanner(tmp_path)
|
||||
await scanner._initialize_cache()
|
||||
cache = await scanner.get_cached_data()
|
||||
before = list(cache.all_folders)
|
||||
|
||||
assert (
|
||||
await scanner.rename_known_folder(
|
||||
"nested",
|
||||
"nested",
|
||||
previous_path=_normalize_path(tmp_path / "nested"),
|
||||
new_path=_normalize_path(tmp_path / "nested"),
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
await scanner.rename_known_folder(
|
||||
"",
|
||||
"renamed",
|
||||
previous_path=_normalize_path(tmp_path),
|
||||
new_path=_normalize_path(tmp_path / "renamed"),
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
assert cache.all_folders == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_folders_updated_after_move(tmp_path: Path):
|
||||
first, _, _ = _create_files(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user