diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 01c6e0cf..38734511 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -65,7 +65,7 @@ class ApiRoutes: app.router.add_get('/api/loras/top-tags', routes.get_top_tags) # Add new route for top tags app.router.add_get('/api/loras/base-models', routes.get_base_models) # Add new route for base models app.router.add_get('/api/lora-civitai-url', routes.get_lora_civitai_url) # Add new route for Civitai URL - app.router.add_post('/api/rename_lora', routes.rename_lora) # Add new route for renaming LoRA files + app.router.add_post('/api/loras/rename', routes.rename_lora) # Add new route for renaming LoRA files app.router.add_get('/api/loras/scan', routes.scan_loras) # Add new route for scanning LoRA files # Add the new trigger words route @@ -873,128 +873,10 @@ class ApiRoutes: async def rename_lora(self, request: web.Request) -> web.Response: """Handle renaming a LoRA file and its associated files""" - try: - if self.scanner is None: - self.scanner = await ServiceRegistry.get_lora_scanner() - - if self.download_manager is None: - self.download_manager = await ServiceRegistry.get_download_manager() - - data = await request.json() - file_path = data.get('file_path') - new_file_name = data.get('new_file_name') + if self.scanner is None: + self.scanner = await ServiceRegistry.get_lora_scanner() - if not file_path or not new_file_name: - return web.json_response({ - 'success': False, - 'error': 'File path and new file name are required' - }, status=400) - - # Validate the new file name (no path separators or invalid characters) - invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] - if any(char in new_file_name for char in invalid_chars): - return web.json_response({ - 'success': False, - 'error': 'Invalid characters in file name' - }, status=400) - - # Get the directory and current file name - target_dir = os.path.dirname(file_path) - old_file_name = os.path.splitext(os.path.basename(file_path))[0] - - # Check if the target file already exists - new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/') - if os.path.exists(new_file_path): - return web.json_response({ - 'success': False, - 'error': 'A file with this name already exists' - }, status=400) - - # Define the patterns for associated files - patterns = [ - f"{old_file_name}.safetensors", # Required - f"{old_file_name}.metadata.json", - f"{old_file_name}.metadata.json.bak", - ] - - # Add all preview file extensions - for ext in PREVIEW_EXTENSIONS: - patterns.append(f"{old_file_name}{ext}") - - # Find all matching files - existing_files = [] - for pattern in patterns: - path = os.path.join(target_dir, pattern) - if os.path.exists(path): - existing_files.append((path, pattern)) - - # Get the hash from the main file to update hash index - hash_value = None - metadata = None - metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json") - - if os.path.exists(metadata_path): - metadata = await ModelRouteUtils.load_local_metadata(metadata_path) - hash_value = metadata.get('sha256') - - # Rename all files - renamed_files = [] - new_metadata_path = None - - for old_path, pattern in existing_files: - # Get the file extension like .safetensors or .metadata.json - ext = ModelRouteUtils.get_multipart_ext(pattern) - - # Create the new path - new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/') - - # Rename the file - os.rename(old_path, new_path) - renamed_files.append(new_path) - - # Keep track of metadata path for later update - if ext == '.metadata.json': - new_metadata_path = new_path - - # Update the metadata file with new file name and paths - if new_metadata_path and metadata: - # Update file_name, file_path and preview_url in metadata - metadata['file_name'] = new_file_name - metadata['file_path'] = new_file_path - - # Update preview_url if it exists - if 'preview_url' in metadata and metadata['preview_url']: - old_preview = metadata['preview_url'] - ext = ModelRouteUtils.get_multipart_ext(old_preview) - new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/') - metadata['preview_url'] = new_preview - - # Save updated metadata - await MetadataManager.save_metadata(new_file_path, metadata) - - # Update the scanner cache - if metadata: - await self.scanner.update_single_model_cache(file_path, new_file_path, metadata) - - # Update recipe files and cache if hash is available - if hash_value: - recipe_scanner = await ServiceRegistry.get_recipe_scanner() - recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name) - logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed LoRA") - - return web.json_response({ - 'success': True, - 'new_file_path': new_file_path, - 'renamed_files': renamed_files, - 'reload_required': False - }) - - except Exception as e: - logger.error(f"Error renaming LoRA: {e}", exc_info=True) - return web.json_response({ - 'success': False, - 'error': str(e) - }, status=500) + return await ModelRouteUtils.handle_rename_model(request, self.scanner) async def get_trigger_words(self, request: web.Request) -> web.Response: """Get trigger words for specified LoRA models""" diff --git a/py/routes/checkpoints_routes.py b/py/routes/checkpoints_routes.py index 2ecf77bf..83cdf611 100644 --- a/py/routes/checkpoints_routes.py +++ b/py/routes/checkpoints_routes.py @@ -56,6 +56,7 @@ class CheckpointsRoutes: app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview) app.router.add_post('/api/checkpoints/download', self.download_checkpoint) app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route + app.router.add_post('/api/checkpoints/rename', self.rename_checkpoint) # Add new rename endpoint # Add new WebSocket endpoint for checkpoint progress app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection) @@ -836,3 +837,7 @@ class CheckpointsRoutes: async def verify_duplicates(self, request: web.Request) -> web.Response: """Handle verification of duplicate checkpoint hashes""" return await ModelRouteUtils.handle_verify_duplicates(request, self.scanner) + + async def rename_checkpoint(self, request: web.Request) -> web.Response: + """Handle renaming a checkpoint file and its associated files""" + return await ModelRouteUtils.handle_rename_model(request, self.scanner) diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index d51419a0..9a533859 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -836,3 +836,132 @@ class ModelRouteUtils: 'success': False, 'error': str(e) }, status=500) + + @staticmethod + async def handle_rename_model(request: web.Request, scanner) -> web.Response: + """Handle renaming a model file and its associated files + + Args: + request: The aiohttp request + scanner: The model scanner instance + + Returns: + web.Response: The HTTP response + """ + try: + data = await request.json() + file_path = data.get('file_path') + new_file_name = data.get('new_file_name') + + if not file_path or not new_file_name: + return web.json_response({ + 'success': False, + 'error': 'File path and new file name are required' + }, status=400) + + # Validate the new file name (no path separators or invalid characters) + invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] + if any(char in new_file_name for char in invalid_chars): + return web.json_response({ + 'success': False, + 'error': 'Invalid characters in file name' + }, status=400) + + # Get the directory and current file name + target_dir = os.path.dirname(file_path) + old_file_name = os.path.splitext(os.path.basename(file_path))[0] + + # Check if the target file already exists + new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/') + if os.path.exists(new_file_path): + return web.json_response({ + 'success': False, + 'error': 'A file with this name already exists' + }, status=400) + + # Define the patterns for associated files + patterns = [ + f"{old_file_name}.safetensors", # Required + f"{old_file_name}.metadata.json", + f"{old_file_name}.metadata.json.bak", + ] + + # Add all preview file extensions + for ext in PREVIEW_EXTENSIONS: + patterns.append(f"{old_file_name}{ext}") + + # Find all matching files + existing_files = [] + for pattern in patterns: + path = os.path.join(target_dir, pattern) + if os.path.exists(path): + existing_files.append((path, pattern)) + + # Get the hash from the main file to update hash index + hash_value = None + metadata = None + metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json") + + if os.path.exists(metadata_path): + metadata = await ModelRouteUtils.load_local_metadata(metadata_path) + hash_value = metadata.get('sha256') + + # Rename all files + renamed_files = [] + new_metadata_path = None + + for old_path, pattern in existing_files: + # Get the file extension like .safetensors or .metadata.json + ext = ModelRouteUtils.get_multipart_ext(pattern) + + # Create the new path + new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/') + + # Rename the file + os.rename(old_path, new_path) + renamed_files.append(new_path) + + # Keep track of metadata path for later update + if ext == '.metadata.json': + new_metadata_path = new_path + + # Update the metadata file with new file name and paths + if new_metadata_path and metadata: + # Update file_name, file_path and preview_url in metadata + metadata['file_name'] = new_file_name + metadata['file_path'] = new_file_path + + # Update preview_url if it exists + if 'preview_url' in metadata and metadata['preview_url']: + old_preview = metadata['preview_url'] + ext = ModelRouteUtils.get_multipart_ext(old_preview) + new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/') + metadata['preview_url'] = new_preview + + # Save updated metadata + await MetadataManager.save_metadata(new_file_path, metadata) + + # Update the scanner cache + if metadata: + await scanner.update_single_model_cache(file_path, new_file_path, metadata) + + # Update recipe files and cache if hash is available and recipe_scanner exists + if hash_value and hasattr(scanner, 'update_lora_filename_by_hash'): + recipe_scanner = await ServiceRegistry.get_recipe_scanner() + if recipe_scanner: + recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name) + logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed model") + + return web.json_response({ + 'success': True, + 'new_file_path': new_file_path, + 'renamed_files': renamed_files, + 'reload_required': False + }) + + except Exception as e: + logger.error(f"Error renaming model: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index 0a8c889b..a595b2aa 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -140,7 +140,7 @@ export async function renameCheckpointFile(filePath, newFileName) { // Show loading indicator state.loadingManager.showSimpleLoading('Renaming checkpoint file...'); - const response = await fetch('/api/rename_checkpoint', { + const response = await fetch('/api/checkpoints/rename', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -160,7 +160,6 @@ export async function renameCheckpointFile(filePath, newFileName) { console.error('Error renaming checkpoint file:', error); throw error; } finally { - // Hide loading indicator state.loadingManager.hide(); } } \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 8781635f..3c6f0c86 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -149,7 +149,7 @@ export async function renameLoraFile(filePath, newFileName) { // Show loading indicator state.loadingManager.showSimpleLoading('Renaming LoRA file...'); - const response = await fetch('/api/rename_lora', { + const response = await fetch('/api/loras/rename', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/static/js/components/ContextMenu/ModelContextMenuMixin.js b/static/js/components/ContextMenu/ModelContextMenuMixin.js index 7314651d..7c0bc0c3 100644 --- a/static/js/components/ContextMenu/ModelContextMenuMixin.js +++ b/static/js/components/ContextMenu/ModelContextMenuMixin.js @@ -1,6 +1,4 @@ import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js'; -import { NSFW_LEVELS } from '../../utils/constants.js'; -import { getStorageItem } from '../../utils/storageHelpers.js'; import { modalManager } from '../../managers/ModalManager.js'; import { state } from '../../state/index.js'; @@ -44,149 +42,6 @@ export const ModelContextMenuMixin = { }); }, - updateCardBlurEffect(card, level) { - // Get user settings for blur threshold - const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4'); - - // Get card preview container - const previewContainer = card.querySelector('.card-preview'); - if (!previewContainer) return; - - // Get preview media element - const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video'); - if (!previewMedia) return; - - // Check if blur should be applied - if (level >= blurThreshold) { - // Add blur class to the preview container - previewContainer.classList.add('blurred'); - - // Get or create the NSFW overlay - let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay'); - if (!nsfwOverlay) { - // Create new overlay - nsfwOverlay = document.createElement('div'); - nsfwOverlay.className = 'nsfw-overlay'; - - // Create and configure the warning content - const warningContent = document.createElement('div'); - warningContent.className = 'nsfw-warning'; - - // Determine NSFW warning text based on level - let nsfwText = "Mature Content"; - if (level >= NSFW_LEVELS.XXX) { - nsfwText = "XXX-rated Content"; - } else if (level >= NSFW_LEVELS.X) { - nsfwText = "X-rated Content"; - } else if (level >= NSFW_LEVELS.R) { - nsfwText = "R-rated Content"; - } - - // Add warning text and show button - warningContent.innerHTML = ` -

