From 07721af87cac425ebbef394749563c2e28ca2d05 Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Thu, 20 Nov 2025 16:08:32 +0800 Subject: [PATCH] feat(settings): add auto-organize exclusions --- locales/de.json | 10 ++ locales/en.json | 10 ++ locales/es.json | 10 ++ locales/fr.json | 10 ++ locales/he.json | 10 ++ locales/ja.json | 10 ++ locales/ko.json | 10 ++ locales/ru.json | 10 ++ locales/zh-CN.json | 10 ++ locales/zh-TW.json | 10 ++ py/routes/handlers/model_handlers.py | 9 +- py/services/model_file_service.py | 42 ++++++++- py/services/settings_manager.py | 53 +++++++++++ .../use_cases/auto_organize_use_case.py | 2 + settings.json.example | 3 +- static/js/api/baseModelApi.js | 23 +++-- static/js/managers/SettingsManager.js | 91 ++++++++++++++++++- static/js/state/index.js | 1 + .../components/modals/settings_modal.html | 23 +++++ tests/routes/test_base_model_routes_smoke.py | 2 +- tests/services/test_use_cases.py | 8 +- 21 files changed, 339 insertions(+), 18 deletions(-) diff --git a/locales/de.json b/locales/de.json index c357f2fd..92351d03 100644 --- a/locales/de.json +++ b/locales/de.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "Download-Pfad-Vorlagen", "exampleImages": "Beispielbilder", "updateFlags": "Update-Markierungen", + "autoOrganize": "Auto-organize", "misc": "Verschiedenes", "metadataArchive": "Metadaten-Archiv-Datenbank", "storageLocation": "Einstellungsort", @@ -251,6 +252,15 @@ "autoplayOnHover": "Videos bei Hover automatisch abspielen", "autoplayOnHoverHelp": "Video-Vorschauen nur beim Darüberfahren mit der Maus abspielen" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "Anzeige-Dichte", "displayDensityOptions": { diff --git a/locales/en.json b/locales/en.json index bd7e57c4..7da54814 100644 --- a/locales/en.json +++ b/locales/en.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "Download Path Templates", "exampleImages": "Example Images", "updateFlags": "Update Flags", + "autoOrganize": "Auto-organize", "misc": "Misc.", "metadataArchive": "Metadata Archive Database", "storageLocation": "Settings Location", @@ -251,6 +252,15 @@ "autoplayOnHover": "Autoplay Videos on Hover", "autoplayOnHoverHelp": "Only play video previews when hovering over them" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "Display Density", "displayDensityOptions": { diff --git a/locales/es.json b/locales/es.json index 96065094..8abea518 100644 --- a/locales/es.json +++ b/locales/es.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "Plantillas de rutas de descarga", "exampleImages": "Imágenes de ejemplo", "updateFlags": "Indicadores de actualización", + "autoOrganize": "Auto-organize", "misc": "Varios", "metadataArchive": "Base de datos de archivo de metadatos", "storageLocation": "Ubicación de ajustes", @@ -251,6 +252,15 @@ "autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón", "autoplayOnHoverHelp": "Solo reproducir vistas previas de video al pasar el ratón sobre ellas" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "Densidad de visualización", "displayDensityOptions": { diff --git a/locales/fr.json b/locales/fr.json index 755abf50..1aa700ac 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "Modèles de chemin de téléchargement", "exampleImages": "Images d'exemple", "updateFlags": "Indicateurs de mise à jour", + "autoOrganize": "Auto-organize", "misc": "Divers", "metadataArchive": "Base de données d'archive des métadonnées", "storageLocation": "Emplacement des paramètres", @@ -251,6 +252,15 @@ "autoplayOnHover": "Lecture automatique vidéo au survol", "autoplayOnHoverHelp": "Lire les aperçus vidéo uniquement lors du survol" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "Densité d'affichage", "displayDensityOptions": { diff --git a/locales/he.json b/locales/he.json index be758c28..8945d9ea 100644 --- a/locales/he.json +++ b/locales/he.json @@ -231,6 +231,7 @@ "downloadPathTemplates": "תבניות נתיב הורדה", "exampleImages": "תמונות דוגמה", "updateFlags": "תגי עדכון", + "autoOrganize": "Auto-organize", "misc": "שונות", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "storageLocation": "מיקום ההגדרות", @@ -251,6 +252,15 @@ "autoplayOnHover": "נגן וידאו אוטומטית בריחוף", "autoplayOnHoverHelp": "נגן תצוגות מקדימות של וידאו רק בעת ריחוף מעליהן" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "צפיפות תצוגה", "displayDensityOptions": { diff --git a/locales/ja.json b/locales/ja.json index b5c75cec..db880232 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "ダウンロードパステンプレート", "exampleImages": "例画像", "updateFlags": "アップデートフラグ", + "autoOrganize": "Auto-organize", "misc": "その他", "metadataArchive": "メタデータアーカイブデータベース", "storageLocation": "設定の場所", @@ -251,6 +252,15 @@ "autoplayOnHover": "ホバー時に動画を自動再生", "autoplayOnHoverHelp": "動画プレビューはホバー時にのみ再生されます" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "表示密度", "displayDensityOptions": { diff --git a/locales/ko.json b/locales/ko.json index 06eb575a..2d00250c 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "다운로드 경로 템플릿", "exampleImages": "예시 이미지", "updateFlags": "업데이트 표시", + "autoOrganize": "Auto-organize", "misc": "기타", "metadataArchive": "메타데이터 아카이브 데이터베이스", "storageLocation": "설정 위치", @@ -251,6 +252,15 @@ "autoplayOnHover": "호버 시 비디오 자동 재생", "autoplayOnHoverHelp": "마우스를 올렸을 때만 비디오 미리보기를 재생합니다" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "표시 밀도", "displayDensityOptions": { diff --git a/locales/ru.json b/locales/ru.json index 4469e996..4fa68fc4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "Шаблоны путей загрузки", "exampleImages": "Примеры изображений", "updateFlags": "Метки обновлений", + "autoOrganize": "Auto-organize", "misc": "Разное", "metadataArchive": "Архив метаданных", "storageLocation": "Расположение настроек", @@ -251,6 +252,15 @@ "autoplayOnHover": "Автовоспроизведение видео при наведении", "autoplayOnHoverHelp": "Воспроизводить превью видео только при наведении курсора" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "Плотность отображения", "displayDensityOptions": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index c358c95b..4ac873af 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "下载路径模板", "exampleImages": "示例图片", "updateFlags": "更新标记", + "autoOrganize": "Auto-organize", "misc": "其他", "metadataArchive": "元数据归档数据库", "storageLocation": "设置位置", @@ -251,6 +252,15 @@ "autoplayOnHover": "悬停时自动播放视频", "autoplayOnHoverHelp": "仅在悬停时播放视频预览" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "显示密度", "displayDensityOptions": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index ca188c59..6afad627 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -232,6 +232,7 @@ "downloadPathTemplates": "下載路徑範本", "exampleImages": "範例圖片", "updateFlags": "更新標記", + "autoOrganize": "Auto-organize", "misc": "其他", "metadataArchive": "中繼資料封存資料庫", "storageLocation": "設定位置", @@ -251,6 +252,15 @@ "autoplayOnHover": "滑鼠懸停自動播放影片", "autoplayOnHoverHelp": "僅在滑鼠懸停時播放影片預覽" }, + "autoOrganizeExclusions": { + "label": "Auto-organize exclusions", + "placeholder": "Example: extras/*, */backups/*; *_temp.safetensors", + "help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.", + "validation": { + "noPatterns": "Enter at least one pattern separated by commas or semicolons.", + "saveFailed": "Unable to save exclusions: {message}" + } + }, "layoutSettings": { "displayDensity": "顯示密度", "displayDensityOptions": { diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index c74dccca..b07bac01 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -16,7 +16,7 @@ from ...services.download_coordinator import DownloadCoordinator from ...services.metadata_sync_service import MetadataSyncService from ...services.model_file_service import ModelMoveService from ...services.preview_asset_service import PreviewAssetService -from ...services.settings_manager import SettingsManager +from ...services.settings_manager import SettingsManager, get_settings_manager from ...services.tag_update_service import TagUpdateService from ...services.use_cases import ( AutoOrganizeInProgressError, @@ -1051,16 +1051,23 @@ class ModelAutoOrganizeHandler: async def auto_organize_models(self, request: web.Request) -> web.Response: try: file_paths = None + exclusion_patterns = None + settings_manager = get_settings_manager() if request.method == "POST": try: data = await request.json() file_paths = data.get("file_paths") + if "exclusion_patterns" in data: + exclusion_patterns = settings_manager.normalize_auto_organize_exclusions( + data.get("exclusion_patterns") + ) except Exception: # pragma: no cover - permissive path pass result = await self._use_case.execute( file_paths=file_paths, progress_callback=self._progress_callback, + exclusion_patterns=exclusion_patterns, ) return web.json_response(result.to_dict()) except AutoOrganizeInProgressError: diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 4bc18f03..83aa5a01 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -1,7 +1,8 @@ import asyncio +import fnmatch import os import logging -from typing import List, Dict, Optional, Any, Set +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 @@ -79,9 +80,10 @@ class ModelFileService: return self.scanner.get_model_roots() async def auto_organize_models( - self, + self, file_paths: Optional[List[str]] = None, - progress_callback: Optional[ProgressCallback] = None + progress_callback: Optional[ProgressCallback] = None, + exclusion_patterns: Optional[Sequence[str]] = None, ) -> AutoOrganizeResult: """Auto-organize models based on current settings @@ -100,6 +102,13 @@ class ModelFileService: # Get all models from cache cache = await self.scanner.get_cached_data() all_models = cache.raw_data + + settings_manager = get_settings_manager() + normalized_exclusions = settings_manager.normalize_auto_organize_exclusions( + exclusion_patterns + if exclusion_patterns is not None + else settings_manager.get_auto_organize_exclusions() + ) # Filter models if specific file paths are provided if file_paths: @@ -107,7 +116,16 @@ class ModelFileService: result.operation_type = 'bulk' else: result.operation_type = 'all' - + + if normalized_exclusions: + all_models = [ + model + for model in all_models + if not self._should_exclude_model( + model.get('file_path'), normalized_exclusions + ) + ] + # Get model roots for this scanner model_roots = self.get_model_roots() if not model_roots: @@ -301,10 +319,24 @@ class ModelFileService: # Normalize paths for comparison normalized_root = os.path.normpath(root).replace(os.sep, '/') normalized_file = os.path.normpath(file_path).replace(os.sep, '/') - + if normalized_file.startswith(normalized_root): return root return None + + def _should_exclude_model( + self, file_path: Optional[str], patterns: Sequence[str] + ) -> bool: + if not file_path or not patterns: + return False + + normalized_path = os.path.normpath(file_path).replace(os.sep, '/') + filename = os.path.basename(normalized_path) + + for pattern in patterns: + if fnmatch.fnmatch(filename, pattern) or fnmatch.fnmatch(normalized_path, pattern): + return True + return False async def _calculate_target_directory( self, diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 03b628da..908013db 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -62,6 +62,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "model_name_display": "model_name", "model_card_footer_action": "example_images", "update_flag_strategy": "same_base", + "auto_organize_exclusions": [], } @@ -239,6 +240,17 @@ class SettingsManager: ) inserted_defaults = True + if "auto_organize_exclusions" in self.settings: + normalized_exclusions = self.normalize_auto_organize_exclusions( + self.settings.get("auto_organize_exclusions") + ) + if normalized_exclusions != self.settings.get("auto_organize_exclusions"): + self.settings["auto_organize_exclusions"] = normalized_exclusions + updated_existing = True + else: + self.settings["auto_organize_exclusions"] = [] + inserted_defaults = True + for key, value in defaults.items(): if key == "priority_tags": continue @@ -719,6 +731,7 @@ class SettingsManager: defaults['download_path_templates'] = {} defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults.setdefault('folder_paths', {}) + defaults['auto_organize_exclusions'] = [] library_name = defaults.get("active_library") or "default" default_library = self._build_library_payload( @@ -744,6 +757,35 @@ class SettingsManager: return normalized + def normalize_auto_organize_exclusions(self, value: Any) -> List[str]: + if value is None: + return [] + + if isinstance(value, str): + candidates: Iterable[str] = ( + value.replace("\n", ",").replace(";", ",").split(",") + ) + elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)): + candidates = value + else: + return [] + + patterns: List[str] = [] + for raw in candidates: + if isinstance(raw, str): + token = raw.strip() + if token: + patterns.append(token) + + unique_patterns: List[str] = [] + seen = set() + for pattern in patterns: + if pattern not in seen: + seen.add(pattern) + unique_patterns.append(pattern) + + return unique_patterns + def get_priority_tag_config(self) -> Dict[str, str]: stored_value = self.settings.get("priority_tags") normalized = self._normalize_priority_tag_config(stored_value) @@ -752,6 +794,15 @@ class SettingsManager: self._save_settings() return normalized.copy() + def get_auto_organize_exclusions(self) -> List[str]: + exclusions = self.normalize_auto_organize_exclusions( + self.settings.get("auto_organize_exclusions") + ) + if exclusions != self.settings.get("auto_organize_exclusions"): + self.settings["auto_organize_exclusions"] = exclusions + self._save_settings() + return exclusions + def get_startup_messages(self) -> List[Dict[str, Any]]: return [message.copy() for message in self._startup_messages] @@ -787,6 +838,8 @@ class SettingsManager: def set(self, key: str, value: Any) -> None: """Set setting value and save""" + if key == "auto_organize_exclusions": + value = self.normalize_auto_organize_exclusions(value) self.settings[key] = value portable_switch_pending = False if key == "use_portable_settings" and isinstance(value, bool): diff --git a/py/services/use_cases/auto_organize_use_case.py b/py/services/use_cases/auto_organize_use_case.py index 0914739f..06306bf0 100644 --- a/py/services/use_cases/auto_organize_use_case.py +++ b/py/services/use_cases/auto_organize_use_case.py @@ -39,6 +39,7 @@ class AutoOrganizeUseCase: *, file_paths: Optional[Sequence[str]] = None, progress_callback: Optional[ProgressCallback] = None, + exclusion_patterns: Optional[Sequence[str]] = None, ) -> AutoOrganizeResult: """Run the auto-organize routine guarded by a shared lock.""" @@ -53,4 +54,5 @@ class AutoOrganizeUseCase: return await self._file_service.auto_organize_models( file_paths=list(file_paths) if file_paths is not None else None, progress_callback=progress_callback, + exclusion_patterns=exclusion_patterns, ) diff --git a/settings.json.example b/settings.json.example index f2479209..47afdde1 100644 --- a/settings.json.example +++ b/settings.json.example @@ -14,5 +14,6 @@ "C:/path/to/your/embeddings_folder", "C:/path/to/another/embeddings_folder" ] - } + }, + "auto_organize_exclusions": [] } diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index e659dda8..ec0b6289 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1252,15 +1252,24 @@ export class BaseModelApiClient { // Start the auto-organize operation const endpoint = this.apiConfig.endpoints.autoOrganize; - const requestOptions = { - method: filePaths ? 'POST' : 'GET', - headers: filePaths ? { 'Content-Type': 'application/json' } : {} - }; - + const exclusionPatterns = (state.global.settings.auto_organize_exclusions || []) + .filter(pattern => typeof pattern === 'string' && pattern.trim()) + .map(pattern => pattern.trim()); + + const requestBody = {}; if (filePaths) { - requestOptions.body = JSON.stringify({ file_paths: filePaths }); + requestBody.file_paths = filePaths; } - + if (exclusionPatterns.length > 0) { + requestBody.exclusion_patterns = exclusionPatterns; + } + + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }; + const response = await fetch(endpoint, requestOptions); if (!response.ok) { diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 7a6b02ca..e954b712 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -131,11 +131,36 @@ export class SettingsManager { } merged.priority_tags = normalizedPriority; + merged.auto_organize_exclusions = this.normalizePatternList( + backendSettings?.auto_organize_exclusions ?? defaults.auto_organize_exclusions + ); + Object.keys(merged).forEach(key => this.backendSettingKeys.add(key)); return merged; } + normalizePatternList(value) { + if (Array.isArray(value)) { + const sanitized = value + .map(item => typeof item === 'string' ? item.trim() : '') + .filter(Boolean); + return [...new Set(sanitized)]; + } + + if (typeof value === 'string') { + const sanitized = value + .replace(/\n/g, ',') + .replace(/;/g, ',') + .split(',') + .map(part => part.trim()) + .filter(Boolean); + return [...new Set(sanitized)]; + } + + return []; + } + registerStartupMessages(messages = []) { if (!Array.isArray(messages) || messages.length === 0) { return; @@ -376,6 +401,16 @@ export class SettingsManager { usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; } + const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions'); + if (autoOrganizeExclusionsInput) { + const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions); + autoOrganizeExclusionsInput.value = patterns.join(', '); + } + const autoOrganizeExclusionsError = document.getElementById('autoOrganizeExclusionsError'); + if (autoOrganizeExclusionsError) { + autoOrganizeExclusionsError.textContent = ''; + } + // Set video autoplay on hover setting const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover'); if (autoplayOnHoverCheckbox) { @@ -1592,11 +1627,63 @@ export class SettingsManager { } } } - + + async saveAutoOrganizeExclusions() { + const input = document.getElementById('autoOrganizeExclusions'); + const errorElement = document.getElementById('autoOrganizeExclusionsError'); + if (!input) return; + + const normalized = this.normalizePatternList(input.value); + + if (input.value.trim() && normalized.length === 0) { + if (errorElement) { + errorElement.textContent = translate( + 'settings.autoOrganizeExclusions.validation.noPatterns', + {}, + 'Enter at least one pattern separated by commas or semicolons.' + ); + } + return; + } + + const current = this.normalizePatternList(state.global.settings.auto_organize_exclusions); + if (normalized.join('|') === current.join('|')) { + if (errorElement) { + errorElement.textContent = ''; + } + return; + } + + try { + if (errorElement) { + errorElement.textContent = ''; + } + + await this.saveSetting('auto_organize_exclusions', normalized); + input.value = normalized.join(', '); + + showToast( + 'toast.settings.settingsUpdated', + { setting: translate('settings.autoOrganizeExclusions.label') }, + 'success' + ); + } catch (error) { + console.error('Failed to save auto-organize exclusions:', error); + if (errorElement) { + errorElement.textContent = translate( + 'settings.autoOrganizeExclusions.validation.saveFailed', + { message: error.message }, + `Unable to save exclusions: ${error.message}` + ); + } + showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); + } + } + async saveInputSetting(elementId, settingKey) { const element = document.getElementById(elementId); if (!element) return; - + const value = element.value.trim(); // Trim whitespace try { diff --git a/static/js/state/index.js b/static/js/state/index.js index d565e674..4ee59b55 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -34,6 +34,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ compact_mode: false, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, update_flag_strategy: 'same_base', + auto_organize_exclusions: [], }); export function createDefaultSettings() { diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 3efcea9a..9b1f1874 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -341,6 +341,29 @@ +
+

{{ t('settings.sections.autoOrganize') }}

+
+
+
+ +
+
+ +
+
+
+ {{ t('settings.autoOrganizeExclusions.help') }} +
+
+
+
+

{{ t('settings.downloadPathTemplates.title') }}

diff --git a/tests/routes/test_base_model_routes_smoke.py b/tests/routes/test_base_model_routes_smoke.py index 55776dcd..835b4c46 100644 --- a/tests/routes/test_base_model_routes_smoke.py +++ b/tests/routes/test_base_model_routes_smoke.py @@ -540,7 +540,7 @@ def test_auto_organize_progress_returns_latest_snapshot(mock_service): def test_auto_organize_route_emits_progress(mock_service, monkeypatch: pytest.MonkeyPatch): - async def fake_auto_organize(self, file_paths=None, progress_callback=None): + async def fake_auto_organize(self, file_paths=None, progress_callback=None, exclusion_patterns=None): result = AutoOrganizeResult() result.total = 1 result.processed = 1 diff --git a/tests/services/test_use_cases.py b/tests/services/test_use_cases.py index 8abea1c5..2c92fadc 100644 --- a/tests/services/test_use_cases.py +++ b/tests/services/test_use_cases.py @@ -53,10 +53,15 @@ class StubFileService: *, file_paths: Optional[List[str]] = None, progress_callback=None, + exclusion_patterns=None, ) -> AutoOrganizeResult: result = AutoOrganizeResult() result.total = len(file_paths or []) - self.calls.append({"file_paths": file_paths, "progress_callback": progress_callback}) + self.calls.append({ + "file_paths": file_paths, + "progress_callback": progress_callback, + "exclusion_patterns": exclusion_patterns, + }) return result @@ -144,6 +149,7 @@ async def test_auto_organize_use_case_executes_with_lock() -> None: assert isinstance(result, AutoOrganizeResult) assert file_service.calls[0]["file_paths"] == ["model1"] + assert file_service.calls[0]["exclusion_patterns"] is None async def test_auto_organize_use_case_rejects_when_running() -> None: