From 5d5a2a998a850d7008a653aff2c0263fb4b7848c Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 28 Dec 2025 21:18:27 +0800 Subject: [PATCH] feat: Implement model move, import, and download functionalities with corresponding UI and API updates. --- py/routes/base_model_routes.py | 4 +- py/routes/handlers/model_handlers.py | 6 +- py/services/model_file_service.py | 34 ++- .../css/components/modal/download-modal.css | 8 +- static/js/api/baseModelApi.js | 250 +++++++++--------- static/js/managers/DownloadManager.js | 128 ++++----- static/js/managers/ImportManager.js | 94 +++---- static/js/managers/MoveManager.js | 125 +++++++-- templates/components/modals/move_modal.html | 79 +++--- 9 files changed, 420 insertions(+), 308 deletions(-) diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index b6aba1e4..fc1ac42e 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -120,7 +120,7 @@ class BaseModelRoutes(ABC): self.service = service self.model_type = service.model_type self.model_file_service = ModelFileService(service.scanner, service.model_type) - self.model_move_service = ModelMoveService(service.scanner) + self.model_move_service = ModelMoveService(service.scanner, service.model_type) self.model_lifecycle_service = ModelLifecycleService( scanner=service.scanner, metadata_manager=MetadataManager, @@ -270,7 +270,7 @@ class BaseModelRoutes(ABC): def _ensure_move_service(self) -> ModelMoveService: if self.model_move_service is None: service = self._ensure_service() - self.model_move_service = ModelMoveService(service.scanner) + self.model_move_service = ModelMoveService(service.scanner, service.model_type) return self.model_move_service def _ensure_lifecycle_service(self) -> ModelLifecycleService: diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 9d8100c8..cbd81625 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -1052,9 +1052,10 @@ class ModelMoveHandler: data = await request.json() file_path = data.get("file_path") target_path = data.get("target_path") + use_default_paths = data.get("use_default_paths", False) if not file_path or not target_path: return web.Response(text="File path and target path are required", status=400) - result = await self._move_service.move_model(file_path, target_path) + result = await self._move_service.move_model(file_path, target_path, use_default_paths=use_default_paths) status = 200 if result.get("success") else 500 return web.json_response(result, status=status) except Exception as exc: @@ -1066,9 +1067,10 @@ class ModelMoveHandler: data = await request.json() file_paths = data.get("file_paths", []) target_path = data.get("target_path") + use_default_paths = data.get("use_default_paths", False) if not file_paths or not target_path: return web.Response(text="File paths and target path are required", status=400) - result = await self._move_service.move_models_bulk(file_paths, target_path) + result = await self._move_service.move_models_bulk(file_paths, target_path, use_default_paths=use_default_paths) return web.json_response(result) except Exception as exc: self._logger.error("Error moving models in bulk: %s", exc, exc_info=True) diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 2e6a11d9..0e10c02f 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -446,25 +446,46 @@ class ModelFileService: class ModelMoveService: """Service for handling individual model moves""" - def __init__(self, scanner): + def __init__(self, scanner, model_type: str): """Initialize the service Args: scanner: Model scanner instance + model_type: Type of model (e.g., 'lora', 'checkpoint') """ self.scanner = scanner + self.model_type = model_type - async def move_model(self, file_path: str, target_path: str) -> Dict[str, Any]: + async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]: """Move a single model file Args: file_path: Source file path - target_path: Target directory path + target_path: Target directory path (used as root if use_default_paths is True) + use_default_paths: Whether to use default path template for organization Returns: Dictionary with move result """ try: + if use_default_paths: + # Find the model in cache to get metadata + cache = await self.scanner.get_cached_data() + model_data = next((m for m in cache.raw_data if m.get('file_path') == file_path), None) + + if model_data: + from ..utils.utils import calculate_relative_path_for_model + relative_path = calculate_relative_path_for_model(model_data, self.model_type) + if relative_path: + target_path = os.path.join(target_path, relative_path).replace(os.sep, '/') + elif not get_settings_manager().get_download_path_template(self.model_type): + # Flat structure, target_path remains the root + pass + else: + # Could not calculate relative path (e.g. missing metadata) + # Fallback to manual target_path or skip? + pass + source_dir = os.path.dirname(file_path) if os.path.normpath(source_dir) == os.path.normpath(target_path): logger.info(f"Source and target directories are the same: {source_dir}") @@ -498,12 +519,13 @@ class ModelMoveService: 'new_file_path': None } - async def move_models_bulk(self, file_paths: List[str], target_path: str) -> Dict[str, Any]: + async def move_models_bulk(self, file_paths: List[str], target_path: str, use_default_paths: bool = False) -> Dict[str, Any]: """Move multiple model files Args: file_paths: List of source file paths - target_path: Target directory path + target_path: Target directory path (used as root if use_default_paths is True) + use_default_paths: Whether to use default path template for organization Returns: Dictionary with bulk move results @@ -512,7 +534,7 @@ class ModelMoveService: results = [] for file_path in file_paths: - result = await self.move_model(file_path, target_path) + result = await self.move_model(file_path, target_path, use_default_paths=use_default_paths) results.append({ "original_file_path": file_path, "new_file_path": result.get('new_file_path'), diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index 09d0143f..d146b87b 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -328,11 +328,11 @@ display: block; } -.tree-node.has-children > .tree-node-content .tree-expand-icon { +.tree-node.has-children>.tree-node-content .tree-expand-icon { opacity: 1; } -.tree-node:not(.has-children) > .tree-node-content .tree-expand-icon { +.tree-node:not(.has-children)>.tree-node-content .tree-expand-icon { opacity: 0; pointer-events: none; } @@ -470,11 +470,11 @@ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } -.inline-toggle-container .toggle-switch input:checked + .toggle-slider { +.inline-toggle-container .toggle-switch input:checked+.toggle-slider { background-color: var(--lora-accent); } -.inline-toggle-container .toggle-switch input:checked + .toggle-slider:before { +.inline-toggle-container .toggle-switch input:checked+.toggle-slider:before { transform: translateX(18px); } diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index e651b4dc..2564596e 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -2,9 +2,9 @@ import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; import { translate } from '../utils/i18nHelpers.js'; import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; -import { - getCompleteApiConfig, - getCurrentModelType, +import { + getCompleteApiConfig, + getCurrentModelType, isValidModelType, DOWNLOAD_ENDPOINTS, WS_ENDPOINTS @@ -51,7 +51,7 @@ export class BaseModelApiClient { async fetchModelsPage(page = 1, pageSize = null) { const pageState = this.getPageState(); const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; - + try { const params = this._buildQueryParams({ page, @@ -63,9 +63,9 @@ export class BaseModelApiClient { if (!response.ok) { throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); } - + const data = await response.json(); - + return { items: data.items, totalItems: data.total, @@ -74,7 +74,7 @@ export class BaseModelApiClient { hasMore: page < data.total_pages, folders: data.folders }; - + } catch (error) { console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error); showToast('toast.api.fetchFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error'); @@ -84,7 +84,7 @@ export class BaseModelApiClient { async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) { const pageState = this.getPageState(); - + try { state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`); @@ -92,22 +92,22 @@ export class BaseModelApiClient { if (resetPage) { pageState.currentPage = 1; // Reset to first page } - + const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize); - + state.virtualScroller.refreshWithData( result.items, result.totalItems, result.hasMore ); - + pageState.hasMore = result.hasMore; pageState.currentPage = pageState.currentPage + 1; - + if (updateFolders) { sidebarManager.refresh(); } - + return result; } catch (error) { console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error); @@ -128,13 +128,13 @@ export class BaseModelApiClient { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: filePath }) }); - + if (!response.ok) { throw new Error(`Failed to delete ${this.apiConfig.config.singularName}: ${response.statusText}`); } - + const data = await response.json(); - + if (data.success) { if (state.virtualScroller) { state.virtualScroller.removeItemByFilePath(filePath); @@ -162,13 +162,13 @@ export class BaseModelApiClient { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: filePath }) }); - + if (!response.ok) { throw new Error(`Failed to exclude ${this.apiConfig.config.singularName}: ${response.statusText}`); } - + const data = await response.json(); - + if (data.success) { if (state.virtualScroller) { state.virtualScroller.removeItemByFilePath(filePath); @@ -190,7 +190,7 @@ export class BaseModelApiClient { async renameModelFile(filePath, newFileName) { try { state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); - + const response = await fetch(this.apiConfig.endpoints.rename, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -203,12 +203,12 @@ export class BaseModelApiClient { const result = await response.json(); if (result.success) { - state.virtualScroller.updateSingleItem(filePath, { - file_name: newFileName, + state.virtualScroller.updateSingleItem(filePath, { + file_name: newFileName, file_path: result.new_file_path, preview_url: result.new_preview_path }); - + showToast('toast.api.fileNameUpdated', {}, 'success'); } else { showToast('toast.api.fileRenameFailed', { error: result.error || 'Unknown error' }, 'error'); @@ -227,21 +227,21 @@ export class BaseModelApiClient { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*,video/mp4'; - + input.onchange = async () => { if (!input.files || !input.files[0]) return; - + const file = input.files[0]; await this.uploadPreview(filePath, file); }; - + input.click(); } async uploadPreview(filePath, file, nsfwLevel = 0) { try { state.loadingManager.showSimpleLoading('Uploading preview...'); - + const formData = new FormData(); formData.append('preview_file', file); formData.append('model_path', filePath); @@ -251,18 +251,18 @@ export class BaseModelApiClient { method: 'POST', body: formData }); - + if (!response.ok) { throw new Error('Upload failed'); } const data = await response.json(); const pageState = this.getPageState(); - + const timestamp = Date.now(); if (pageState.previewVersions) { pageState.previewVersions.set(filePath, timestamp); - + const storageKey = `${this.modelType}_preview_versions`; saveMapToStorage(storageKey, pageState.previewVersions); } @@ -285,7 +285,7 @@ export class BaseModelApiClient { async saveModelMetadata(filePath, data) { try { state.loadingManager.showSimpleLoading('Saving metadata...'); - + const response = await fetch(this.apiConfig.endpoints.save, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -339,18 +339,18 @@ export class BaseModelApiClient { state.loadingManager.showSimpleLoading( `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...` ); - + const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); url.searchParams.append('full_rebuild', fullRebuild); - + const response = await fetch(url); - + if (!response.ok) { throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); } resetAndReload(true); - + showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success'); } catch (error) { console.error('Refresh failed:', error); @@ -364,7 +364,7 @@ export class BaseModelApiClient { async refreshSingleModelMetadata(filePath) { try { state.loadingManager.showSimpleLoading('Refreshing metadata...'); - + const response = await fetch(this.apiConfig.endpoints.fetchCivitai, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -376,7 +376,7 @@ export class BaseModelApiClient { } const data = await response.json(); - + if (data.success) { if (data.metadata && state.virtualScroller) { state.virtualScroller.updateSingleItem(filePath, data.metadata); @@ -399,21 +399,21 @@ export class BaseModelApiClient { async fetchCivitaiMetadata() { let ws = null; - + await state.loadingManager.showWithProgress(async (loading) => { try { const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); - + const operationComplete = new Promise((resolve, reject) => { ws.onmessage = (event) => { const data = JSON.parse(event.data); - - switch(data.status) { + + switch (data.status) { case 'started': loading.setStatus('Starting metadata fetch...'); break; - + case 'processing': const percent = ((data.processed / data.total) * 100).toFixed(1); loading.setProgress(percent); @@ -421,7 +421,7 @@ export class BaseModelApiClient { `Processing (${data.processed}/${data.total}) ${data.current_name}` ); break; - + case 'completed': loading.setProgress(100); loading.setStatus( @@ -429,34 +429,34 @@ export class BaseModelApiClient { ); resolve(); break; - + case 'error': reject(new Error(data.error)); break; } }; - + ws.onerror = (error) => { reject(new Error('WebSocket error: ' + error.message)); }; }); - + // Wait for WebSocket connection to establish await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); - + const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - + if (!response.ok) { throw new Error('Failed to fetch metadata'); } - + // Wait for the operation to complete via WebSocket await operationComplete; @@ -492,15 +492,15 @@ export class BaseModelApiClient { for (let i = 0; i < filePaths.length; i++) { const filePath = filePaths[i]; const fileName = filePath.split('/').pop(); - + try { const overallProgress = Math.floor((i / totalItems) * 100); progressController.updateProgress( - overallProgress, - fileName, + overallProgress, + fileName, `Processing ${i + 1}/${totalItems}: ${fileName}` ); - + const response = await fetch(this.apiConfig.endpoints.fetchCivitai, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -512,7 +512,7 @@ export class BaseModelApiClient { } const data = await response.json(); - + if (data.success) { if (data.metadata && state.virtualScroller) { state.virtualScroller.updateSingleItem(filePath, data.metadata); @@ -521,12 +521,12 @@ export class BaseModelApiClient { } else { throw new Error(data.error || 'Failed to refresh metadata'); } - + } catch (error) { console.error(`Error refreshing metadata for ${fileName}:`, error); failedItems.push({ filePath, fileName, error: error.message }); } - + processedCount++; } @@ -537,7 +537,7 @@ export class BaseModelApiClient { } else if (successCount > 0) { completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`); showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning'); - + // if (failedItems.length > 0) { // const failureMessage = failedItems.length <= 3 // ? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n') @@ -770,7 +770,7 @@ export class BaseModelApiClient { _buildQueryParams(baseParams, pageState) { const params = new URLSearchParams(baseParams); - + if (pageState.activeFolder !== null) { params.append('folder', pageState.activeFolder); } @@ -790,7 +790,7 @@ export class BaseModelApiClient { if (pageState.filters?.search) { params.append('search', pageState.filters.search); params.append('fuzzy', 'true'); - + if (pageState.searchOptions) { params.append('search_filename', pageState.searchOptions.filename.toString()); params.append('search_modelname', pageState.searchOptions.modelname.toString()); @@ -804,7 +804,7 @@ export class BaseModelApiClient { } params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); - + if (pageState.filters) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { Object.entries(pageState.filters.tags).forEach(([tag, state]) => { @@ -815,17 +815,17 @@ export class BaseModelApiClient { } }); } - + if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { pageState.filters.baseModel.forEach(model => { params.append('base_model', model); }); } - + // Add license filters if (pageState.filters.license) { const licenseFilters = pageState.filters.license; - + if (licenseFilters.noCredit) { // For noCredit filter: // - 'include' means credit_required=False (no credit required) @@ -836,7 +836,7 @@ export class BaseModelApiClient { params.append('credit_required', 'true'); } } - + if (licenseFilters.allowSelling) { // For allowSelling filter: // - 'include' means allow_selling_generated_content=True @@ -848,7 +848,7 @@ export class BaseModelApiClient { } } } - + if (pageState.filters.modelTypes && pageState.filters.modelTypes.length > 0) { pageState.filters.modelTypes.forEach((type) => { params.append('model_type', type); @@ -895,13 +895,13 @@ export class BaseModelApiClient { } } - async moveSingleModel(filePath, targetPath) { + async moveSingleModel(filePath, targetPath, useDefaultPaths = false) { // Only allow move if supported if (!this.apiConfig.config.supportsMove) { showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); return null; } - if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { + if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath && !useDefaultPaths) { showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info'); return null; } @@ -913,7 +913,8 @@ export class BaseModelApiClient { }, body: JSON.stringify({ file_path: filePath, - target_path: targetPath + target_path: targetPath, + use_default_paths: useDefaultPaths }) }); @@ -941,12 +942,12 @@ export class BaseModelApiClient { return null; } - async moveBulkModels(filePaths, targetPath) { + async moveBulkModels(filePaths, targetPath, useDefaultPaths = false) { if (!this.apiConfig.config.supportsMove) { showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); return []; } - const movedPaths = filePaths.filter(path => { + const movedPaths = useDefaultPaths ? filePaths : filePaths.filter(path => { return path.substring(0, path.lastIndexOf('/')) !== targetPath; }); @@ -962,7 +963,8 @@ export class BaseModelApiClient { }, body: JSON.stringify({ file_paths: movedPaths, - target_path: targetPath + target_path: targetPath, + use_default_paths: useDefaultPaths }) }); @@ -974,10 +976,10 @@ export class BaseModelApiClient { if (result.success) { if (result.failure_count > 0) { - showToast('toast.api.bulkMovePartial', { - successCount: result.success_count, - type: this.apiConfig.config.displayName, - failureCount: result.failure_count + showToast('toast.api.bulkMovePartial', { + successCount: result.success_count, + type: this.apiConfig.config.displayName, + failureCount: result.failure_count }, 'warning'); console.log('Move operation results:', result.results); const failedFiles = result.results @@ -987,18 +989,18 @@ export class BaseModelApiClient { return `${fileName}: ${r.message}`; }); if (failedFiles.length > 0) { - const failureMessage = failedFiles.length <= 3 + const failureMessage = failedFiles.length <= 3 ? failedFiles.join('\n') : failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`; showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000); } } else { - showToast('toast.api.bulkMoveSuccess', { - successCount: result.success_count, - type: this.apiConfig.config.displayName + showToast('toast.api.bulkMoveSuccess', { + successCount: result.success_count, + type: this.apiConfig.config.displayName }, 'success'); } - + // Return the results array with original_file_path and new_file_path return result.results || []; } else { @@ -1013,7 +1015,7 @@ export class BaseModelApiClient { try { state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`); - + const response = await fetch(this.apiConfig.endpoints.bulkDelete, { method: 'POST', headers: { @@ -1023,13 +1025,13 @@ export class BaseModelApiClient { file_paths: filePaths }) }); - + if (!response.ok) { throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`); } - + const result = await response.json(); - + if (result.success) { return { success: true, @@ -1050,20 +1052,20 @@ export class BaseModelApiClient { async downloadExampleImages(modelHashes, modelTypes = null) { let ws = null; - + await state.loadingManager.showWithProgress(async (loading) => { try { // Connect to WebSocket for progress updates const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); - + const operationComplete = new Promise((resolve, reject) => { ws.onmessage = (event) => { const data = JSON.parse(event.data); - + if (data.type !== 'example_images_progress') return; - - switch(data.status) { + + switch (data.status) { case 'running': const percent = ((data.processed / data.total) * 100).toFixed(1); loading.setProgress(percent); @@ -1071,7 +1073,7 @@ export class BaseModelApiClient { `Processing (${data.processed}/${data.total}) ${data.current_model || ''}` ); break; - + case 'completed': loading.setProgress(100); loading.setStatus( @@ -1079,33 +1081,33 @@ export class BaseModelApiClient { ); resolve(); break; - + case 'error': reject(new Error(data.error)); break; } }; - + ws.onerror = (error) => { reject(new Error('WebSocket error: ' + error.message)); }; }); - + // Wait for WebSocket connection to establish await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); - + // Get the output directory from state const outputDir = state.global?.settings?.example_images_path || ''; if (!outputDir) { throw new Error('Please set the example images path in the settings first.'); } - + // Determine optimize setting const optimize = state.global?.settings?.optimize_example_images ?? true; - + // Make the API request to start the download process const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, { method: 'POST', @@ -1119,18 +1121,18 @@ export class BaseModelApiClient { model_types: modelTypes || [this.apiConfig.config.singularName] }) }); - + if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || 'Failed to download example images'); } - + // Wait for the operation to complete via WebSocket await operationComplete; - + showToast('toast.api.exampleImagesDownloadSuccess', {}, 'success'); return true; - + } catch (error) { console.error('Error downloading example images:', error); showToast('toast.api.exampleImagesDownloadFailed', { message: error.message }, 'error'); @@ -1150,13 +1152,13 @@ export class BaseModelApiClient { try { const params = new URLSearchParams({ file_path: filePath }); const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`); - + if (!response.ok) { throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} metadata: ${response.statusText}`); } - + const data = await response.json(); - + if (data.success) { return data.metadata; } else { @@ -1172,13 +1174,13 @@ export class BaseModelApiClient { try { const params = new URLSearchParams({ file_path: filePath }); const response = await fetch(`${this.apiConfig.endpoints.modelDescription}?${params}`); - + if (!response.ok) { throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} description: ${response.statusText}`); } - + const data = await response.json(); - + if (data.success) { return data.description; } else { @@ -1197,26 +1199,26 @@ export class BaseModelApiClient { */ async autoOrganizeModels(filePaths = null) { let ws = null; - + await state.loadingManager.showWithProgress(async (loading) => { try { // Connect to WebSocket for progress updates const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); - + const operationComplete = new Promise((resolve, reject) => { ws.onmessage = (event) => { const data = JSON.parse(event.data); - + if (data.type !== 'auto_organize_progress') return; - - switch(data.status) { + + switch (data.status) { case 'started': loading.setProgress(0); const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models'; loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.starting', { type: operationType }, `Starting auto-organize for ${operationType}...`)); break; - + case 'processing': const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0; loading.setProgress(percent); @@ -1230,12 +1232,12 @@ export class BaseModelApiClient { }, `Processing (${data.processed}/${data.total}) - ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`) ); break; - + case 'cleaning': loading.setProgress(95); loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.cleaning', {}, 'Cleaning up empty directories...')); break; - + case 'completed': loading.setProgress(100); loading.setStatus( @@ -1246,25 +1248,25 @@ export class BaseModelApiClient { total: data.total }, `Completed: ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`) ); - + setTimeout(() => { resolve(data); }, 1500); break; - + case 'error': loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`)); reject(new Error(data.error)); break; } }; - + ws.onerror = (error) => { console.error('WebSocket error during auto-organize:', error); reject(new Error('Connection error')); }; }); - + // Start the auto-organize operation const endpoint = this.apiConfig.endpoints.autoOrganize; const exclusionPatterns = (state.global.settings.auto_organize_exclusions || []) @@ -1286,29 +1288,29 @@ export class BaseModelApiClient { }; const response = await fetch(endpoint, requestOptions); - + if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || 'Failed to start auto-organize operation'); } - + // Wait for the operation to complete via WebSocket const result = await operationComplete; - + // Show appropriate success message based on results if (result.failures === 0) { - showToast('toast.loras.autoOrganizeSuccess', { + showToast('toast.loras.autoOrganizeSuccess', { count: result.success, type: result.operation_type === 'bulk' ? 'selected models' : 'all models' }, 'success'); } else { - showToast('toast.loras.autoOrganizePartialSuccess', { + showToast('toast.loras.autoOrganizePartialSuccess', { success: result.success, failures: result.failures, total: result.total }, 'warning'); } - + } catch (error) { console.error('Error during auto-organize:', error); showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error'); diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 1566035a..3cb3afc4 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -15,17 +15,17 @@ export class DownloadManager { this.modelVersionId = null; this.modelId = null; this.source = null; - + this.initialized = false; this.selectedFolder = ''; this.apiClient = null; this.useDefaultPath = false; - + this.loadingManager = new LoadingManager(); this.folderTreeManager = new FolderTreeManager(); this.folderClickHandler = null; this.updateTargetPath = this.updateTargetPath.bind(this); - + // Bound methods for event handling this.handleValidateAndFetchVersions = this.validateAndFetchVersions.bind(this); this.handleProceedToLocation = this.proceedToLocation.bind(this); @@ -38,11 +38,11 @@ export class DownloadManager { showDownloadModal() { console.log('Showing unified download modal...'); - + // Get API client for current page type this.apiClient = getModelApiClient(); const config = this.apiClient.apiConfig.config; - + if (!this.initialized) { const modal = document.getElementById('downloadModal'); if (!modal) { @@ -52,15 +52,15 @@ export class DownloadManager { this.initializeEventHandlers(); this.initialized = true; } - + // Update modal title and labels based on model type this.updateModalLabels(); - + modalManager.showModal('downloadModal', null, () => { this.cleanupFolderBrowser(); }); this.resetSteps(); - + // Auto-focus on the URL input setTimeout(() => { const urlInput = document.getElementById('modelUrl'); @@ -78,23 +78,23 @@ export class DownloadManager { document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl); document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); - + // Default path toggle handler document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath); } updateModalLabels() { const config = this.apiClient.apiConfig.config; - + // Update modal title document.getElementById('downloadModalTitle').textContent = translate('modals.download.titleWithType', { type: config.displayName }); - + // Update URL label document.getElementById('modelUrlLabel').textContent = translate('modals.download.civitaiUrl'); - + // Update root selection label document.getElementById('modelRootLabel').textContent = translate('modals.download.selectTypeRoot', { type: config.displayName }); - + // Update path preview labels const pathLabels = document.querySelectorAll('.path-preview label'); pathLabels.forEach(label => { @@ -102,7 +102,7 @@ export class DownloadManager { label.textContent = translate('modals.download.locationPreview') + ':'; } }); - + // Update initial path text const pathText = document.querySelector('#targetPathDisplay .path-text'); if (pathText) { @@ -115,27 +115,27 @@ export class DownloadManager { document.getElementById('urlStep').style.display = 'block'; document.getElementById('modelUrl').value = ''; document.getElementById('urlError').textContent = ''; - + // Clear folder path input const folderPathInput = document.getElementById('folderPath'); if (folderPathInput) { folderPathInput.value = ''; } - + this.currentVersion = null; this.versions = []; this.modelInfo = null; this.modelId = null; this.modelVersionId = null; this.source = null; - + this.selectedFolder = ''; - + // Clear folder tree selection if (this.folderTreeManager) { this.folderTreeManager.clearSelection(); } - + // Reset default path toggle this.loadDefaultPathSetting(); } @@ -151,10 +151,10 @@ export class DownloadManager { async validateAndFetchVersions() { const url = document.getElementById('modelUrl').value.trim(); const errorElement = document.getElementById('urlError'); - + try { this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); - + this.modelId = this.extractModelId(url); if (!this.modelId) { throw new Error(translate('modals.download.errors.invalidUrl')); @@ -166,7 +166,7 @@ export class DownloadManager { if (this.modelVersionId) { this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); } - + this.showVersionStep(); } catch (error) { errorElement.textContent = error.message; @@ -239,20 +239,20 @@ export class DownloadManager { showVersionStep() { document.getElementById('urlStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'block'; - + const versionList = document.getElementById('versionList'); versionList.innerHTML = this.versions.map(version => { const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; - - const fileSize = version.modelSizeKB ? - (version.modelSizeKB / 1024).toFixed(2) : + + const fileSize = version.modelSizeKB ? + (version.modelSizeKB / 1024).toFixed(2) : (version.files[0]?.sizeKB / 1024).toFixed(2); - + const existsLocally = version.existsLocally; const localPath = version.localPath; const isEarlyAccess = version.availability === 'EarlyAccess'; - + let earlyAccessBadge = ''; if (isEarlyAccess) { earlyAccessBadge = ` @@ -261,8 +261,8 @@ export class DownloadManager { `; } - - const localStatus = existsLocally ? + + const localStatus = existsLocally ? `
${translate('modals.download.inLibrary')}
${localPath || ''}
@@ -293,7 +293,7 @@ export class DownloadManager {
`; }).join(''); - + // Add click handlers for version selection versionList.addEventListener('click', (event) => { const versionItem = event.target.closest('.version-item'); @@ -301,12 +301,12 @@ export class DownloadManager { this.selectVersion(versionItem.dataset.versionId); } }); - + // Auto-select the version if there's only one if (this.versions.length === 1 && !this.currentVersion) { this.selectVersion(this.versions[0].id.toString()); } - + this.updateNextButtonState(); } @@ -317,16 +317,16 @@ export class DownloadManager { document.querySelectorAll('.version-item').forEach(item => { item.classList.toggle('selected', item.dataset.versionId === versionId); }); - + this.updateNextButtonState(); } - + updateNextButtonState() { const nextButton = document.getElementById('nextFromVersion'); if (!nextButton) return; - + const existsLocally = this.currentVersion?.existsLocally; - + if (existsLocally) { nextButton.disabled = true; nextButton.classList.add('disabled'); @@ -343,7 +343,7 @@ export class DownloadManager { showToast('toast.loras.pleaseSelectVersion', {}, 'error'); return; } - + const existsLocally = this.currentVersion.existsLocally; if (existsLocally) { showToast('toast.loras.versionExists', {}, 'info'); @@ -352,12 +352,12 @@ export class DownloadManager { document.getElementById('versionStep').style.display = 'none'; document.getElementById('locationStep').style.display = 'block'; - + try { // Fetch model roots const rootsData = await this.apiClient.fetchModelRoots(); const modelRoot = document.getElementById('modelRoot'); - modelRoot.innerHTML = rootsData.roots.map(root => + modelRoot.innerHTML = rootsData.roots.map(root => `` ).join(''); @@ -380,7 +380,7 @@ export class DownloadManager { // Initialize folder tree await this.initializeFolderTree(); - + // Setup folder tree manager this.folderTreeManager.init({ onPathChange: (path) => { @@ -388,16 +388,16 @@ export class DownloadManager { this.updateTargetPath(); } }); - + // Setup model root change handler modelRoot.addEventListener('change', async () => { await this.initializeFolderTree(); this.updateTargetPath(); }); - + // Load default path setting for current model type this.loadDefaultPathSetting(); - + this.updateTargetPath(); } catch (error) { showToast('toast.downloads.loadError', { message: error.message }, 'error'); @@ -408,7 +408,7 @@ export class DownloadManager { const modelType = this.apiClient.modelType; const storageKey = `use_default_path_${modelType}`; this.useDefaultPath = getStorageItem(storageKey, false); - + const toggleInput = document.getElementById('useDefaultPath'); if (toggleInput) { toggleInput.checked = this.useDefaultPath; @@ -418,12 +418,12 @@ export class DownloadManager { toggleDefaultPath(event) { this.useDefaultPath = event.target.checked; - + // Save to localStorage per model type const modelType = this.apiClient.modelType; const storageKey = `use_default_path_${modelType}`; setStorageItem(storageKey, this.useDefaultPath); - + this.updatePathSelectionUI(); this.updateTargetPath(); } @@ -446,7 +446,7 @@ export class DownloadManager { const displayName = versionName || `#${versionId}`; let ws = null; - let updateProgress = () => {}; + let updateProgress = () => { }; try { this.loadingManager.restoreProgressBar(); @@ -549,7 +549,7 @@ export class DownloadManager { updatePathSelectionUI() { const manualSelection = document.getElementById('manualPathSelection'); - + // Always show manual path selection, but disable/enable based on useDefaultPath manualSelection.style.display = 'block'; if (this.useDefaultPath) { @@ -566,11 +566,11 @@ export class DownloadManager { el.tabIndex = 0; }); } - + // Always update the main path display this.updateTargetPath(); } - + backToUrl() { document.getElementById('versionStep').style.display = 'none'; document.getElementById('urlStep').style.display = 'block'; @@ -592,7 +592,7 @@ export class DownloadManager { async startDownload() { const modelRoot = document.getElementById('modelRoot').value; const config = this.apiClient.apiConfig.config; - + if (!modelRoot) { showToast('toast.models.pleaseSelectRoot', { type: config.displayName }, 'error'); return; @@ -601,7 +601,7 @@ export class DownloadManager { // Determine target folder and use_default_paths parameter let targetFolder = ''; let useDefaultPaths = false; - + if (this.useDefaultPath) { useDefaultPaths = true; targetFolder = ''; // Not needed when using default paths @@ -646,7 +646,7 @@ export class DownloadManager { try { // Fetch unified folder tree const treeData = await this.apiClient.fetchUnifiedFolderTree(); - + if (treeData.success) { // Load tree data into folder tree manager await this.folderTreeManager.loadTree(treeData.tree); @@ -674,23 +674,23 @@ export class DownloadManager { folderItem.classList.remove('selected'); this.selectedFolder = ''; } else { - folderBrowser.querySelectorAll('.folder-item').forEach(f => + folderBrowser.querySelectorAll('.folder-item').forEach(f => f.classList.remove('selected')); folderItem.classList.add('selected'); this.selectedFolder = folderItem.dataset.folder; } - + this.updateTargetPath(); }; folderBrowser.addEventListener('click', this.folderClickHandler); - + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - + modelRoot.addEventListener('change', this.updateTargetPath); newFolder.addEventListener('input', this.updateTargetPath); - + this.updateTargetPath(); } @@ -702,21 +702,21 @@ export class DownloadManager { this.folderClickHandler = null; } } - + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - + if (modelRoot) modelRoot.removeEventListener('change', this.updateTargetPath); if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); } - + updateTargetPath() { const pathDisplay = document.getElementById('targetPathDisplay'); const modelRoot = document.getElementById('modelRoot').value; const config = this.apiClient.apiConfig.config; - + let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName }); - + if (modelRoot) { if (this.useDefaultPath) { // Show actual template path diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index 3aa4de35..e93b9174 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -28,7 +28,7 @@ export class ImportManager { this.importMode = 'url'; // Default mode: 'url' or 'upload' this.useDefaultPath = false; this.apiClient = null; - + // Initialize sub-managers this.loadingManager = new LoadingManager(); this.stepManager = new ImportStepManager(); @@ -36,7 +36,7 @@ export class ImportManager { this.recipeDataManager = new RecipeDataManager(this); this.downloadManager = new DownloadManager(this); this.folderTreeManager = new FolderTreeManager(); - + // Bind methods this.formatFileSize = formatFileSize; this.updateTargetPath = this.updateTargetPath.bind(this); @@ -53,17 +53,17 @@ export class ImportManager { this.initializeEventHandlers(); this.initialized = true; } - + // Get API client for LoRAs this.apiClient = getModelApiClient(MODEL_TYPES.LORA); - + // Reset state this.resetSteps(); if (recipeData) { this.downloadableLoRAs = recipeData.loras; this.recipeId = recipeId; } - + // Show modal modalManager.showModal('importModal', null, () => { this.cleanupFolderBrowser(); @@ -71,7 +71,7 @@ export class ImportManager { }); // Verify visibility and focus on URL input - setTimeout(() => { + setTimeout(() => { // Ensure URL option is selected and focus on the input this.toggleImportMode('url'); const urlInput = document.getElementById('imageUrlInput'); @@ -93,32 +93,32 @@ export class ImportManager { // Clear UI state this.stepManager.removeInjectedStyles(); this.stepManager.showStep('uploadStep'); - + // Reset form inputs const fileInput = document.getElementById('recipeImageUpload'); if (fileInput) fileInput.value = ''; - + const urlInput = document.getElementById('imageUrlInput'); if (urlInput) urlInput.value = ''; - + const uploadError = document.getElementById('uploadError'); if (uploadError) uploadError.textContent = ''; - + const importUrlError = document.getElementById('importUrlError'); if (importUrlError) importUrlError.textContent = ''; - + const recipeName = document.getElementById('recipeName'); if (recipeName) recipeName.value = ''; - + const tagsContainer = document.getElementById('tagsContainer'); if (tagsContainer) tagsContainer.innerHTML = `
${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}
`; - + // Clear folder path input const folderPathInput = document.getElementById('importFolderPath'); if (folderPathInput) { folderPathInput.value = ''; } - + // Reset state variables this.recipeImage = null; this.recipeData = null; @@ -127,30 +127,30 @@ export class ImportManager { this.missingLoras = []; this.downloadableLoRAs = []; this.selectedFolder = ''; - + // Reset import mode this.importMode = 'url'; this.toggleImportMode('url'); - + // Clear folder tree selection if (this.folderTreeManager) { this.folderTreeManager.clearSelection(); } - + // Reset default path toggle this.loadDefaultPathSetting(); - + // Reset duplicate related properties this.duplicateRecipes = []; } toggleImportMode(mode) { this.importMode = mode; - + // Update toggle buttons const uploadBtn = document.querySelector('.toggle-btn[data-mode="upload"]'); const urlBtn = document.querySelector('.toggle-btn[data-mode="url"]'); - + if (uploadBtn && urlBtn) { if (mode === 'upload') { uploadBtn.classList.add('active'); @@ -160,11 +160,11 @@ export class ImportManager { urlBtn.classList.add('active'); } } - + // Show/hide appropriate sections const uploadSection = document.getElementById('uploadSection'); const urlSection = document.getElementById('urlSection'); - + if (uploadSection && urlSection) { if (mode === 'upload') { uploadSection.style.display = 'block'; @@ -174,11 +174,11 @@ export class ImportManager { urlSection.style.display = 'block'; } } - + // Clear error messages const uploadError = document.getElementById('uploadError'); const importUrlError = document.getElementById('importUrlError'); - + if (uploadError) uploadError.textContent = ''; if (importUrlError) importUrlError.textContent = ''; } @@ -206,7 +206,7 @@ export class ImportManager { addTag() { this.recipeDataManager.addTag(); } - + removeTag(tag) { this.recipeDataManager.removeTag(tag); } @@ -217,12 +217,12 @@ export class ImportManager { async proceedToLocation() { this.stepManager.showStep('locationStep'); - + try { // Fetch LoRA roots const rootsData = await this.apiClient.fetchModelRoots(); const loraRoot = document.getElementById('importLoraRoot'); - loraRoot.innerHTML = rootsData.roots.map(root => + loraRoot.innerHTML = rootsData.roots.map(root => `` ).join(''); @@ -247,19 +247,19 @@ export class ImportManager { this.updateTargetPath(); } }); - + // Initialize folder tree await this.initializeFolderTree(); - + // Setup lora root change handler loraRoot.addEventListener('change', async () => { await this.initializeFolderTree(); this.updateTargetPath(); }); - + // Load default path setting for LoRAs this.loadDefaultPathSetting(); - + this.updateTargetPath(); } catch (error) { showToast('toast.recipes.importFailed', { message: error.message }, 'error'); @@ -268,19 +268,19 @@ export class ImportManager { backToUpload() { this.stepManager.showStep('uploadStep'); - + // Reset file input const fileInput = document.getElementById('recipeImageUpload'); if (fileInput) fileInput.value = ''; - + // Reset URL input const urlInput = document.getElementById('imageUrlInput'); if (urlInput) urlInput.value = ''; - + // Clear error messages const uploadError = document.getElementById('uploadError'); if (uploadError) uploadError.textContent = ''; - + const importUrlError = document.getElementById('importUrlError'); if (importUrlError) importUrlError.textContent = ''; } @@ -296,7 +296,7 @@ export class ImportManager { loadDefaultPathSetting() { const storageKey = 'use_default_path_loras'; this.useDefaultPath = getStorageItem(storageKey, false); - + const toggleInput = document.getElementById('importUseDefaultPath'); if (toggleInput) { toggleInput.checked = this.useDefaultPath; @@ -306,18 +306,18 @@ export class ImportManager { toggleDefaultPath(event) { this.useDefaultPath = event.target.checked; - + // Save to localStorage for LoRAs const storageKey = 'use_default_path_loras'; setStorageItem(storageKey, this.useDefaultPath); - + this.updatePathSelectionUI(); this.updateTargetPath(); } updatePathSelectionUI() { const manualSelection = document.getElementById('importManualPathSelection'); - + // Always show manual path selection, but disable/enable based on useDefaultPath if (manualSelection) { manualSelection.style.display = 'block'; @@ -336,7 +336,7 @@ export class ImportManager { }); } } - + // Always update the main path display this.updateTargetPath(); } @@ -345,7 +345,7 @@ export class ImportManager { try { // Fetch unified folder tree const treeData = await this.apiClient.fetchUnifiedFolderTree(); - + if (treeData.success) { // Load tree data into folder tree manager await this.folderTreeManager.loadTree(treeData.tree); @@ -368,8 +368,8 @@ export class ImportManager { updateTargetPath() { const pathDisplay = document.getElementById('importTargetPathDisplay'); const loraRoot = document.getElementById('importLoraRoot').value; - - let fullPath = loraRoot || translate('recipes.controls.import.selectLoraRoot', {}, 'Select a LoRA root directory'); if (loraRoot) { + + let fullPath = loraRoot || translate('recipes.controls.import.selectLoraRoot', {}, 'Select a LoRA root directory'); if (loraRoot) { if (this.useDefaultPath) { // Show actual template path try { @@ -417,19 +417,19 @@ export class ImportManager { // Store the recipe data and ID this.recipeData = recipeData; this.recipeId = recipeId; - + // Show the modal and go to location step this.showImportModal(recipeData, recipeId); this.proceedToLocation(); - + // Update the modal title const modalTitle = document.querySelector('#importModal h2'); if (modalTitle) modalTitle.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs'); - + // Update the save button text const saveButton = document.querySelector('#locationStep .primary-btn'); if (saveButton) saveButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs'); - + // Hide the back button const backButton = document.querySelector('#locationStep .secondary-btn'); if (backButton) backButton.style.display = 'none'; diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 88f62839..4f909b97 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -6,6 +6,8 @@ import { getModelApiClient } from '../api/modelApiFactory.js'; import { RecipeSidebarApiClient } from '../api/recipeApi.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { sidebarManager } from '../components/SidebarManager.js'; +import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; +import { translate } from '../utils/i18nHelpers.js'; class MoveManager { constructor() { @@ -14,9 +16,11 @@ class MoveManager { this.folderTreeManager = new FolderTreeManager(); this.initialized = false; this.recipeApiClient = null; - + this.useDefaultPath = false; + // Bind methods this.updateTargetPath = this.updateTargetPath.bind(this); + this.handleToggleDefaultPath = this.handleToggleDefaultPath.bind(this); } _getApiClient(modelType = null) { @@ -31,15 +35,21 @@ class MoveManager { initializeEventListeners() { if (this.initialized) return; - + const modelRootSelect = document.getElementById('moveModelRoot'); - + // Initialize model root directory selector modelRootSelect.addEventListener('change', async () => { await this.initializeFolderTree(); this.updateTargetPath(); }); - + + // Default path toggle handler + const toggleInput = document.getElementById('moveUseDefaultPath'); + if (toggleInput) { + toggleInput.addEventListener('change', this.handleToggleDefaultPath); + } + this.initialized = true; } @@ -47,11 +57,11 @@ class MoveManager { // Reset state this.currentFilePath = null; this.bulkFilePaths = null; - + const apiClient = this._getApiClient(modelType); const currentPageType = state.currentPageType; const modelConfig = apiClient.apiConfig.config; - + // Handle bulk mode if (filePath === 'bulk') { const selectedPaths = Array.from(state.selectedModels); @@ -66,11 +76,11 @@ class MoveManager { this.currentFilePath = filePath; document.getElementById('moveModalTitle').textContent = `Move ${modelConfig.displayName}`; } - + // Update UI labels based on model type document.getElementById('moveRootLabel').textContent = `Select ${modelConfig.displayName} Root:`; document.getElementById('moveTargetPathDisplay').querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`; - + // Clear folder path input const folderPathInput = document.getElementById('moveFolderPath'); if (folderPathInput) { @@ -86,13 +96,13 @@ class MoveManager { } else { rootsData = await apiClient.fetchModelRoots(); } - + if (!rootsData.roots || rootsData.roots.length === 0) { throw new Error(`No ${modelConfig.displayName.toLowerCase()} roots found`); } // Populate model root selector - modelRootSelect.innerHTML = rootsData.roots.map(root => + modelRootSelect.innerHTML = rootsData.roots.map(root => `` ).join(''); @@ -105,7 +115,7 @@ class MoveManager { // Initialize event listeners this.initializeEventListeners(); - + // Setup folder tree manager this.folderTreeManager.init({ onPathChange: (path) => { @@ -113,10 +123,13 @@ class MoveManager { }, elementsPrefix: 'move' }); - + // Initialize folder tree await this.initializeFolderTree(); + // Load default path setting + this.loadDefaultPathSetting(apiClient.modelType); + this.updateTargetPath(); modalManager.showModal('moveModal', null, () => { // Cleanup on modal close @@ -124,19 +137,63 @@ class MoveManager { this.folderTreeManager.destroy(); } }); - + } catch (error) { console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error); showToast('toast.models.moveFailed', { message: error.message }, 'error'); } } + loadDefaultPathSetting(modelType) { + const storageKey = `use_default_path_${modelType}`; + this.useDefaultPath = getStorageItem(storageKey, false); + + const toggleInput = document.getElementById('moveUseDefaultPath'); + if (toggleInput) { + toggleInput.checked = this.useDefaultPath; + this.updatePathSelectionUI(); + } + } + + handleToggleDefaultPath(event) { + this.useDefaultPath = event.target.checked; + + // Save to localStorage per model type + const apiClient = this._getApiClient(); + const modelType = apiClient.modelType; + const storageKey = `use_default_path_${modelType}`; + setStorageItem(storageKey, this.useDefaultPath); + + this.updatePathSelectionUI(); + this.updateTargetPath(); + } + + updatePathSelectionUI() { + const manualSelection = document.getElementById('moveManualPathSelection'); + if (!manualSelection) return; + + if (this.useDefaultPath) { + manualSelection.classList.add('disabled'); + // Disable all inputs and buttons inside manualSelection + manualSelection.querySelectorAll('input, select, button').forEach(el => { + el.disabled = true; + el.tabIndex = -1; + }); + } else { + manualSelection.classList.remove('disabled'); + manualSelection.querySelectorAll('input, select, button').forEach(el => { + el.disabled = false; + el.tabIndex = 0; + }); + } + } + async initializeFolderTree() { try { const apiClient = this._getApiClient(); // Fetch unified folder tree const treeData = await apiClient.fetchUnifiedFolderTree(); - + if (treeData.success) { // Load tree data into folder tree manager await this.folderTreeManager.loadTree(treeData.tree); @@ -155,13 +212,27 @@ class MoveManager { const modelRoot = document.getElementById('moveModelRoot').value; const apiClient = this._getApiClient(); const config = apiClient.apiConfig.config; - - let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`; - + + let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName }); + if (modelRoot) { - const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; - if (selectedPath) { - fullPath += '/' + selectedPath; + if (this.useDefaultPath) { + // Show actual template path + try { + const singularType = apiClient.modelType.replace(/s$/, ''); + const templates = state.global.settings.download_path_templates; + const template = templates[singularType]; + fullPath += `/${template}`; + } catch (error) { + console.error('Failed to fetch template:', error); + fullPath += '/' + translate('modals.download.autoOrganizedPath'); + } + } else { + // Show manual path selection + const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; + if (selectedPath) { + fullPath += '/' + selectedPath; + } } } @@ -172,7 +243,7 @@ class MoveManager { const selectedRoot = document.getElementById('moveModelRoot').value; const apiClient = this._getApiClient(); const config = apiClient.apiConfig.config; - + if (!selectedRoot) { showToast('toast.models.pleaseSelectRoot', { type: config.displayName.toLowerCase() }, 'error'); return; @@ -180,7 +251,7 @@ class MoveManager { // Get selected folder path from folder tree manager const targetFolder = this.folderTreeManager.getSelectedPath(); - + let targetPath = selectedRoot; if (targetFolder) { targetPath = `${targetPath}/${targetFolder}`; @@ -189,7 +260,7 @@ class MoveManager { try { if (this.bulkFilePaths) { // Bulk move mode - const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath); + const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath); // Update virtual scroller if in active folder view const pageState = getCurrentPageState(); @@ -206,7 +277,7 @@ class MoveManager { if (result.success && result.new_file_path !== result.original_file_path) { const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1); const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.')); - + state.virtualScroller.updateSingleItem(result.original_file_path, { file_path: result.new_file_path, file_name: baseFileName @@ -216,7 +287,7 @@ class MoveManager { } } else { // Single move mode - const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath); + const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath); const pageState = getCurrentPageState(); if (result && result.new_file_path) { @@ -226,7 +297,7 @@ class MoveManager { // Update both file_path and file_name if they changed const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1); const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.')); - + state.virtualScroller.updateSingleItem(this.currentFilePath, { file_path: result.new_file_path, file_name: baseFileName @@ -239,7 +310,7 @@ class MoveManager { sidebarManager.refresh(); modalManager.closeModal('moveModal'); - + // If we were in bulk mode, exit it after successful move if (this.bulkFilePaths && state.bulkMode) { bulkManager.toggleBulkMode(); diff --git a/templates/components/modals/move_modal.html b/templates/components/modals/move_modal.html index 019e1d30..a9273a41 100644 --- a/templates/components/modals/move_modal.html +++ b/templates/components/modals/move_modal.html @@ -6,50 +6,65 @@ ×
- +
- +
+ +
+ {{ t('modals.download.useDefaultPath') }} +
+ + +
+
+
{{ t('modals.download.selectRootDirectory') }}
-
- - -
- - -
- -
- - + +
+
+ +
- -
- - - - - -
- -
-
- + + +
+ +
+ + +
+ +
+ + + + + +
+ +
+
+ +