feat(recipes): add configurable storage path migration

This commit is contained in:
Will Miao
2026-04-09 15:57:37 +08:00
parent e13d70248a
commit db4726a961
20 changed files with 722 additions and 3 deletions

View File

@@ -264,6 +264,7 @@
"layoutSettings": "Layout-Einstellungen", "layoutSettings": "Layout-Einstellungen",
"misc": "Verschiedenes", "misc": "Verschiedenes",
"folderSettings": "Standard-Roots", "folderSettings": "Standard-Roots",
"recipeSettings": "Rezepte",
"extraFolderPaths": "Zusätzliche Ordnerpfade", "extraFolderPaths": "Zusätzliche Ordnerpfade",
"downloadPathTemplates": "Download-Pfad-Vorlagen", "downloadPathTemplates": "Download-Pfad-Vorlagen",
"priorityTags": "Prioritäts-Tags", "priorityTags": "Prioritäts-Tags",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultEmbeddingRoot": "Embedding-Stammordner", "defaultEmbeddingRoot": "Embedding-Stammordner",
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"recipesPath": "Rezepte-Speicherpfad",
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "Rezepte-Speicher wird verschoben...",
"noDefault": "Kein Standard" "noDefault": "Kein Standard"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "Fehler beim Speichern der Basis-Modell-Zuordnungen: {message}", "mappingSaveFailed": "Fehler beim Speichern der Basis-Modell-Zuordnungen: {message}",
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert", "downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}", "downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
"settingsUpdated": "Einstellungen aktualisiert: {setting}", "settingsUpdated": "Einstellungen aktualisiert: {setting}",
"compactModeToggled": "Kompakt-Modus {state}", "compactModeToggled": "Kompakt-Modus {state}",
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}", "settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "Layout Settings", "layoutSettings": "Layout Settings",
"misc": "Miscellaneous", "misc": "Miscellaneous",
"folderSettings": "Default Roots", "folderSettings": "Default Roots",
"recipeSettings": "Recipes",
"extraFolderPaths": "Extra Folder Paths", "extraFolderPaths": "Extra Folder Paths",
"downloadPathTemplates": "Download Path Templates", "downloadPathTemplates": "Download Path Templates",
"priorityTags": "Priority Tags", "priorityTags": "Priority Tags",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves", "defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
"defaultEmbeddingRoot": "Embedding Root", "defaultEmbeddingRoot": "Embedding Root",
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves", "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
"recipesPath": "Recipes Storage Path",
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "Migrating recipes storage...",
"noDefault": "No Default" "noDefault": "No Default"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "Failed to save base model mappings: {message}", "mappingSaveFailed": "Failed to save base model mappings: {message}",
"downloadTemplatesUpdated": "Download path templates updated", "downloadTemplatesUpdated": "Download path templates updated",
"downloadTemplatesFailed": "Failed to save download path templates: {message}", "downloadTemplatesFailed": "Failed to save download path templates: {message}",
"recipesPathUpdated": "Recipes storage path updated",
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
"settingsUpdated": "Settings updated: {setting}", "settingsUpdated": "Settings updated: {setting}",
"compactModeToggled": "Compact Mode {state}", "compactModeToggled": "Compact Mode {state}",
"settingSaveFailed": "Failed to save setting: {message}", "settingSaveFailed": "Failed to save setting: {message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "Configuración de diseño", "layoutSettings": "Configuración de diseño",
"misc": "Varios", "misc": "Varios",
"folderSettings": "Raíces predeterminadas", "folderSettings": "Raíces predeterminadas",
"recipeSettings": "Recetas",
"extraFolderPaths": "Rutas de carpetas adicionales", "extraFolderPaths": "Rutas de carpetas adicionales",
"downloadPathTemplates": "Plantillas de rutas de descarga", "downloadPathTemplates": "Plantillas de rutas de descarga",
"priorityTags": "Etiquetas prioritarias", "priorityTags": "Etiquetas prioritarias",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos", "defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
"defaultEmbeddingRoot": "Raíz de embedding", "defaultEmbeddingRoot": "Raíz de embedding",
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"recipesPath": "Ruta de almacenamiento de recetas",
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "Migrando el almacenamiento de recetas...",
"noDefault": "Sin predeterminado" "noDefault": "Sin predeterminado"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}", "mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas", "downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}", "downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
"settingsUpdated": "Configuración actualizada: {setting}", "settingsUpdated": "Configuración actualizada: {setting}",
"compactModeToggled": "Modo compacto {state}", "compactModeToggled": "Modo compacto {state}",
"settingSaveFailed": "Error al guardar configuración: {message}", "settingSaveFailed": "Error al guardar configuración: {message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "Paramètres d'affichage", "layoutSettings": "Paramètres d'affichage",
"misc": "Divers", "misc": "Divers",
"folderSettings": "Racines par défaut", "folderSettings": "Racines par défaut",
"recipeSettings": "Recipes",
"extraFolderPaths": "Chemins de dossiers supplémentaires", "extraFolderPaths": "Chemins de dossiers supplémentaires",
"downloadPathTemplates": "Modèles de chemin de téléchargement", "downloadPathTemplates": "Modèles de chemin de téléchargement",
"priorityTags": "Étiquettes prioritaires", "priorityTags": "Étiquettes prioritaires",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements", "defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
"defaultEmbeddingRoot": "Racine Embedding", "defaultEmbeddingRoot": "Racine Embedding",
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
"recipesPath": "Recipes Storage Path",
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "Migrating recipes storage...",
"noDefault": "Aucun par défaut" "noDefault": "Aucun par défaut"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}", "mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour", "downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}", "downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
"recipesPathUpdated": "Recipes storage path updated",
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
"settingsUpdated": "Paramètres mis à jour : {setting}", "settingsUpdated": "Paramètres mis à jour : {setting}",
"compactModeToggled": "Mode compact {state}", "compactModeToggled": "Mode compact {state}",
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}", "settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"misc": "שונות", "misc": "שונות",
"folderSettings": "תיקיות ברירת מחדל", "folderSettings": "תיקיות ברירת מחדל",
"recipeSettings": "מתכונים",
"extraFolderPaths": "נתיבי תיקיות נוספים", "extraFolderPaths": "נתיבי תיקיות נוספים",
"downloadPathTemplates": "תבניות נתיב הורדה", "downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות", "priorityTags": "תגיות עדיפות",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות", "defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
"defaultEmbeddingRoot": "תיקיית שורש Embedding", "defaultEmbeddingRoot": "תיקיית שורש Embedding",
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"recipesPath": "נתיב אחסון מתכונים",
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "מעביר את אחסון המתכונים...",
"noDefault": "אין ברירת מחדל" "noDefault": "אין ברירת מחדל"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}", "mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו", "downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}", "downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
"settingsUpdated": "הגדרות עודכנו: {setting}", "settingsUpdated": "הגדרות עודכנו: {setting}",
"compactModeToggled": "מצב קומפקטי {state}", "compactModeToggled": "מצב קומפקטי {state}",
"settingSaveFailed": "שמירת ההגדרה נכשלה: {message}", "settingSaveFailed": "שמירת ההגדרה נכשלה: {message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "レイアウト設定", "layoutSettings": "レイアウト設定",
"misc": "その他", "misc": "その他",
"folderSettings": "デフォルトルート", "folderSettings": "デフォルトルート",
"recipeSettings": "レシピ",
"extraFolderPaths": "追加フォルダーパス", "extraFolderPaths": "追加フォルダーパス",
"downloadPathTemplates": "ダウンロードパステンプレート", "downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ", "priorityTags": "優先タグ",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定", "defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
"defaultEmbeddingRoot": "Embeddingルート", "defaultEmbeddingRoot": "Embeddingルート",
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
"recipesPath": "レシピ保存先",
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "レシピ保存先を移動中...",
"noDefault": "デフォルトなし" "noDefault": "デフォルトなし"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}", "mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました", "downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}", "downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
"recipesPathUpdated": "レシピ保存先を更新しました",
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
"settingsUpdated": "設定が更新されました:{setting}", "settingsUpdated": "設定が更新されました:{setting}",
"compactModeToggled": "コンパクトモード {state}", "compactModeToggled": "コンパクトモード {state}",
"settingSaveFailed": "設定の保存に失敗しました:{message}", "settingSaveFailed": "設定の保存に失敗しました:{message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "레이아웃 설정", "layoutSettings": "레이아웃 설정",
"misc": "기타", "misc": "기타",
"folderSettings": "기본 루트", "folderSettings": "기본 루트",
"recipeSettings": "레시피",
"extraFolderPaths": "추가 폴다 경로", "extraFolderPaths": "추가 폴다 경로",
"downloadPathTemplates": "다운로드 경로 템플릿", "downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그", "priorityTags": "우선순위 태그",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다", "defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
"defaultEmbeddingRoot": "Embedding 루트", "defaultEmbeddingRoot": "Embedding 루트",
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
"recipesPath": "레시피 저장 경로",
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "레시피 저장 경로를 이동 중...",
"noDefault": "기본값 없음" "noDefault": "기본값 없음"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}", "mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다", "downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}", "downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
"settingsUpdated": "설정 업데이트됨: {setting}", "settingsUpdated": "설정 업데이트됨: {setting}",
"compactModeToggled": "컴팩트 모드 {state}", "compactModeToggled": "컴팩트 모드 {state}",
"settingSaveFailed": "설정 저장 실패: {message}", "settingSaveFailed": "설정 저장 실패: {message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "Настройки макета", "layoutSettings": "Настройки макета",
"misc": "Разное", "misc": "Разное",
"folderSettings": "Корневые папки", "folderSettings": "Корневые папки",
"recipeSettings": "Рецепты",
"extraFolderPaths": "Дополнительные пути к папкам", "extraFolderPaths": "Дополнительные пути к папкам",
"downloadPathTemplates": "Шаблоны путей загрузки", "downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги", "priorityTags": "Приоритетные теги",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений", "defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
"defaultEmbeddingRoot": "Корневая папка Embedding", "defaultEmbeddingRoot": "Корневая папка Embedding",
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
"recipesPath": "Путь хранения рецептов",
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "Перенос хранилища рецептов...",
"noDefault": "Не задано" "noDefault": "Не задано"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}", "mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены", "downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}", "downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
"recipesPathUpdated": "Путь хранения рецептов обновлён",
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
"settingsUpdated": "Настройки обновлены: {setting}", "settingsUpdated": "Настройки обновлены: {setting}",
"compactModeToggled": "Компактный режим {state}", "compactModeToggled": "Компактный режим {state}",
"settingSaveFailed": "Не удалось сохранить настройку: {message}", "settingSaveFailed": "Не удалось сохранить настройку: {message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "布局设置", "layoutSettings": "布局设置",
"misc": "其他", "misc": "其他",
"folderSettings": "默认根目录", "folderSettings": "默认根目录",
"recipeSettings": "配方",
"extraFolderPaths": "额外文件夹路径", "extraFolderPaths": "额外文件夹路径",
"downloadPathTemplates": "下载路径模板", "downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签", "priorityTags": "优先标签",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录", "defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
"defaultEmbeddingRoot": "Embedding 根目录", "defaultEmbeddingRoot": "Embedding 根目录",
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
"recipesPath": "配方存储路径",
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "正在迁移配方存储...",
"noDefault": "无默认" "noDefault": "无默认"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "保存基础模型映射失败:{message}", "mappingSaveFailed": "保存基础模型映射失败:{message}",
"downloadTemplatesUpdated": "下载路径模板已更新", "downloadTemplatesUpdated": "下载路径模板已更新",
"downloadTemplatesFailed": "保存下载路径模板失败:{message}", "downloadTemplatesFailed": "保存下载路径模板失败:{message}",
"recipesPathUpdated": "配方存储路径已更新",
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
"settingsUpdated": "设置已更新:{setting}", "settingsUpdated": "设置已更新:{setting}",
"compactModeToggled": "紧凑模式 {state}", "compactModeToggled": "紧凑模式 {state}",
"settingSaveFailed": "保存设置失败:{message}", "settingSaveFailed": "保存设置失败:{message}",

