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 ? `