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.
This commit is contained in:
Will Miao
2025-03-29 09:25:41 +08:00
parent c31c9c16cf
commit 63aa4e188e
4 changed files with 437 additions and 12 deletions

View File

@@ -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/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/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_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 # Add update check routes
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
@@ -929,6 +930,149 @@ class ApiRoutes:
}) })
except Exception as e: except Exception as e:
logger.error(f"Error retrieving base models: {e}", exc_info=True) 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({ return web.json_response({
'success': False, 'success': False,
'error': str(e) 'error': str(e)

View File

@@ -2,7 +2,7 @@ import os
import logging import logging
import asyncio import asyncio
import json import json
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any, Tuple
from ..config import config from ..config import config
from .recipe_cache import RecipeCache from .recipe_cache import RecipeCache
from .lora_scanner import LoraScanner from .lora_scanner import LoraScanner
@@ -430,3 +430,87 @@ class RecipeScanner:
} }
return result 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

View File

@@ -543,25 +543,53 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
cursor: pointer;
padding: 4px; padding: 4px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: background-color 0.2s; transition: background-color 0.2s;
position: relative;
} }
.file-name-wrapper:hover { .file-name-wrapper:hover {
background: oklch(var(--lora-accent) / 0.1); background: oklch(var(--lora-accent) / 0.1);
} }
.file-name-wrapper i { .file-name-content {
color: var(--text-color); padding: 2px 4px;
opacity: 0.5; border-radius: var(--border-radius-xs);
transition: opacity 0.2s; border: 1px solid transparent;
flex: 1;
} }
.file-name-wrapper:hover i { .file-name-wrapper.editing .file-name-content {
opacity: 1; border: 1px solid var(--lora-accent);
color: 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 */ /* Base Model and Size combined styles */

View File

@@ -29,9 +29,11 @@ export function showLoraModal(lora) {
</div> </div>
<div class="info-item"> <div class="info-item">
<label>File Name</label> <label>File Name</label>
<div class="file-name-wrapper" onclick="copyFileName('${lora.file_name}')"> <div class="file-name-wrapper">
<span id="file-name">${lora.file_name || 'N/A'}</span> <span id="file-name" class="file-name-content">${lora.file_name || 'N/A'}</span>
<i class="fas fa-copy" title="Copy file name"></i> <button class="edit-file-name-btn" title="Edit file name">
<i class="fas fa-pencil-alt"></i>
</button>
</div> </div>
</div> </div>
<div class="info-item location-size"> <div class="info-item location-size">
@@ -130,6 +132,7 @@ export function showLoraModal(lora) {
setupTriggerWordsEditMode(); setupTriggerWordsEditMode();
setupModelNameEditing(); setupModelNameEditing();
setupBaseModelEditing(); setupBaseModelEditing();
setupFileNameEditing();
// If we have a model ID but no description, fetch it // If we have a model ID but no description, fetch it
if (lora.civitai?.modelId && !lora.modelDescription) { if (lora.civitai?.modelId && !lora.modelDescription) {
@@ -1562,3 +1565,169 @@ function setupBaseModelEditing() {
}); });
}); });
} }
// New function to handle file name editing
function setupFileNameEditing() {
const fileNameContent = document.querySelector('.file-name-content');
const editBtn = document.querySelector('.edit-file-name-btn');
if (!fileNameContent || !editBtn) return;
// Show edit button on hover
const fileNameWrapper = document.querySelector('.file-name-wrapper');
fileNameWrapper.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
fileNameWrapper.addEventListener('mouseleave', () => {
if (!fileNameWrapper.classList.contains('editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
fileNameWrapper.classList.add('editing');
fileNameContent.setAttribute('contenteditable', 'true');
fileNameContent.focus();
// Store original value for comparison later
fileNameContent.dataset.originalValue = fileNameContent.textContent.trim();
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(fileNameContent);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
editBtn.classList.add('visible');
});
// Handle keyboard events in edit mode
fileNameContent.addEventListener('keydown', function(e) {
if (!this.getAttribute('contenteditable')) return;
if (e.key === 'Enter') {
e.preventDefault();
this.blur(); // Trigger save on Enter
} else if (e.key === 'Escape') {
e.preventDefault();
// Restore original value
this.textContent = this.dataset.originalValue;
exitEditMode();
}
});
// Handle input validation
fileNameContent.addEventListener('input', function() {
if (!this.getAttribute('contenteditable')) return;
// Replace invalid characters for filenames
const invalidChars = /[\\/:*?"<>|]/g;
if (invalidChars.test(this.textContent)) {
const cursorPos = window.getSelection().getRangeAt(0).startOffset;
this.textContent = this.textContent.replace(invalidChars, '');
// Restore cursor position
const range = document.createRange();
const sel = window.getSelection();
const newPos = Math.min(cursorPos, this.textContent.length);
if (this.firstChild) {
range.setStart(this.firstChild, newPos);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
showToast('Invalid characters removed from filename', 'warning');
}
});
// Handle focus out - save changes
fileNameContent.addEventListener('blur', async function() {
if (!this.getAttribute('contenteditable')) return;
const newFileName = this.textContent.trim();
const originalValue = this.dataset.originalValue;
// Basic validation
if (!newFileName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('File name cannot be empty', 'error');
exitEditMode();
return;
}
if (newFileName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Get the full file path
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent + originalValue + '.safetensors';
// Call API to rename the file
const response = await fetch('/api/rename_lora', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
new_file_name: newFileName
})
});
const result = await response.json();
if (result.success) {
showToast('File name updated successfully', 'success');
// Update card in the gallery
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
// Update the card's filepath attribute to the new path
loraCard.dataset.filepath = result.new_file_path;
loraCard.dataset.file_name = newFileName;
// Update the filename display in the card
const cardFileName = loraCard.querySelector('.card-filename');
if (cardFileName) {
cardFileName.textContent = newFileName;
}
}
// Handle the case where we need to reload the page
if (result.reload_required) {
showToast('Reloading page to apply changes...', 'info');
setTimeout(() => {
window.location.reload();
}, 1500);
}
} else {
// Show error and restore original filename
showToast(result.error || 'Failed to update file name', 'error');
this.textContent = originalValue;
}
} catch (error) {
console.error('Error saving filename:', error);
showToast('Failed to update file name', 'error');
this.textContent = originalValue;
} finally {
exitEditMode();
}
});
function exitEditMode() {
fileNameContent.removeAttribute('contenteditable');
fileNameWrapper.classList.remove('editing');
editBtn.classList.remove('visible');
}
}