View File

@@ -264,6 +264,7 @@
"layoutSettings": "版面設定", "layoutSettings": "版面設定",
"misc": "其他", "misc": "其他",
"folderSettings": "預設根目錄", "folderSettings": "預設根目錄",
"recipeSettings": "配方",
"extraFolderPaths": "額外資料夾路徑", "extraFolderPaths": "額外資料夾路徑",
"downloadPathTemplates": "下載路徑範本", "downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤", "priorityTags": "優先標籤",
@@ -393,6 +394,10 @@
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄", "defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
"defaultEmbeddingRoot": "Embedding 根目錄", "defaultEmbeddingRoot": "Embedding 根目錄",
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
"recipesPath": "配方儲存路徑",
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
"recipesPathPlaceholder": "/path/to/recipes",
"recipesPathMigrating": "正在遷移配方儲存...",
"noDefault": "未設定預設" "noDefault": "未設定預設"
}, },
"extraFolderPaths": { "extraFolderPaths": {
@@ -1629,6 +1634,8 @@
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}", "mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
"downloadTemplatesUpdated": "下載路徑範本已更新", "downloadTemplatesUpdated": "下載路徑範本已更新",
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}", "downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
"recipesPathUpdated": "配方儲存路徑已更新",
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
"settingsUpdated": "設定已更新:{setting}", "settingsUpdated": "設定已更新:{setting}",
"compactModeToggled": "緊湊模式已{state}", "compactModeToggled": "緊湊模式已{state}",
"settingSaveFailed": "儲存設定失敗:{message}", "settingSaveFailed": "儲存設定失敗:{message}",

