Implement move to folder

This commit is contained in:
Will Miao
2025-02-18 19:01:02 +08:00
parent bbc5aea08c
commit 52b01d1bce
8 changed files with 333 additions and 14 deletions

View File

@@ -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"""

View File

@@ -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()
# 获取当前运行的事件循环

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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', () => {

View File

@@ -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;
}

View 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();

View File

@@ -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')">&times;</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">