mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Implement move to folder
This commit is contained in:
@@ -39,6 +39,7 @@ class ApiRoutes:
|
||||
app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions)
|
||||
app.router.add_post('/api/download-lora', routes.download_lora)
|
||||
app.router.add_post('/api/settings', routes.update_settings)
|
||||
app.router.add_post('/api/move_model', routes.move_model)
|
||||
|
||||
async def delete_model(self, request: web.Request) -> web.Response:
|
||||
"""Handle model deletion request"""
|
||||
@@ -513,6 +514,28 @@ class ApiRoutes:
|
||||
logger.error(f"Error updating settings: {e}", exc_info=True) # 添加 exc_info=True 以获取完整堆栈
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
async def move_model(self, request: web.Request) -> web.Response:
|
||||
"""Handle model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
target_path = data.get('target_path')
|
||||
|
||||
if not file_path or not target_path:
|
||||
return web.Response(text='File path and target path are required', status=400)
|
||||
|
||||
# Call scanner to handle the move operation
|
||||
success = await self.scanner.move_model(file_path, target_path)
|
||||
|
||||
if success:
|
||||
return web.json_response({'success': True})
|
||||
else:
|
||||
return web.Response(text='Failed to move model', status=500)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
@classmethod
|
||||
async def cleanup(cls):
|
||||
"""Add cleanup method for application shutdown"""
|
||||
|
||||
@@ -57,6 +57,8 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
def on_deleted(self, event):
|
||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
||||
return
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
logger.info(f"LoRA file deleted: {event.src_path}")
|
||||
self._schedule_update('remove', event.src_path)
|
||||
|
||||
@@ -131,6 +133,7 @@ class LoraFileMonitor:
|
||||
|
||||
def __init__(self, scanner: LoraScanner, roots: List[str]):
|
||||
self.scanner = scanner
|
||||
scanner.set_file_monitor(self)
|
||||
self.roots = roots
|
||||
self.observer = Observer()
|
||||
# 获取当前运行的事件循环
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
import shutil
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
@@ -30,6 +32,11 @@ class LoraScanner:
|
||||
self._initialization_lock = asyncio.Lock()
|
||||
self._initialization_task: Optional[asyncio.Task] = None
|
||||
self._initialized = True
|
||||
self.file_monitor = None # Add this line
|
||||
|
||||
def set_file_monitor(self, monitor):
|
||||
"""Set file monitor instance"""
|
||||
self.file_monitor = monitor
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls):
|
||||
@@ -39,7 +46,7 @@ class LoraScanner:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> LoraCache:
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> LoraCache:
|
||||
"""Get cached LoRA data, refresh if needed"""
|
||||
async with self._initialization_lock:
|
||||
|
||||
@@ -295,17 +302,7 @@ class LoraScanner:
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
# 计算相对于 lora_roots 的文件夹路径
|
||||
folder = None
|
||||
file_dir = os.path.dirname(file_path)
|
||||
for root in config.loras_roots:
|
||||
if file_dir.startswith(root):
|
||||
rel_path = os.path.relpath(file_dir, root)
|
||||
if rel_path == '.':
|
||||
folder = '' # 根目录
|
||||
else:
|
||||
folder = rel_path.replace(os.sep, '/')
|
||||
break
|
||||
folder = self._calculate_folder(file_path)
|
||||
|
||||
# 确保 folder 字段存在
|
||||
metadata_dict = metadata.to_dict()
|
||||
@@ -316,4 +313,119 @@ class LoraScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_folder(self, file_path: str) -> str:
|
||||
"""Calculate the folder path for a LoRA file"""
|
||||
for root in config.loras_roots:
|
||||
if file_path.startswith(root):
|
||||
rel_path = os.path.relpath(file_path, root)
|
||||
return os.path.dirname(rel_path).replace(os.path.sep, '/')
|
||||
return ''
|
||||
|
||||
async def move_model(self, source_path: str, target_path: str) -> bool:
|
||||
"""Move a model and its associated files to a new location
|
||||
|
||||
Args:
|
||||
source_path: Full path to the source lora file
|
||||
target_path: Full path to the target directory
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Ensure paths are normalized
|
||||
source_path = source_path.replace(os.sep, '/')
|
||||
target_path = target_path.replace(os.sep, '/')
|
||||
|
||||
# Get base name without extension
|
||||
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
||||
source_dir = os.path.dirname(source_path)
|
||||
|
||||
# Create target directory if it doesn't exist
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
# Calculate target lora path
|
||||
target_lora = os.path.join(target_path, f"{base_name}.safetensors").replace(os.sep, '/')
|
||||
|
||||
# Get source file size for timeout calculation
|
||||
file_size = os.path.getsize(source_path)
|
||||
|
||||
# Tell file monitor to ignore these paths
|
||||
if self.file_monitor:
|
||||
self.file_monitor.handler.add_ignore_path(
|
||||
source_path,
|
||||
file_size
|
||||
)
|
||||
self.file_monitor.handler.add_ignore_path(
|
||||
target_lora,
|
||||
file_size
|
||||
)
|
||||
|
||||
# Move main lora file
|
||||
shutil.move(source_path, target_lora)
|
||||
|
||||
# Move associated files
|
||||
source_metadata = os.path.join(source_dir, f"{base_name}.metadata.json")
|
||||
if os.path.exists(source_metadata):
|
||||
target_metadata = os.path.join(target_path, f"{base_name}.metadata.json")
|
||||
shutil.move(source_metadata, target_metadata)
|
||||
lora_data = await self._update_metadata_paths(target_metadata, target_lora)
|
||||
|
||||
# Move preview file if exists
|
||||
preview_extensions = ['.preview.png', '.preview.jpeg', '.preview.jpg', '.preview.mp4',
|
||||
'.png', '.jpeg', '.jpg', '.mp4']
|
||||
for ext in preview_extensions:
|
||||
source_preview = os.path.join(source_dir, f"{base_name}{ext}")
|
||||
if os.path.exists(source_preview):
|
||||
target_preview = os.path.join(target_path, f"{base_name}{ext}")
|
||||
shutil.move(source_preview, target_preview)
|
||||
break
|
||||
|
||||
# Update cache folders
|
||||
cache = await self.get_cached_data()
|
||||
cache.raw_data = [
|
||||
item for item in cache.raw_data
|
||||
if item['file_path'] != source_path
|
||||
]
|
||||
if lora_data:
|
||||
cache.raw_data.append(lora_data)
|
||||
all_folders = set(cache.folders)
|
||||
all_folders.add(lora_data['folder'])
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# Resort cache
|
||||
await cache.resort()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
|
||||
"""Update file paths in metadata file"""
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
# Update file_path
|
||||
metadata['file_path'] = lora_path.replace(os.sep, '/')
|
||||
|
||||
# Update preview_url if exists
|
||||
if 'preview_url' in metadata:
|
||||
preview_dir = os.path.dirname(lora_path)
|
||||
preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0]
|
||||
preview_ext = os.path.splitext(metadata['preview_url'])[1]
|
||||
new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}")
|
||||
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
|
||||
|
||||
# Save updated metadata
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
|
||||
metadata['folder'] = self._calculate_folder(lora_path)
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
|
||||
|
||||
|
||||
@@ -42,8 +42,7 @@ export class LoraContextMenu {
|
||||
this.currentCard.querySelector('.fa-trash')?.click();
|
||||
break;
|
||||
case 'move':
|
||||
// To be implemented
|
||||
console.log('Move to folder feature coming soon');
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SearchManager } from './utils/search.js';
|
||||
import { DownloadManager } from './managers/DownloadManager.js';
|
||||
import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js';
|
||||
import { LoraContextMenu } from './components/ContextMenu.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
|
||||
// Export all functions that need global access
|
||||
window.loadMoreLoras = loadMoreLoras;
|
||||
@@ -43,6 +44,7 @@ window.showToast = showToast
|
||||
window.toggleFolderTags = toggleFolderTags;
|
||||
window.settingsManager = new SettingsManager();
|
||||
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
|
||||
window.moveManager = moveManager;
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -44,6 +44,15 @@ export class ModalManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Add moveModal registration
|
||||
this.registerModal('moveModal', {
|
||||
element: document.getElementById('moveModal'),
|
||||
onClose: () => {
|
||||
this.getModal('moveModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
130
static/js/managers/MoveManager.js
Normal file
130
static/js/managers/MoveManager.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { resetAndReload } from '../api/loraApi.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
|
||||
class MoveManager {
|
||||
constructor() {
|
||||
this.currentFilePath = null;
|
||||
this.modal = document.getElementById('moveModal');
|
||||
this.loraRootSelect = document.getElementById('moveLoraRoot');
|
||||
this.folderBrowser = document.getElementById('moveFolderBrowser');
|
||||
this.newFolderInput = document.getElementById('moveNewFolder');
|
||||
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
// 初始化LoRA根目录选择器
|
||||
this.loraRootSelect.addEventListener('change', () => this.updatePathPreview());
|
||||
|
||||
// 文件夹选择事件
|
||||
this.folderBrowser.addEventListener('click', (e) => {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (!folderItem) return;
|
||||
|
||||
// 取消其他选中状态
|
||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// 设置当前选中状态
|
||||
folderItem.classList.add('selected');
|
||||
this.updatePathPreview();
|
||||
});
|
||||
|
||||
// 新文件夹输入事件
|
||||
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
|
||||
}
|
||||
|
||||
async showMoveModal(filePath) {
|
||||
this.currentFilePath = filePath;
|
||||
|
||||
// 清除之前的选择
|
||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
this.newFolderInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lora-roots');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch LoRA roots');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No LoRA roots found');
|
||||
}
|
||||
|
||||
// 填充LoRA根目录选择器
|
||||
this.loraRootSelect.innerHTML = data.roots.map(root =>
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
this.updatePathPreview();
|
||||
modalManager.showModal('moveModal');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching LoRA roots:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
updatePathPreview() {
|
||||
const selectedRoot = this.loraRootSelect.value;
|
||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
||||
const newFolder = this.newFolderInput.value.trim();
|
||||
|
||||
let targetPath = selectedRoot;
|
||||
if (selectedFolder) {
|
||||
targetPath = `${targetPath}/${selectedFolder}`;
|
||||
}
|
||||
if (newFolder) {
|
||||
targetPath = `${targetPath}/${newFolder}`;
|
||||
}
|
||||
|
||||
this.pathDisplay.querySelector('.path-text').textContent = targetPath;
|
||||
}
|
||||
|
||||
async moveModel() {
|
||||
const selectedRoot = this.loraRootSelect.value;
|
||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
||||
const newFolder = this.newFolderInput.value.trim();
|
||||
|
||||
let targetPath = selectedRoot;
|
||||
if (selectedFolder) {
|
||||
targetPath = `${targetPath}/${selectedFolder}`;
|
||||
}
|
||||
if (newFolder) {
|
||||
targetPath = `${targetPath}/${newFolder}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/move_model', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: this.currentFilePath,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to move model');
|
||||
}
|
||||
|
||||
showToast('Model moved successfully', 'success');
|
||||
modalManager.closeModal('moveModal');
|
||||
await resetAndReload(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error moving model:', error);
|
||||
showToast('Failed to move model: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const moveManager = new MoveManager();
|
||||
@@ -83,6 +83,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move Model Modal -->
|
||||
<div id="moveModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<button class="close" onclick="modalManager.closeModal('moveModal')">×</button>
|
||||
<h2>Move Model</h2>
|
||||
<div class="location-selection">
|
||||
<div class="path-preview">
|
||||
<label>Target Location Preview:</label>
|
||||
<div class="path-display" id="moveTargetPathDisplay">
|
||||
<span class="path-text">Select a LoRA root directory</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Select LoRA Root:</label>
|
||||
<select id="moveLoraRoot"></select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Target Folder:</label>
|
||||
<div class="folder-browser" id="moveFolderBrowser">
|
||||
{% for folder in folders %}
|
||||
{% if folder %}
|
||||
<div class="folder-item" data-folder="{{ folder }}">
|
||||
{{ folder }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="moveNewFolder">New Folder (optional):</label>
|
||||
<input type="text" id="moveNewFolder" placeholder="Enter folder name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="modalManager.closeModal('moveModal')">Cancel</button>
|
||||
<button class="primary-btn" onclick="moveManager.moveModel()">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settingsModal" class="modal">
|
||||
<div class="modal-content settings-modal">
|
||||
|
||||
Reference in New Issue
Block a user