View File

@@ -134,6 +134,7 @@ class Config:
self.extra_checkpoints_roots: List[str] = [] self.extra_checkpoints_roots: List[str] = []
self.extra_unet_roots: List[str] = [] self.extra_unet_roots: List[str] = []
self.extra_embeddings_roots: List[str] = [] self.extra_embeddings_roots: List[str] = []
self.recipes_path: str = ""
# Scan symbolic links during initialization # Scan symbolic links during initialization
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
@@ -652,6 +653,8 @@ class Config:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
for root in self.extra_embeddings_roots or []: for root in self.extra_embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
if self.recipes_path:
preview_roots.update(self._expand_preview_root(self.recipes_path))
for target, link in self._path_mappings.items(): for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target)) preview_roots.update(self._expand_preview_root(target))
@@ -911,9 +914,11 @@ class Config:
self, self,
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None, extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
recipes_path: str = "",
) -> None: ) -> None:
self._path_mappings.clear() self._path_mappings.clear()
self._preview_root_paths = set() self._preview_root_paths = set()
self.recipes_path = recipes_path if isinstance(recipes_path, str) else ""
lora_paths = folder_paths.get("loras", []) or [] lora_paths = folder_paths.get("loras", []) or []
checkpoint_paths = folder_paths.get("checkpoints", []) or [] checkpoint_paths = folder_paths.get("checkpoints", []) or []
@@ -1169,7 +1174,12 @@ class Config:
if not isinstance(extra_folder_paths, Mapping): if not isinstance(extra_folder_paths, Mapping):
extra_folder_paths = None extra_folder_paths = None
self._apply_library_paths(folder_paths, extra_folder_paths) recipes_path = (
str(library_config.get("recipes_path", ""))
if isinstance(library_config, Mapping)
else ""
)
self._apply_library_paths(folder_paths, extra_folder_paths, recipes_path)
logger.info( logger.info(
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)", "Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",

View File

@@ -18,6 +18,7 @@ from .service_registry import ServiceRegistry
from .lora_scanner import LoraScanner from .lora_scanner import LoraScanner
from .metadata_service import get_default_metadata_provider from .metadata_service import get_default_metadata_provider
from .checkpoint_scanner import CheckpointScanner from .checkpoint_scanner import CheckpointScanner
from .settings_manager import get_settings_manager
from .recipes.errors import RecipeNotFoundError from .recipes.errors import RecipeNotFoundError
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
from natsort import natsorted from natsort import natsorted
@@ -1090,6 +1091,14 @@ class RecipeScanner:
@property @property
def recipes_dir(self) -> str: def recipes_dir(self) -> str:
"""Get path to recipes directory""" """Get path to recipes directory"""
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
recipes_dir = os.path.abspath(
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
)
os.makedirs(recipes_dir, exist_ok=True)
return recipes_dir
if not config.loras_roots: if not config.loras_roots:
return "" return ""

View File

@@ -3,6 +3,7 @@ import copy
import json import json
import os import os
import shutil import shutil
import tempfile
import logging import logging
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -70,6 +71,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"default_checkpoint_root": "", "default_checkpoint_root": "",
"default_unet_root": "", "default_unet_root": "",
"default_embedding_root": "", "default_embedding_root": "",
"recipes_path": "",
"base_model_path_mappings": {}, "base_model_path_mappings": {},
"download_path_templates": {}, "download_path_templates": {},
"folder_paths": {}, "folder_paths": {},
@@ -254,6 +256,7 @@ class SettingsManager:
default_checkpoint_root=merged.get("default_checkpoint_root"), default_checkpoint_root=merged.get("default_checkpoint_root"),
default_unet_root=merged.get("default_unet_root"), default_unet_root=merged.get("default_unet_root"),
default_embedding_root=merged.get("default_embedding_root"), default_embedding_root=merged.get("default_embedding_root"),
recipes_path=merged.get("recipes_path"),
) )
} }
merged["active_library"] = library_name merged["active_library"] = library_name
@@ -382,6 +385,7 @@ class SettingsManager:
), ),
default_unet_root=self.settings.get("default_unet_root", ""), default_unet_root=self.settings.get("default_unet_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""), default_embedding_root=self.settings.get("default_embedding_root", ""),
recipes_path=self.settings.get("recipes_path", ""),
) )
libraries = {library_name: library_payload} libraries = {library_name: library_payload}
self.settings["libraries"] = libraries self.settings["libraries"] = libraries
@@ -429,6 +433,7 @@ class SettingsManager:
default_checkpoint_root=data.get("default_checkpoint_root"), default_checkpoint_root=data.get("default_checkpoint_root"),
default_unet_root=data.get("default_unet_root"), default_unet_root=data.get("default_unet_root"),
default_embedding_root=data.get("default_embedding_root"), default_embedding_root=data.get("default_embedding_root"),
recipes_path=data.get("recipes_path"),
metadata=data.get("metadata"), metadata=data.get("metadata"),
base=data, base=data,
) )
@@ -475,6 +480,7 @@ class SettingsManager:
self.settings["default_embedding_root"] = active_library.get( self.settings["default_embedding_root"] = active_library.get(
"default_embedding_root", "" "default_embedding_root", ""
) )
self.settings["recipes_path"] = active_library.get("recipes_path", "")
if save: if save:
self._save_settings() self._save_settings()
@@ -491,6 +497,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
recipes_path: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None,
base: Optional[Mapping[str, Any]] = None, base: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -529,6 +536,11 @@ class SettingsManager:
else: else:
payload.setdefault("default_embedding_root", "") payload.setdefault("default_embedding_root", "")
if recipes_path is not None:
payload["recipes_path"] = recipes_path
else:
payload.setdefault("recipes_path", "")
if metadata: if metadata:
merged_meta = dict(payload.get("metadata", {})) merged_meta = dict(payload.get("metadata", {}))
merged_meta.update(metadata) merged_meta.update(metadata)
@@ -630,6 +642,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
recipes_path: Optional[str] = None,
) -> bool: ) -> bool:
libraries = self.settings.get("libraries", {}) libraries = self.settings.get("libraries", {})
active_name = self.settings.get("active_library") active_name = self.settings.get("active_library")
@@ -679,6 +692,10 @@ class SettingsManager:
library["default_embedding_root"] = default_embedding_root library["default_embedding_root"] = default_embedding_root
changed = True changed = True
if recipes_path is not None and library.get("recipes_path") != recipes_path:
library["recipes_path"] = recipes_path
changed = True
if changed: if changed:
library.setdefault("created_at", self._current_timestamp()) library.setdefault("created_at", self._current_timestamp())
library["updated_at"] = self._current_timestamp() library["updated_at"] = self._current_timestamp()
@@ -942,7 +959,9 @@ class SettingsManager:
extra_folder_paths=defaults.get("extra_folder_paths", {}), extra_folder_paths=defaults.get("extra_folder_paths", {}),
default_lora_root=defaults.get("default_lora_root"), default_lora_root=defaults.get("default_lora_root"),
default_checkpoint_root=defaults.get("default_checkpoint_root"), default_checkpoint_root=defaults.get("default_checkpoint_root"),
default_unet_root=defaults.get("default_unet_root"),
default_embedding_root=defaults.get("default_embedding_root"), default_embedding_root=defaults.get("default_embedding_root"),
recipes_path=defaults.get("recipes_path"),
) )
defaults["libraries"] = {library_name: default_library} defaults["libraries"] = {library_name: default_library}
defaults["active_library"] = library_name defaults["active_library"] = library_name
@@ -1236,6 +1255,193 @@ class SettingsManager:
"""Get setting value""" """Get setting value"""
return self.settings.get(key, default) return self.settings.get(key, default)
def _normalize_recipes_path_value(self, value: Any) -> str:
"""Return a normalized absolute recipes path or an empty string."""
if not isinstance(value, str):
value = "" if value is None else str(value)
stripped = value.strip()
if not stripped:
return ""
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
"""Resolve the effective recipes directory for the active library."""
normalized_custom = self._normalize_recipes_path_value(
self.settings.get("recipes_path", "")
if recipes_path is None
else recipes_path
)
if normalized_custom:
return normalized_custom
folder_paths = self.settings.get("folder_paths", {})
configured_lora_roots = []
if isinstance(folder_paths, Mapping):
raw_lora_roots = folder_paths.get("loras", [])
if isinstance(raw_lora_roots, Sequence) and not isinstance(
raw_lora_roots, (str, bytes)
):
configured_lora_roots = [
path
for path in raw_lora_roots
if isinstance(path, str) and path.strip()
]
if configured_lora_roots:
lora_root = sorted(configured_lora_roots, key=str.casefold)[0]
return os.path.abspath(os.path.join(lora_root, "recipes"))
config_lora_roots = [
path
for path in getattr(config, "loras_roots", []) or []
if isinstance(path, str) and path.strip()
]
if not config_lora_roots:
return ""
return os.path.abspath(
os.path.join(sorted(config_lora_roots, key=str.casefold)[0], "recipes")
)
def _validate_recipes_storage_path(self, normalized_path: str) -> None:
"""Ensure the recipes storage target is usable before saving it."""
if not normalized_path:
return
if os.path.exists(normalized_path) and not os.path.isdir(normalized_path):
raise ValueError("Recipes path must point to a directory")
try:
os.makedirs(normalized_path, exist_ok=True)
except Exception as exc:
raise ValueError(f"Unable to create recipes directory: {exc}") from exc
try:
fd, probe_path = tempfile.mkstemp(
prefix=".lora-manager-recipes-", dir=normalized_path
)
os.close(fd)
os.remove(probe_path)
except Exception as exc:
raise ValueError(f"Recipes path is not writable: {exc}") from exc
def _migrate_recipes_directory(self, source_dir: str, target_dir: str) -> None:
"""Move existing recipe files to a new recipes root and rewrite JSON paths."""
source = os.path.abspath(os.path.normpath(source_dir)) if source_dir else ""
target = os.path.abspath(os.path.normpath(target_dir)) if target_dir else ""
if not source or not target or source == target:
return
if not os.path.exists(source):
os.makedirs(target, exist_ok=True)
return
if os.path.exists(target) and not os.path.isdir(target):
raise ValueError("Recipes path must point to a directory")
try:
common_root = os.path.commonpath([source, target])
except ValueError as exc:
raise ValueError("Invalid recipes path change") from exc
if common_root == source:
raise ValueError("Recipes path cannot be moved into a nested directory")
planned_recipe_updates: Dict[str, Dict[str, Any]] = {}
file_pairs: List[Tuple[str, str]] = []
for root, _, files in os.walk(source):
for filename in files:
source_path = os.path.normpath(os.path.join(root, filename))
relative_path = os.path.relpath(source_path, source)
target_path = os.path.normpath(os.path.join(target, relative_path))
file_pairs.append((source_path, target_path))
if not filename.endswith(".recipe.json"):
continue
try:
with open(source_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception as exc:
raise ValueError(
f"Unable to read recipe metadata during migration: {source_path}: {exc}"
) from exc
if not isinstance(payload, dict):
continue
file_path = payload.get("file_path")
if isinstance(file_path, str) and file_path.strip():
normalized_file_path = os.path.abspath(
os.path.normpath(os.path.expanduser(file_path))
)
source_candidates = [source]
real_source = os.path.abspath(
os.path.normpath(os.path.realpath(source_dir))
)
if real_source not in source_candidates:
source_candidates.append(real_source)
rewritten = False
for source_candidate in source_candidates:
try:
file_common_root = os.path.commonpath(
[normalized_file_path, source_candidate]
)
except ValueError:
continue
if file_common_root != source_candidate:
continue
image_relative_path = os.path.relpath(
normalized_file_path, source_candidate
)
payload["file_path"] = os.path.normpath(
os.path.join(target, image_relative_path)
)
rewritten = True
break
if not rewritten and source_candidates:
logger.debug(
"Skipping recipe file_path rewrite during migration for %s",
normalized_file_path,
)
planned_recipe_updates[target_path] = payload
for _, target_path in file_pairs:
if os.path.exists(target_path):
raise ValueError(
f"Recipes path already contains conflicting file: {target_path}"
)
os.makedirs(target, exist_ok=True)
for source_path, target_path in file_pairs:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
shutil.move(source_path, target_path)
for target_path, payload in planned_recipe_updates.items():
with open(target_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=4, ensure_ascii=False)
for root, dirs, files in os.walk(source, topdown=False):
if dirs or files:
continue
try:
os.rmdir(root)
except OSError:
pass
def set(self, key: str, value: Any) -> None: def set(self, key: str, value: Any) -> None:
"""Set setting value and save""" """Set setting value and save"""
if key == "auto_organize_exclusions": if key == "auto_organize_exclusions":
@@ -1246,6 +1452,12 @@ class SettingsManager:
value = self.normalize_download_skip_base_models(value) value = self.normalize_download_skip_base_models(value)
elif key == "mature_blur_level": elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value) value = self.normalize_mature_blur_level(value)
elif key == "recipes_path":
current_recipes_dir = self._get_effective_recipes_dir()
value = self._normalize_recipes_path_value(value)
target_recipes_dir = self._get_effective_recipes_dir(value)
self._validate_recipes_storage_path(target_recipes_dir)
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
self.settings[key] = value self.settings[key] = value
portable_switch_pending = False portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool): if key == "use_portable_settings" and isinstance(value, bool):
@@ -1263,9 +1475,13 @@ class SettingsManager:
self._update_active_library_entry(default_unet_root=str(value)) self._update_active_library_entry(default_unet_root=str(value))
elif key == "default_embedding_root": elif key == "default_embedding_root":
self._update_active_library_entry(default_embedding_root=str(value)) self._update_active_library_entry(default_embedding_root=str(value))
elif key == "recipes_path":
self._update_active_library_entry(recipes_path=str(value))
elif key == "model_name_display": elif key == "model_name_display":
self._notify_model_name_display_change(value) self._notify_model_name_display_change(value)
self._save_settings() self._save_settings()
if key == "recipes_path":
self._notify_library_change(self.get_active_library_name())
if portable_switch_pending: if portable_switch_pending:
self._finalize_portable_switch() self._finalize_portable_switch()
@@ -1575,6 +1791,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
recipes_path: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False, activate: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -1618,6 +1835,11 @@ class SettingsManager:
if default_embedding_root is not None if default_embedding_root is not None
else existing.get("default_embedding_root") else existing.get("default_embedding_root")
), ),
recipes_path=(
recipes_path
if recipes_path is not None
else existing.get("recipes_path")
),
metadata=metadata if metadata is not None else existing.get("metadata"), metadata=metadata if metadata is not None else existing.get("metadata"),
base=existing, base=existing,
) )
@@ -1645,6 +1867,7 @@ class SettingsManager:
default_checkpoint_root: str = "", default_checkpoint_root: str = "",
default_unet_root: str = "", default_unet_root: str = "",
default_embedding_root: str = "", default_embedding_root: str = "",
recipes_path: str = "",
metadata: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False, activate: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -1662,6 +1885,7 @@ class SettingsManager:
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root, default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root, default_embedding_root=default_embedding_root,
recipes_path=recipes_path,
metadata=metadata, metadata=metadata,
activate=activate, activate=activate,
) )
@@ -1721,6 +1945,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
recipes_path: Optional[str] = None,
) -> None: ) -> None:
"""Update folder paths for the active library.""" """Update folder paths for the active library."""
@@ -1733,6 +1958,7 @@ class SettingsManager:
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root, default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root, default_embedding_root=default_embedding_root,
recipes_path=recipes_path,
activate=True, activate=True,
) )