${nsfwText}

- - `; - - // Add click event to the show button - const showBtn = warningContent.querySelector('.show-content-btn'); - showBtn.addEventListener('click', (e) => { - e.stopPropagation(); - previewContainer.classList.remove('blurred'); - nsfwOverlay.style.display = 'none'; - - // Update toggle button icon if it exists - const toggleBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; - } - }); - - nsfwOverlay.appendChild(warningContent); - previewContainer.appendChild(nsfwOverlay); - } else { - // Update existing overlay - const warningText = nsfwOverlay.querySelector('p'); - if (warningText) { - let nsfwText = "Mature Content"; - if (level >= NSFW_LEVELS.XXX) { - nsfwText = "XXX-rated Content"; - } else if (level >= NSFW_LEVELS.X) { - nsfwText = "X-rated Content"; - } else if (level >= NSFW_LEVELS.R) { - nsfwText = "R-rated Content"; - } - warningText.textContent = nsfwText; - } - nsfwOverlay.style.display = 'flex'; - } - - // Get or create the toggle button in the header - const cardHeader = previewContainer.querySelector('.card-header'); - if (cardHeader) { - let toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); - - if (!toggleBtn) { - toggleBtn = document.createElement('button'); - toggleBtn.className = 'toggle-blur-btn'; - toggleBtn.title = 'Toggle blur'; - toggleBtn.innerHTML = ''; - - // Add click event to toggle button - toggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const isBlurred = previewContainer.classList.toggle('blurred'); - const icon = toggleBtn.querySelector('i'); - - // Update icon and overlay visibility - if (isBlurred) { - icon.className = 'fas fa-eye'; - nsfwOverlay.style.display = 'flex'; - } else { - icon.className = 'fas fa-eye-slash'; - nsfwOverlay.style.display = 'none'; - } - }); - - // Add to the beginning of header - cardHeader.insertBefore(toggleBtn, cardHeader.firstChild); - - // Update base model label class - const baseModelLabel = cardHeader.querySelector('.base-model-label'); - if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) { - baseModelLabel.classList.add('with-toggle'); - } - } else { - // Update existing toggle button - toggleBtn.querySelector('i').className = 'fas fa-eye'; - } - } - } else { - // Remove blur - previewContainer.classList.remove('blurred'); - - // Hide overlay if it exists - const overlay = previewContainer.querySelector('.nsfw-overlay'); - if (overlay) overlay.style.display = 'none'; - - // Remove toggle button when content is set to PG or PG13 - const cardHeader = previewContainer.querySelector('.card-header'); - if (cardHeader) { - const toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - // Remove the toggle button completely - toggleBtn.remove(); - - // Update base model label class if it exists - const baseModelLabel = cardHeader.querySelector('.base-model-label'); - if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) { - baseModelLabel.classList.remove('with-toggle'); - } - } - } - } - }, - showNSFWLevelSelector(x, y, card) { const selector = document.getElementById('nsfwLevelSelector'); const currentLevelEl = document.getElementById('currentNSFWLevel'); diff --git a/static/js/components/checkpointModal/ModelMetadata.js b/static/js/components/checkpointModal/ModelMetadata.js index 37b0d831..e282738e 100644 --- a/static/js/components/checkpointModal/ModelMetadata.js +++ b/static/js/components/checkpointModal/ModelMetadata.js @@ -4,7 +4,7 @@ */ import { showToast } from '../../utils/uiHelpers.js'; import { BASE_MODELS } from '../../utils/constants.js'; -import { updateModelCard } from '../../utils/cardUpdater.js'; +import { state } from '../../state/index.js'; import { saveModelMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; /** @@ -412,30 +412,10 @@ export function setupFileNameEditing(filePath) { if (result.success) { showToast('File name updated successfully', 'success'); - // Get the new file path from the result - const pathParts = filePath.split(/[\\/]/); - pathParts.pop(); // Remove old filename - const newFilePath = [...pathParts, newFileName].join('/'); + const newFilePath = filePath.replace(originalValue, newFileName); - // Update the checkpoint card with new file path - updateModelCard(filePath, { - filepath: newFilePath, - file_name: newFileName - }); - - // Update the file name display in the modal - document.querySelector('#file-name').textContent = newFileName; - - // Update the modal's data-filepath attribute - const modalContent = document.querySelector('#checkpointModal .modal-content'); - if (modalContent) { - modalContent.dataset.filepath = newFilePath; - } - - // Reload the page after a short delay to reflect changes - setTimeout(() => { - window.location.reload(); - }, 1500); + state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath }); + this.textContent = newFileName; } else { throw new Error(result.error || 'Unknown error'); } diff --git a/static/js/components/loraModal/ModelMetadata.js b/static/js/components/loraModal/ModelMetadata.js index 11833dec..fe9f6b23 100644 --- a/static/js/components/loraModal/ModelMetadata.js +++ b/static/js/components/loraModal/ModelMetadata.js @@ -4,7 +4,7 @@ */ import { showToast } from '../../utils/uiHelpers.js'; import { BASE_MODELS } from '../../utils/constants.js'; -import { updateModelCard } from '../../utils/cardUpdater.js'; +import { state } from '../../state/index.js'; import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js'; /** @@ -420,8 +420,8 @@ export function setupFileNameEditing(filePath) { // Get the new file path and update the card const newFilePath = filePath.replace(originalValue, newFileName); - // Pass the new file_name in the updates object for proper card update - updateModelCard(filePath, { file_name: newFileName, filepath: newFilePath }); +; + state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath }); } else { throw new Error(result.error || 'Unknown error'); } diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 4e9cdec9..15b55f5a 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -2,7 +2,6 @@ import { showToast } from '../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { modalManager } from './ModalManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; -import { updateModelCard } from '../utils/cardUpdater.js'; class MoveManager { constructor() { @@ -151,8 +150,8 @@ class MoveManager { const filename = filePath.substring(filePath.lastIndexOf('/') + 1); // Construct new filepath const newFilePath = `${targetPath}/${filename}`; - // Update the card with new filepath - updateModelCard(filePath, {filepath: newFilePath}); + + state.virtualScroller.updateSingleItem(filePath, {file_path: newFilePath}); }); } } else { @@ -169,8 +168,8 @@ class MoveManager { const filename = this.currentFilePath.substring(this.currentFilePath.lastIndexOf('/') + 1); // Construct new filepath const newFilePath = `${targetPath}/${filename}`; - // Update the card with new filepath - updateModelCard(this.currentFilePath, {filepath: newFilePath}); + + state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath}); } } diff --git a/static/js/utils/cardUpdater.js b/static/js/utils/cardUpdater.js index 3a0263d5..938ee288 100644 --- a/static/js/utils/cardUpdater.js +++ b/static/js/utils/cardUpdater.js @@ -2,47 +2,6 @@ * Utility functions to update checkpoint cards after modal edits */ -/** - * Update the Lora card after metadata edits in the modal - * @param {string} filePath - Path to the Lora file - * @param {Object} updates - Object containing the updates (model_name, base_model, notes, usage_tips, etc) - */ -export function updateModelCard(filePath, updates) { - // Find the card with matching filepath - const modelCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (!modelCard) return; - - // Update card dataset and visual elements based on the updates object - Object.entries(updates).forEach(([key, value]) => { - // Update dataset - modelCard.dataset[key] = value; - - // Update visual elements based on the property - switch(key) { - case 'model_name': - // Update the model name in the card title - const titleElement = modelCard.querySelector('.card-title'); - if (titleElement) titleElement.textContent = value; - - // Also update the model name in the footer if it exists - const modelNameElement = modelCard.querySelector('.model-name'); - if (modelNameElement) modelNameElement.textContent = value; - break; - - case 'base_model': - // Update the base model label in the card header if it exists - const baseModelLabel = modelCard.querySelector('.base-model-label'); - if (baseModelLabel) { - baseModelLabel.textContent = value; - baseModelLabel.title = value; - } - break; - } - }); - - return modelCard; // Return the updated card element for chaining -} - /** * Update the recipe card after metadata edits in the modal * @param {string} recipeId - ID of the recipe to update