From 63aa4e188ecef95b386c930d6f0fe802a62f9cf3 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Sat, 29 Mar 2025 09:25:41 +0800 Subject: [PATCH] Add rename functionality for LoRA files and enhance UI for editing file names - Introduced a new API endpoint to rename LoRA files, including validation and error handling for file paths and names. - Updated the RecipeScanner to reflect changes in LoRA filenames across recipe files and cache. - Enhanced the LoraModal UI to allow inline editing of file names with improved user interaction and validation. - Added CSS styles for the editing interface to improve visual feedback during file name editing. --- py/routes/api_routes.py | 144 ++++++++++++++++++++++ py/services/recipe_scanner.py | 86 ++++++++++++- static/css/components/lora-modal.css | 44 +++++-- static/js/components/LoraModal.js | 175 ++++++++++++++++++++++++++- 4 files changed, 437 insertions(+), 12 deletions(-) diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 10a52a78..6d763406 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -52,6 +52,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 # Add update check routes UpdateRoutes.setup_routes(app) @@ -929,6 +930,149 @@ class ApiRoutes: }) except Exception as e: logger.error(f"Error retrieving base models: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + + def get_multipart_ext(self, filename): + parts = filename.split(".") + if len(parts) > 2: # 如果包含多级扩展名 + return "." + ".".join(parts[-2:]) # 取最后两部分,如 ".metadata.json" + return os.path.splitext(filename)[1] # 否则取普通扩展名,如 ".safetensors" + + async def rename_lora(self, request: web.Request) -> web.Response: + """Handle renaming a LoRA file and its associated files""" + 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}.preview.png", + f"{old_file_name}.preview.jpg", + f"{old_file_name}.preview.jpeg", + f"{old_file_name}.preview.webp", + f"{old_file_name}.preview.mp4", + f"{old_file_name}.png", + f"{old_file_name}.jpg", + f"{old_file_name}.jpeg", + f"{old_file_name}.webp", + f"{old_file_name}.mp4" + ] + + # 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): + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + hash_value = metadata.get('sha256') + except Exception as e: + logger.error(f"Error loading metadata for rename: {e}") + + # Rename all files + renamed_files = [] + new_metadata_path = None + + # Notify file monitor to ignore these events + main_file_path = os.path.join(target_dir, f"{old_file_name}.safetensors") + if os.path.exists(main_file_path) and self.download_manager.file_monitor: + # Add old and new paths to ignore list + file_size = os.path.getsize(main_file_path) + self.download_manager.file_monitor.handler.add_ignore_path(main_file_path, file_size) + self.download_manager.file_monitor.handler.add_ignore_path(new_file_path, file_size) + + for old_path, pattern in existing_files: + # Get the file extension like .safetensors or .metadata.json + ext = self.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 = self.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 + with open(new_metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + # Update the scanner cache + if metadata: + await self.scanner.update_single_lora_cache(file_path, new_file_path, metadata) + + # Update recipe files and cache if hash is available + if hash_value: + recipe_scanner = RecipeScanner(self.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) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 01bfb9b0..aeb27516 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -2,7 +2,7 @@ import os import logging import asyncio import json -from typing import List, Dict, Optional, Any +from typing import List, Dict, Optional, Any, Tuple from ..config import config from .recipe_cache import RecipeCache from .lora_scanner import LoraScanner @@ -430,3 +430,87 @@ class RecipeScanner: } return result + + async def update_lora_filename_by_hash(self, hash_value: str, new_file_name: str) -> Tuple[int, int]: + """Update file_name in all recipes that contain a LoRA with the specified hash. + + Args: + hash_value: The SHA256 hash value of the LoRA + new_file_name: The new file_name to set + + Returns: + Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache) + """ + if not hash_value or not new_file_name: + return 0, 0 + + # Always use lowercase hash for consistency + hash_value = hash_value.lower() + + # Get recipes directory + recipes_dir = self.recipes_dir + if not recipes_dir or not os.path.exists(recipes_dir): + logger.warning(f"Recipes directory not found: {recipes_dir}") + return 0, 0 + + # Check if cache is initialized + cache_initialized = self._cache is not None + cache_updated_count = 0 + file_updated_count = 0 + + # Get all recipe JSON files in the recipes directory + recipe_files = [] + for root, _, files in os.walk(recipes_dir): + for file in files: + if file.lower().endswith('.recipe.json'): + recipe_files.append(os.path.join(root, file)) + + # Process each recipe file + for recipe_path in recipe_files: + try: + # Load the recipe data + with open(recipe_path, 'r', encoding='utf-8') as f: + recipe_data = json.load(f) + + # Skip if no loras or invalid structure + if not recipe_data or not isinstance(recipe_data, dict) or 'loras' not in recipe_data: + continue + + # Check if any lora has matching hash + file_updated = False + for lora in recipe_data.get('loras', []): + if 'hash' in lora and lora['hash'].lower() == hash_value: + # Update file_name + old_file_name = lora.get('file_name', '') + lora['file_name'] = new_file_name + file_updated = True + logger.info(f"Updated file_name in recipe {recipe_path}: {old_file_name} -> {new_file_name}") + + # If updated, save the file + if file_updated: + with open(recipe_path, 'w', encoding='utf-8') as f: + json.dump(recipe_data, f, indent=4, ensure_ascii=False) + file_updated_count += 1 + + # Also update in cache if it exists + if cache_initialized: + recipe_id = recipe_data.get('id') + if recipe_id: + for cache_item in self._cache.raw_data: + if cache_item.get('id') == recipe_id: + # Replace loras array with updated version + cache_item['loras'] = recipe_data['loras'] + cache_updated_count += 1 + break + + except Exception as e: + logger.error(f"Error updating recipe file {recipe_path}: {e}") + import traceback + traceback.print_exc(file=sys.stderr) + + # Resort cache if updates were made + if cache_initialized and cache_updated_count > 0: + await self._cache.resort() + logger.info(f"Resorted recipe cache after updating {cache_updated_count} items") + + return file_updated_count, cache_updated_count diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 3fa5d344..66f43a92 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -543,25 +543,53 @@ display: flex; align-items: center; gap: 8px; - cursor: pointer; padding: 4px; border-radius: var(--border-radius-xs); transition: background-color 0.2s; + position: relative; } .file-name-wrapper:hover { background: oklch(var(--lora-accent) / 0.1); } -.file-name-wrapper i { - color: var(--text-color); - opacity: 0.5; - transition: opacity 0.2s; +.file-name-content { + padding: 2px 4px; + border-radius: var(--border-radius-xs); + border: 1px solid transparent; + flex: 1; } -.file-name-wrapper:hover i { - opacity: 1; - color: var(--lora-accent); +.file-name-wrapper.editing .file-name-content { + border: 1px solid var(--lora-accent); + background: var(--bg-color); + outline: none; +} + +.edit-file-name-btn { + background: transparent; + border: none; + color: var(--text-color); + opacity: 0; + cursor: pointer; + padding: 2px 5px; + border-radius: var(--border-radius-xs); + transition: all 0.2s ease; + margin-left: var(--space-1); +} + +.edit-file-name-btn.visible, +.file-name-wrapper:hover .edit-file-name-btn { + opacity: 0.5; +} + +.edit-file-name-btn:hover { + opacity: 0.8 !important; + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .edit-file-name-btn:hover { + background: rgba(255, 255, 255, 0.05); } /* Base Model and Size combined styles */ diff --git a/static/js/components/LoraModal.js b/static/js/components/LoraModal.js index 54454f69..186158b4 100644 --- a/static/js/components/LoraModal.js +++ b/static/js/components/LoraModal.js @@ -29,9 +29,11 @@ export function showLoraModal(lora) {