View File

@@ -766,6 +766,11 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
} }
const recipesPathInput = document.getElementById('recipesPath');
if (recipesPathInput) {
recipesPathInput.value = state.global.settings.recipes_path || '';
}
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions'); const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
if (autoOrganizeExclusionsInput) { if (autoOrganizeExclusionsInput) {
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions); const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
@@ -2464,6 +2469,7 @@ export class SettingsManager {
if (!element) return; if (!element) return;
const value = element.value.trim(); // Trim whitespace const value = element.value.trim(); // Trim whitespace
const shouldShowLoading = settingKey === 'recipes_path';
try { try {
// Check if value has changed from existing value // Check if value has changed from existing value
@@ -2472,6 +2478,12 @@ export class SettingsManager {
return; // No change, exit early return; // No change, exit early
} }
if (shouldShowLoading) {
state.loadingManager?.showSimpleLoading(
translate('settings.folderSettings.recipesPathMigrating', {}, 'Migrating recipes...')
);
}
// For username and password, handle empty values specially // For username and password, handle empty values specially
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') { if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
// Remove from state instead of setting to empty string // Remove from state instead of setting to empty string
@@ -2497,10 +2509,25 @@ export class SettingsManager {
await this.saveSetting(settingKey, value); await this.saveSetting(settingKey, value);
} }
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); if (shouldShowLoading) {
state.loadingManager?.hide();
}
if (settingKey === 'recipes_path') {
showToast('toast.settings.recipesPathUpdated', {}, 'success');
} else {
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
}
} catch (error) { } catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); if (shouldShowLoading) {
state.loadingManager?.hide();
}
if (settingKey === 'recipes_path') {
showToast('toast.settings.recipesPathSaveFailed', { message: error.message }, 'error');
} else {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
} }
} }

View File

@@ -18,6 +18,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
default_lora_root: '', default_lora_root: '',
default_checkpoint_root: '', default_checkpoint_root: '',
default_embedding_root: '', default_embedding_root: '',
recipes_path: '',
base_model_path_mappings: {}, base_model_path_mappings: {},
download_path_templates: {}, download_path_templates: {},
example_images_path: '', example_images_path: '',

View File

@@ -530,6 +530,32 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Recipe Settings -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.recipeSettings') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="recipesPath">
{{ t('settings.folderSettings.recipesPath') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.recipesPathHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text" id="recipesPath"
placeholder="{{ t('settings.folderSettings.recipesPathPlaceholder') }}"
onblur="settingsManager.saveInputSetting('recipesPath', 'recipes_path')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div> </div>
<!-- Extra Folder Paths --> <!-- Extra Folder Paths -->

View File

@@ -205,4 +205,58 @@ describe('SettingsManager library controls', () => {
expect(select.value).toBe('alpha'); expect(select.value).toBe('alpha');
expect(activateSpy).not.toHaveBeenCalled(); expect(activateSpy).not.toHaveBeenCalled();
}); });
it('loads recipes_path into the settings input', async () => {
const manager = createManager();
const input = document.createElement('input');
input.id = 'recipesPath';
document.body.appendChild(input);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
isAvailable: false,
isEnabled: false,
databaseSize: 0,
}),
});
state.global.settings = {
recipes_path: '/custom/recipes',
};
await manager.loadSettingsToUI();
expect(input.value).toBe('/custom/recipes');
});
it('shows loading while saving recipes_path', async () => {
const manager = createManager();
const input = document.createElement('input');
input.id = 'recipesPath';
input.value = '/custom/recipes';
document.body.appendChild(input);
state.global.settings = {
recipes_path: '',
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await manager.saveInputSetting('recipesPath', 'recipes_path');
expect(state.loadingManager.showSimpleLoading).toHaveBeenCalledWith(
'Migrating recipes...'
);
expect(state.loadingManager.hide).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith(
'toast.settings.recipesPathUpdated',
{},
'success',
);
});
}); });

View File

@@ -113,6 +113,78 @@ async def test_config_updates_preview_roots_after_switch(tmp_path):
assert decoded.replace("\\", "/").endswith("model.webp") assert decoded.replace("\\", "/").endswith("model.webp")
async def test_preview_handler_allows_custom_recipes_path(tmp_path):
lora_root = tmp_path / "library"
lora_root.mkdir()
recipes_root = tmp_path / "recipes_storage"
recipes_root.mkdir()
preview_file = recipes_root / "recipe.webp"
preview_file.write_bytes(b"preview")
config = Config()
config.apply_library_settings(
{
"folder_paths": {
"loras": [str(lora_root)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"recipes_path": str(recipes_root),
}
)
assert config.is_preview_path_allowed(str(preview_file))
handler = PreviewHandler(config=config)
encoded_path = urllib.parse.quote(str(preview_file), safe="")
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
response = await handler.serve_preview(request)
assert isinstance(response, web.FileResponse)
assert response.status == 200
assert Path(response._path) == preview_file
async def test_preview_handler_allows_symlinked_recipes_path(tmp_path):
lora_root = tmp_path / "library"
lora_root.mkdir()
real_recipes_root = tmp_path / "real_recipes"
real_recipes_root.mkdir()
symlink_recipes_root = tmp_path / "linked_recipes"
symlink_recipes_root.symlink_to(real_recipes_root, target_is_directory=True)
preview_file = real_recipes_root / "recipe.webp"
preview_file.write_bytes(b"preview")
config = Config()
config.apply_library_settings(
{
"folder_paths": {
"loras": [str(lora_root)],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"recipes_path": str(symlink_recipes_root),
}
)
symlink_preview_path = symlink_recipes_root / "recipe.webp"
assert config.is_preview_path_allowed(str(symlink_preview_path))
handler = PreviewHandler(config=config)
encoded_path = urllib.parse.quote(str(symlink_preview_path), safe="")
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
response = await handler.serve_preview(request)
assert isinstance(response, web.FileResponse)
assert response.status == 200
assert Path(response._path) == preview_file.resolve()
def test_is_preview_path_allowed_case_insensitive_on_windows(tmp_path): def test_is_preview_path_allowed_case_insensitive_on_windows(tmp_path):
"""Test that preview path validation is case-insensitive on Windows. """Test that preview path validation is case-insensitive on Windows.

View File

@@ -8,6 +8,7 @@ import pytest
from py.config import config from py.config import config
from py.services.recipe_scanner import RecipeScanner from py.services.recipe_scanner import RecipeScanner
from py.services import settings_manager as settings_manager_module
from py.utils.utils import calculate_recipe_fingerprint from py.utils.utils import calculate_recipe_fingerprint
@@ -72,12 +73,56 @@ class StubLoraScanner:
@pytest.fixture @pytest.fixture
def recipe_scanner(tmp_path: Path, monkeypatch): def recipe_scanner(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
stub = StubLoraScanner() stub = StubLoraScanner()
scanner = RecipeScanner(lora_scanner=stub) scanner = RecipeScanner(lora_scanner=stub)
asyncio.run(scanner.refresh_cache(force=True)) asyncio.run(scanner.refresh_cache(force=True))
yield scanner, stub yield scanner, stub
RecipeScanner._instance = None RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
settings_path = tmp_path / "settings.json"
custom_recipes = tmp_path / "custom" / ".." / "custom_recipes"
monkeypatch.setattr(
"py.services.settings_manager.ensure_settings_file",
lambda logger=None: str(settings_path),
)
monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "loras-root")])
manager = settings_manager_module.get_settings_manager()
manager.set("recipes_path", str(custom_recipes))
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
resolved = scanner.recipes_dir
assert resolved == str((tmp_path / "custom_recipes").resolve())
assert Path(resolved).is_dir()
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
def test_recipes_dir_falls_back_to_first_lora_root(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "alpha")])
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
resolved = scanner.recipes_dir
assert resolved == str(tmp_path / "alpha" / "recipes")
assert Path(resolved).is_dir()
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
async def test_add_recipe_during_concurrent_reads(recipe_scanner): async def test_add_recipe_during_concurrent_reads(recipe_scanner):

View File

@@ -496,6 +496,7 @@ def test_migrate_sanitizes_legacy_libraries(tmp_path, monkeypatch):
assert payload["default_lora_root"] == "" assert payload["default_lora_root"] == ""
assert payload["default_checkpoint_root"] == "" assert payload["default_checkpoint_root"] == ""
assert payload["default_embedding_root"] == "" assert payload["default_embedding_root"] == ""
assert payload["recipes_path"] == ""
assert manager.get_active_library_name() == "legacy" assert manager.get_active_library_name() == "legacy"
@@ -507,12 +508,14 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
"default_lora_root": "/loras", "default_lora_root": "/loras",
"default_checkpoint_root": "/ckpt", "default_checkpoint_root": "/ckpt",
"default_embedding_root": "/embed", "default_embedding_root": "/embed",
"recipes_path": "/loras/recipes",
}, },
"studio": { "studio": {
"folder_paths": {"loras": ["/studio"]}, "folder_paths": {"loras": ["/studio"]},
"default_lora_root": "/studio", "default_lora_root": "/studio",
"default_checkpoint_root": "/studio_ckpt", "default_checkpoint_root": "/studio_ckpt",
"default_embedding_root": "/studio_embed", "default_embedding_root": "/studio_embed",
"recipes_path": "/studio/custom-recipes",
}, },
}, },
"active_library": "studio", "active_library": "studio",
@@ -521,6 +524,7 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
"default_lora_root": "/loras", "default_lora_root": "/loras",
"default_checkpoint_root": "/ckpt", "default_checkpoint_root": "/ckpt",
"default_embedding_root": "/embed", "default_embedding_root": "/embed",
"recipes_path": "/loras/recipes",
} }
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial) manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
@@ -530,14 +534,17 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
assert manager.get("default_lora_root") == "/studio" assert manager.get("default_lora_root") == "/studio"
assert manager.get("default_checkpoint_root") == "/studio_ckpt" assert manager.get("default_checkpoint_root") == "/studio_ckpt"
assert manager.get("default_embedding_root") == "/studio_embed" assert manager.get("default_embedding_root") == "/studio_embed"
assert manager.get("recipes_path") == "/studio/custom-recipes"
# Drift the top-level values again and ensure activate_library repairs them # Drift the top-level values again and ensure activate_library repairs them
manager.settings["folder_paths"] = {"loras": ["/loras"]} manager.settings["folder_paths"] = {"loras": ["/loras"]}
manager.settings["default_lora_root"] = "/loras" manager.settings["default_lora_root"] = "/loras"
manager.settings["recipes_path"] = "/loras/recipes"
manager.activate_library("studio") manager.activate_library("studio")
assert manager.get("folder_paths")["loras"] == ["/studio"] assert manager.get("folder_paths")["loras"] == ["/studio"]
assert manager.get("default_lora_root") == "/studio" assert manager.get("default_lora_root") == "/studio"
assert manager.get("recipes_path") == "/studio/custom-recipes"
def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch): def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch):
@@ -554,6 +561,7 @@ def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatc
"default_lora_root": "", "default_lora_root": "",
"default_checkpoint_root": "", "default_checkpoint_root": "",
"default_embedding_root": "", "default_embedding_root": "",
"recipes_path": "",
} }
}, },
"active_library": "default", "active_library": "default",
@@ -589,6 +597,177 @@ def test_upsert_library_creates_entry_and_activates(manager, tmp_path):
assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths
def test_set_recipes_path_updates_active_library_entry(manager, tmp_path):
recipes_dir = tmp_path / "custom" / "recipes"
manager.set("recipes_path", str(recipes_dir))
assert manager.get("recipes_path") == str(recipes_dir.resolve())
assert (
manager.get_libraries()["default"]["recipes_path"]
== str(recipes_dir.resolve())
)
def test_set_recipes_path_migrates_existing_recipe_files(manager, tmp_path):
lora_root = tmp_path / "loras"
old_recipes_dir = lora_root / "recipes" / "nested"
old_recipes_dir.mkdir(parents=True)
manager.set("folder_paths", {"loras": [str(lora_root)]})
recipe_id = "recipe-1"
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
old_image_path.write_bytes(b"image-bytes")
old_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": str(old_image_path),
"title": "Recipe 1",
}
),
encoding="utf-8",
)
new_recipes_dir = tmp_path / "custom_recipes"
manager.set("recipes_path", str(new_recipes_dir))
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
assert migrated_image_path.read_bytes() == b"image-bytes"
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
assert migrated_payload["file_path"] == str(migrated_image_path)
assert not old_image_path.exists()
assert not old_json_path.exists()
def test_clearing_recipes_path_migrates_files_to_default_location(manager, tmp_path):
lora_root = tmp_path / "loras"
custom_recipes_dir = tmp_path / "custom_recipes"
old_recipes_dir = custom_recipes_dir / "nested"
old_recipes_dir.mkdir(parents=True)
manager.set("folder_paths", {"loras": [str(lora_root)]})
manager.settings["recipes_path"] = str(custom_recipes_dir)
recipe_id = "recipe-2"
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
old_image_path.write_bytes(b"image-bytes")
old_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": str(old_image_path),
"title": "Recipe 2",
}
),
encoding="utf-8",
)
manager.set("recipes_path", "")
fallback_recipes_dir = lora_root / "recipes"
migrated_image_path = fallback_recipes_dir / "nested" / f"{recipe_id}.webp"
migrated_json_path = fallback_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
assert manager.get("recipes_path") == ""
assert migrated_image_path.read_bytes() == b"image-bytes"
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
assert migrated_payload["file_path"] == str(migrated_image_path)
assert not old_image_path.exists()
assert not old_json_path.exists()
def test_moving_recipes_path_back_to_parent_directory_is_allowed(manager, tmp_path):
lora_root = tmp_path / "loras"
manager.set("folder_paths", {"loras": [str(lora_root)]})
source_recipes_dir = lora_root / "recipes" / "custom"
source_recipes_dir.mkdir(parents=True)
recipe_id = "recipe-parent"
old_image_path = source_recipes_dir / f"{recipe_id}.webp"
old_json_path = source_recipes_dir / f"{recipe_id}.recipe.json"
old_image_path.write_bytes(b"parent-bytes")
old_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": str(old_image_path),
"title": "Recipe Parent",
}
),
encoding="utf-8",
)
manager.settings["recipes_path"] = str(source_recipes_dir)
manager.set("recipes_path", str(lora_root / "recipes"))
migrated_image_path = lora_root / "recipes" / f"{recipe_id}.webp"
migrated_json_path = lora_root / "recipes" / f"{recipe_id}.recipe.json"
assert manager.get("recipes_path") == str((lora_root / "recipes").resolve())
assert migrated_image_path.read_bytes() == b"parent-bytes"
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
assert migrated_payload["file_path"] == str(migrated_image_path)
assert not old_image_path.exists()
assert not old_json_path.exists()
def test_set_recipes_path_rewrites_symlinked_recipe_metadata(manager, tmp_path):
real_recipes_dir = tmp_path / "real_recipes"
real_recipes_dir.mkdir()
symlink_recipes_dir = tmp_path / "linked_recipes"
symlink_recipes_dir.symlink_to(real_recipes_dir, target_is_directory=True)
manager.settings["recipes_path"] = str(symlink_recipes_dir)
manager.set("folder_paths", {"loras": [str(tmp_path / "loras")]})
recipe_id = "recipe-symlink"
old_image_path = real_recipes_dir / f"{recipe_id}.webp"
old_json_path = real_recipes_dir / f"{recipe_id}.recipe.json"
old_image_path.write_bytes(b"symlink-bytes")
old_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": str(old_image_path),
"title": "Recipe Symlink",
}
),
encoding="utf-8",
)
new_recipes_dir = tmp_path / "migrated_recipes"
manager.set("recipes_path", str(new_recipes_dir))
migrated_image_path = new_recipes_dir / f"{recipe_id}.webp"
migrated_json_path = new_recipes_dir / f"{recipe_id}.recipe.json"
assert migrated_image_path.read_bytes() == b"symlink-bytes"
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
assert migrated_payload["file_path"] == str(migrated_image_path)
assert not old_image_path.exists()
assert not old_json_path.exists()
def test_set_recipes_path_rejects_file_target(manager, tmp_path):
lora_root = tmp_path / "loras"
lora_root.mkdir()
manager.set("folder_paths", {"loras": [str(lora_root)]})
target_file = tmp_path / "not_a_directory"
target_file.write_text("blocked", encoding="utf-8")
with pytest.raises(ValueError, match="directory"):
manager.set("recipes_path", str(target_file))
assert manager.get("recipes_path") == ""
def test_extra_folder_paths_stored_separately(manager, tmp_path): def test_extra_folder_paths_stored_separately(manager, tmp_path):
lora_dir = tmp_path / "loras" lora_dir = tmp_path / "loras"
extra_dir = tmp_path / "extra_loras" extra_dir = tmp_path / "extra_loras"