Compare commits

..

32 Commits

Author SHA1 Message Date
Will Miao
c23ab04d90 chore(release): update version to 0.9.2 and add release notes for bulk auto-organization feature 2025-09-06 14:38:00 +08:00
Will Miao
d50dde6cf6 refactor(i18n): remove legacy migration summary and transition to JSON format 2025-09-06 10:07:43 +08:00
Will Miao
fcb1fb39be feat(controls): add toggleBulkMode functionality for Checkpoints and Embeddings pages 2025-09-06 08:15:18 +08:00
Will Miao
b0ef74f802 feat(LoraManager): add example images cleanup functionality to remove invalid or empty folders, see #402 2025-09-06 07:59:33 +08:00
Will Miao
f332aef41d fix(BulkManager): prevent initialization on recipes page to avoid unnecessary processing 2025-09-05 22:45:23 +08:00
Will Miao
1f91a3da8e fix(BulkManager): streamline cleanupBulkBaseModelModal to clear base model select options 2025-09-05 21:00:54 +08:00
Will Miao
16840c321d feat(api): enhance fetchModelDescription to improve error handling and response parsing 2025-09-05 20:57:36 +08:00
Will Miao
c109e392ad feat(auto-organize): add auto-organize functionality for selected models and update context menu 2025-09-05 20:51:30 +08:00
pixelpaws
5e69671366 Merge pull request #398 from gaoqi125/gaoqi125-patch-1
Create wanvideo_lora_select_from_text.py
2025-09-05 19:55:40 +08:00
Will Miao
52d23d9b75 feat(constants): update model tags to include 'realistic', 'anime', 'toon', and 'furry' 2025-09-05 19:53:29 +08:00
Will Miao
4c4e6d7a7b feat(release-notes): update version to 0.9.1 and enhance bulk operations documentation 2025-09-05 18:15:08 +08:00
Will Miao
03b6e78705 feat(locales): add bulk base model functionality in multiple languages and update toast messages 2025-09-05 18:00:21 +08:00
pixelpaws
24c01141d7 Merge pull request #400 from willmiao/bulk-menu
Bulk menu
2025-09-05 17:44:40 +08:00
Will Miao
6dc2811af4 feat(bulk-modal): refactor bulk base model modal for improved UI and functionality, fixes 352 2025-09-05 17:36:54 +08:00
Will Miao
e6425dce32 feat(bulk-manager): enhance bulk mode handling by skipping actions when a modal is open 2025-09-05 17:07:57 +08:00
Will Miao
95e2ff5f1e Implement centralized event management system with priority handling and state tracking
- Enhanced EventManager class to support priority-based event handling, conditional execution, and automatic cleanup.
- Integrated event management into BulkManager for global keyboard shortcuts and marquee selection events.
- Migrated mouse tracking and node selector events to UIHelpers for better coordination.
- Established global event handlers for context menu interactions and modal state management.
- Added comprehensive documentation for event management implementation and usage.
- Implemented initialization logic for event management, including error handling and cleanup on page unload.
2025-09-05 16:56:26 +08:00
Will Miao
92ac487128 feat(bulk-base-model): implement bulk base model setting functionality with UI and context menu integration 2025-09-05 14:07:03 +08:00
Will Miao
3250fa89cb feat(selection): implement marquee selection for bulk operations 2025-09-05 11:24:48 +08:00
Will Miao
7475de366b feat(context-menu): enhance bulk workflow options with append and replace actions 2025-09-05 11:24:48 +08:00
Will Miao
affb507b37 feat(sync): enhance translation key synchronization to remove obsolete keys 2025-09-05 11:24:48 +08:00
pixelpaws
3320b80150 Merge pull request #399 from willmiao/bulk-menu
Bulk context menu
2025-09-05 09:31:48 +08:00
Will Miao
fb2b69b787 feat(tags): refactor preset tags to constants for better maintainability 2025-09-05 09:27:45 +08:00
Will Miao
29a05f6533 Move test_i18n.py to scripts folder 2025-09-05 08:48:20 +08:00
Will Miao
9fa3fac973 feat(locales): add bulk tag management translations for multiple languages 2025-09-05 08:43:01 +08:00
Will Miao
904b0d104a feat(sync): add translation key synchronization script for locale management 2025-09-05 08:35:20 +08:00
Will Miao
1d31dae110 feat(tags): implement bulk tag addition and replacement functionality 2025-09-05 07:18:24 +08:00
Will Miao
476ecb7423 fix(banner): ensure href attribute defaults to '#' for actions without a URL 2025-09-04 22:09:15 +08:00
Will Miao
4eb67cf6da feat(bulk-tags): add bulk tag management modal and context menu integration 2025-09-04 22:08:55 +08:00
Will Miao
a5a9f7ed83 fix(banner): ensure href attribute defaults to '#' for actions without a URL 2025-09-04 22:07:07 +08:00
Will Miao
c0b029e228 feat(context-menu): refactor context menu initialization and coordination for improved bulk operations 2025-09-04 16:34:05 +08:00
Will Miao
9bebcc9a4b feat(bulk): implement bulk context menu for model operations and remove bulk operations panel 2025-09-04 15:24:54 +08:00
gaoqi125
8c6311355d Create wanvideo_lora_select_from_text.py
Stacking new LoRA nodes via lora_syntax text input
2025-09-02 17:18:48 +08:00
55 changed files with 3663 additions and 1209 deletions

View File

@@ -34,6 +34,14 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes
### v0.9.2
* **Bulk Auto-Organization Action** - Added a new bulk auto-organization feature. You can now select multiple models and automatically organize them according to your current path template settings for streamlined management.
* **Bug Fixes** - Addressed several bugs to improve stability and reliability.
### v0.9.1
* **Enhanced Bulk Operations** - Improved bulk operations with Marquee Selection and a bulk operation context menu, providing a more intuitive, desktop-application-like user experience.
* **New Bulk Actions** - Added bulk operations for adding tags and setting base models to multiple models simultaneously.
### v0.9.0
* **UI Overhaul for Enhanced Navigation** - Replaced the top flat folder tags with a new folder sidebar and breadcrumb navigation system for a more intuitive folder browsing and selection experience.
* **Dual-Mode Folder Sidebar** - The new folder sidebar offers two display modes: 'List Mode,' which mirrors the classic folder view, and 'Tree Mode,' which presents a hierarchical folder structure for effortless navigation through nested directories.
@@ -69,61 +77,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
### v0.8.25
* **LoRA List Reordering**
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
- Keyboard Shortcuts:
- Arrow keys: Navigate between LoRAs
- Ctrl/Cmd + Arrow: Move selected LoRA up/down
- Ctrl/Cmd + Home/End: Move selected LoRA to top/bottom
- Delete/Backspace: Remove selected LoRA
- Context Menu: Right-click for quick actions like Move Up, Move Down, Move to Top, Move to Bottom.
* **Bulk Operations for Checkpoints & Embeddings**
- Bulk Mode: Select multiple checkpoints or embeddings for batch actions.
- Bulk Refresh: Update Civitai metadata for selected models.
- Bulk Delete: Remove multiple models at once.
- Bulk Move (Embeddings): Move selected embeddings to a different folder.
* **New Setting: Auto Download Example Images**
- Automatically fetch example images for models missing previews (requires download location to be set). Enabled by default.
* **General Improvements**
- Various user experience enhancements and stability fixes.
### v0.8.22
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
* **Custom Download Path Templates & Base Model Mapping** - Implemented UI settings for configuring download path templates and base model path mappings, allowing customized model organization and storage location when downloading models via LM Civitai Extension.
* **LM Civitai Extension Enhancements** - Improved concurrent download performance and stability, with new support for canceling active downloads directly from the extension interface.
* **Update Feature** - Added update functionality, allowing users to update LoRA Manager to the latest release version directly from the LoRA Manager UI.
* **Bulk Operations: Refresh All** - Added bulk refresh functionality, allowing users to update Civitai metadata across multiple LoRAs.
### v0.8.20
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding
* **WanVideo Integration** - Introduced WanVideo Lora Select (LoraManager) node compatible with ComfyUI-WanVideoWrapper for streamlined lora usage in video workflows, including a template workflow to help you get started quickly
### v0.8.19
* **Analytics Dashboard** - Added new Statistics page providing comprehensive visual analysis of model collection and usage patterns for better library insights
* **Target Node Selection** - Enhanced workflow integration with intelligent target choosing when sending LoRAs/recipes to workflows with multiple loader/stacker nodes; a visual selector now appears showing node color, type, ID, and title for precise targeting
* **Enhanced NSFW Controls** - Added support for setting NSFW levels on recipes with automatic content blurring based on user preferences
* **Customizable Card Display** - New display settings allowing users to choose whether card information and action buttons are always visible or only revealed on hover
* **Expanded Compatibility** - Added support for efficiency-nodes-comfyui in Save Recipe and Save Image nodes, plus fixed compatibility with ComfyUI_Custom_Nodes_AlekPet
### v0.8.18
* **Custom Example Images** - Added ability to import your own example images for LoRAs and checkpoints with automatic metadata extraction from embedded information
* **Enhanced Example Management** - New action buttons to set specific examples as previews or delete custom examples
* **Improved Duplicate Detection** - Enhanced "Find Duplicates" with hash verification feature to eliminate false positives when identifying duplicate models
* **Tag Management** - Added tag editing functionality allowing users to customize and manage model tags
* **Advanced Selection Controls** - Implemented Ctrl+A shortcut for quickly selecting all filtered LoRAs, automatically entering bulk mode when needed
* **Note**: Cache file functionality temporarily disabled pending rework
### v0.8.17
* **Duplicate Model Detection** - Added "Find Duplicates" functionality for LoRAs and checkpoints using model file hash detection, enabling convenient viewing and batch deletion of duplicate models
* **Enhanced URL Recipe Imports** - Optimized import recipe via URL functionality using CivitAI API calls instead of web scraping, now supporting all rated images (including NSFW) for recipe imports
* **Improved TriggerWord Control** - Enhanced TriggerWord Toggle node with new default_active switch to set the initial state (active/inactive) when trigger words are added
* **Centralized Example Management** - Added "Migrate Existing Example Images" feature to consolidate downloaded example images from model folders into central storage with customizable naming patterns
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
[View Update History](./update_logs.md)
---

View File

@@ -0,0 +1,182 @@
# Event Management Implementation Summary
## What Has Been Implemented
### 1. Enhanced EventManager Class
- **Location**: `static/js/utils/EventManager.js`
- **Features**:
- Priority-based event handling
- Conditional execution based on application state
- Element filtering (target/exclude selectors)
- Mouse button filtering
- Automatic cleanup with cleanup functions
- State tracking for app modes
- Error handling for event handlers
### 2. BulkManager Integration
- **Location**: `static/js/managers/BulkManager.js`
- **Migrated Events**:
- Global keyboard shortcuts (Ctrl+A, Escape, B key)
- Marquee selection events (mousedown, mousemove, mouseup, contextmenu)
- State synchronization with EventManager
- **Benefits**:
- Centralized priority handling
- Conditional execution based on modal state
- Better coordination with other components
### 3. UIHelpers Integration
- **Location**: `static/js/utils/uiHelpers.js`
- **Migrated Events**:
- Mouse position tracking for node selector positioning
- Node selector click events (outside clicks and selection)
- State management for node selector
- **Benefits**:
- Reduced direct DOM listeners
- Coordinated state tracking
- Better cleanup
### 4. ModelCard Integration
- **Location**: `static/js/components/shared/ModelCard.js`
- **Migrated Events**:
- Model card click delegation
- Action button handling (star, globe, copy, etc.)
- Better return value handling for event propagation
- **Benefits**:
- Single event listener for all model cards
- Priority-based execution
- Better event flow control
### 5. Documentation and Initialization
- **EventManagerDocs.md**: Comprehensive documentation
- **eventManagementInit.js**: Initialization and global handlers
- **Features**:
- Global escape key handling
- Modal state synchronization
- Error handling
- Analytics integration points
- Cleanup on page unload
## Application States Tracked
1. **bulkMode**: When bulk selection mode is active
2. **marqueeActive**: When marquee selection is in progress
3. **modalOpen**: When any modal dialog is open
4. **nodeSelectorActive**: When node selector popup is visible
## Priority Levels Used
- **250+**: Critical system events (escape keys)
- **200+**: High priority system events (modal close)
- **100-199**: Application-level shortcuts (bulk operations)
- **80-99**: UI interactions (marquee selection)
- **60-79**: Component interactions (model cards)
- **10-49**: Tracking and monitoring
- **1-9**: Analytics and low-priority tasks
## Event Flow Examples
### Bulk Mode Toggle (B key)
1. **Priority 100**: BulkManager keyboard handler catches 'b' key
2. Toggles bulk mode state
3. Updates EventManager state
4. Updates UI accordingly
5. Stops propagation (returns true)
### Marquee Selection
1. **Priority 80**: BulkManager mousedown handler (only in .models-container, excluding cards/buttons)
2. Starts marquee selection
3. **Priority 90**: BulkManager mousemove handler (only when marquee active)
4. Updates selection rectangle
5. **Priority 90**: BulkManager mouseup handler ends selection
### Model Card Click
1. **Priority 60**: ModelCard delegation handler checks for specific elements
2. If action button: handles action and stops propagation
3. If general card click: continues to other handlers
4. Bulk selection may also handle the event if in bulk mode
## Remaining Event Listeners (Not Yet Migrated)
### High Priority for Migration
1. **SearchManager keyboard events** - Global search shortcuts
2. **ModalManager escape handling** - Already integrated with initialization
3. **Scroll-based events** - Back to top, virtual scrolling
4. **Resize events** - Panel positioning, responsive layouts
### Medium Priority
1. **Form input events** - Tag inputs, settings forms
2. **Component-specific events** - Recipe modal, showcase view
3. **Sidebar events** - Resize handling, toggle events
### Low Priority (Can Remain As-Is)
1. **VirtualScroller events** - Performance-critical, specialized
2. **Component lifecycle events** - Modal open/close callbacks
3. **One-time setup events** - Theme initialization, etc.
## Benefits Achieved
### Performance Improvements
- **Reduced DOM listeners**: From ~15+ individual listeners to ~5 coordinated handlers
- **Conditional execution**: Handlers only run when conditions are met
- **Priority ordering**: Important events handled first
- **Better memory management**: Automatic cleanup prevents leaks
### Coordination Improvements
- **State synchronization**: All components aware of app state
- **Event flow control**: Proper propagation stopping
- **Conflict resolution**: Priority system prevents conflicts
- **Debugging**: Centralized event handling for easier debugging
### Code Quality Improvements
- **Consistent patterns**: All event handling follows same patterns
- **Better separation of concerns**: Event logic separated from business logic
- **Error handling**: Centralized error catching and reporting
- **Documentation**: Clear patterns for future development
## Next Steps (Recommendations)
### 1. Migrate Search Events
```javascript
// In SearchManager.js
eventManager.addHandler('keydown', 'search-shortcuts', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
this.focusSearchInput();
return true;
}
}, { priority: 120 });
```
### 2. Integrate Resize Events
```javascript
// Create ResizeManager
eventManager.addHandler('resize', 'layout-resize', debounce((e) => {
this.updateLayoutDimensions();
}, 250), { priority: 50 });
```
### 3. Add Debug Mode
```javascript
// In EventManager.js
if (window.DEBUG_EVENTS) {
console.log(`Event ${eventType} handled by ${source} (priority: ${priority})`);
}
```
### 4. Create Event Analytics
```javascript
// Track event patterns for optimization
eventManager.addHandler('*', 'analytics', (e) => {
this.trackEventUsage(e.type, performance.now());
}, { priority: 1 });
```
## Testing Recommendations
1. **Verify bulk mode interactions** work correctly
2. **Test marquee selection** in various scenarios
3. **Check modal state synchronization**
4. **Verify node selector** positioning and cleanup
5. **Test keyboard shortcuts** don't conflict
6. **Verify proper cleanup** when components are destroyed
The centralized event management system provides a solid foundation for coordinated, efficient event handling across the application while maintaining good performance and code organization.

301
docs/EventManagerDocs.md Normal file
View File

@@ -0,0 +1,301 @@
# Centralized Event Management System
This document describes the centralized event management system that coordinates event handling across the ComfyUI LoRA Manager application.
## Overview
The `EventManager` class provides a centralized way to handle DOM events with priority-based execution, conditional execution based on application state, and proper cleanup mechanisms.
## Features
- **Priority-based execution**: Handlers with higher priority run first
- **Conditional execution**: Handlers can be executed based on application state
- **Element filtering**: Handlers can target specific elements or exclude others
- **Automatic cleanup**: Cleanup functions are called when handlers are removed
- **State tracking**: Tracks application states like bulk mode, modal open, etc.
## Basic Usage
### Importing
```javascript
import { eventManager } from './EventManager.js';
```
### Adding Event Handlers
```javascript
eventManager.addHandler('click', 'myComponent', (event) => {
console.log('Button clicked!');
return true; // Stop propagation to other handlers
}, {
priority: 100,
targetSelector: '.my-button',
skipWhenModalOpen: true
});
```
### Removing Event Handlers
```javascript
// Remove specific handler
eventManager.removeHandler('click', 'myComponent');
// Remove all handlers for a component
eventManager.removeAllHandlersForSource('myComponent');
```
### Updating Application State
```javascript
// Set state
eventManager.setState('bulkMode', true);
eventManager.setState('modalOpen', true);
// Get state
const isBulkMode = eventManager.getState('bulkMode');
```
## Available States
- `bulkMode`: Whether bulk selection mode is active
- `marqueeActive`: Whether marquee selection is in progress
- `modalOpen`: Whether any modal is currently open
- `nodeSelectorActive`: Whether the node selector popup is active
## Handler Options
### Priority
Higher numbers = higher priority. Handlers run in descending priority order.
```javascript
{
priority: 100 // High priority
}
```
### Conditional Execution
```javascript
{
onlyInBulkMode: true, // Only run when bulk mode is active
onlyWhenMarqueeActive: true, // Only run when marquee selection is active
skipWhenModalOpen: true, // Skip when any modal is open
skipWhenNodeSelectorActive: true, // Skip when node selector is active
onlyWhenNodeSelectorActive: true // Only run when node selector is active
}
```
### Element Filtering
```javascript
{
targetSelector: '.model-card', // Only handle events on matching elements
excludeSelector: 'button, input', // Exclude events from these elements
button: 0 // Only handle specific mouse button (0=left, 1=middle, 2=right)
}
```
### Cleanup Functions
```javascript
{
cleanup: () => {
// Custom cleanup logic
console.log('Handler cleaned up');
}
}
```
## Integration Examples
### BulkManager Integration
```javascript
class BulkManager {
registerEventHandlers() {
// High priority keyboard shortcuts
eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
return this.handleGlobalKeyboard(e);
}, {
priority: 100,
skipWhenModalOpen: true
});
// Marquee selection
eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
return this.handleMarqueeStart(e);
}, {
priority: 80,
skipWhenModalOpen: true,
targetSelector: '.models-container',
excludeSelector: '.model-card, button, input',
button: 0
});
}
cleanup() {
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
}
}
```
### Modal Integration
```javascript
class ModalManager {
showModal(modalId) {
// Update state when modal opens
eventManager.setState('modalOpen', true);
this.displayModal(modalId);
}
closeModal(modalId) {
// Update state when modal closes
eventManager.setState('modalOpen', false);
this.hideModal(modalId);
}
}
```
### Component Event Delegation
```javascript
export function setupComponentEvents() {
eventManager.addHandler('click', 'myComponent-actions', (event) => {
const button = event.target.closest('.action-button');
if (!button) return false;
this.handleAction(button.dataset.action);
return true; // Stop propagation
}, {
priority: 60,
targetSelector: '.component-container'
});
}
```
## Best Practices
### 1. Use Descriptive Source Names
Use the format `componentName-purposeDescription`:
```javascript
// Good
'bulkManager-marqueeSelection'
'nodeSelector-clickOutside'
'modelCard-delegation'
// Avoid
'bulk'
'click'
'handler1'
```
### 2. Set Appropriate Priorities
- 200+: Critical system events (escape keys, critical modals)
- 100-199: High priority application events (keyboard shortcuts)
- 50-99: Normal UI interactions (buttons, cards)
- 1-49: Low priority events (tracking, analytics)
### 3. Use Conditional Execution
Instead of checking state inside handlers, use options:
```javascript
// Good
eventManager.addHandler('click', 'bulk-action', handler, {
onlyInBulkMode: true
});
// Avoid
eventManager.addHandler('click', 'bulk-action', (e) => {
if (!state.bulkMode) return;
// handler logic
});
```
### 4. Clean Up Properly
Always clean up handlers when components are destroyed:
```javascript
class MyComponent {
constructor() {
this.registerEvents();
}
destroy() {
eventManager.removeAllHandlersForSource('myComponent');
}
}
```
### 5. Return Values Matter
- Return `true` to stop event propagation to other handlers
- Return `false` or `undefined` to continue with other handlers
## Migration Guide
### From Direct Event Listeners
**Before:**
```javascript
document.addEventListener('click', (e) => {
if (e.target.closest('.my-button')) {
this.handleClick(e);
}
});
```
**After:**
```javascript
eventManager.addHandler('click', 'myComponent-button', (e) => {
this.handleClick(e);
}, {
targetSelector: '.my-button'
});
```
### From Event Delegation
**Before:**
```javascript
container.addEventListener('click', (e) => {
const card = e.target.closest('.model-card');
if (!card) return;
if (e.target.closest('.action-btn')) {
this.handleAction(e);
}
});
```
**After:**
```javascript
eventManager.addHandler('click', 'container-actions', (e) => {
const card = e.target.closest('.model-card');
if (!card) return false;
if (e.target.closest('.action-btn')) {
this.handleAction(e);
return true;
}
}, {
targetSelector: '.container'
});
```
## Performance Benefits
1. **Reduced DOM listeners**: Single listener per event type instead of multiple
2. **Conditional execution**: Handlers only run when conditions are met
3. **Priority ordering**: Important handlers run first, avoiding unnecessary work
4. **Automatic cleanup**: Prevents memory leaks from orphaned listeners
5. **Centralized debugging**: All event handling flows through one system
## Debugging
Enable debug logging to trace event handling:
```javascript
// Add to EventManager.js for debugging
console.log(`Handling ${eventType} event with ${handlers.length} handlers`);
```
The event manager provides a foundation for coordinated, efficient event handling across the entire application.

View File

@@ -1,170 +0,0 @@
# i18n System Migration Complete
## 概要 (Summary)
成功完成了从JavaScript ES6模块到JSON格式的国际化系统迁移包含完整的多语言翻译和代码更新。
Successfully completed the migration from JavaScript ES6 modules to JSON format for the internationalization system, including complete multilingual translations and code updates.
## 完成的工作 (Completed Work)
### 1. 文件结构重组 (File Structure Reorganization)
- **新建目录**: `/locales/` - 集中存放所有JSON翻译文件
- **移除目录**: `/static/js/i18n/locales/` - 删除了旧的JavaScript文件
### 2. 格式转换 (Format Conversion)
- **转换前**: ES6模块格式 (`export const en = { ... }`)
- **转换后**: 标准JSON格式 (`{ ... }`)
- **支持语言**: 9种语言完全转换
- English (en)
- 简体中文 (zh-CN)
- 繁體中文 (zh-TW)
- 日本語 (ja)
- Русский (ru)
- Deutsch (de)
- Français (fr)
- Español (es)
- 한국어 (ko)
### 3. 翻译完善 (Translation Completion)
- **翻译条目**: 每种语言386个翻译键值对
- **覆盖范围**: 完整覆盖所有UI元素
- **质量保证**: 所有翻译键在各语言间保持一致
### 4. JavaScript代码更新 (JavaScript Code Updates)
#### 主要修改文件: `static/js/i18n/index.js`
```javascript
// 旧版本: 静态导入
import { en } from './locales/en.js';
// 新版本: 动态JSON加载
async loadLocale(locale) {
const response = await fetch(`/locales/${locale}.json`);
return await response.json();
}
```
#### 核心功能更新:
- **构造函数**: 从静态导入改为配置驱动
- **语言加载**: 异步JSON获取机制
- **初始化**: 支持Promise-based的异步初始化
- **错误处理**: 增强的回退机制到英语
- **向后兼容**: 保持现有API接口不变
### 5. Python服务端更新 (Python Server-side Updates)
#### 修改文件: `py/services/server_i18n.py`
```python
# 旧版本: 解析JavaScript文件
def _load_locale_file(self, path, filename, locale_code):
# 复杂的JS到JSON转换逻辑
# 新版本: 直接加载JSON
def _load_locale_file(self, path, filename, locale_code):
with open(file_path, 'r', encoding='utf-8') as f:
translations = json.load(f)
```
#### 路径更新:
- **旧路径**: `static/js/i18n/locales/*.js`
- **新路径**: `locales/*.json`
### 6. 服务器路由配置 (Server Route Configuration)
#### 修改文件: `standalone.py`
```python
# 新增静态路由服务JSON文件
app.router.add_static('/locales', locales_path)
```
## 技术架构 (Technical Architecture)
### 前端 (Frontend)
```
Browser → JavaScript i18n Manager → fetch('/locales/{lang}.json') → JSON Response
```
### 后端 (Backend)
```
Python Server → ServerI18nManager → Direct JSON loading → Template Rendering
```
### 文件组织 (File Organization)
```
ComfyUI-Lora-Manager/
├── locales/ # 新的JSON翻译文件目录
│ ├── en.json # 英语翻译 (基准)
│ ├── zh-CN.json # 简体中文翻译
│ ├── zh-TW.json # 繁体中文翻译
│ ├── ja.json # 日语翻译
│ ├── ru.json # 俄语翻译
│ ├── de.json # 德语翻译
│ ├── fr.json # 法语翻译
│ ├── es.json # 西班牙语翻译
│ └── ko.json # 韩语翻译
├── static/js/i18n/
│ └── index.js # 更新的JavaScript i18n管理器
└── py/services/
└── server_i18n.py # 更新的Python服务端i18n
```
## 测试验证 (Testing & Validation)
### 测试脚本: `test_i18n.py`
```bash
🚀 Testing updated i18n system...
✅ All JSON locale files are valid (9 languages)
✅ Server-side i18n system working correctly
✅ All languages have complete translations (386 keys each)
🎉 All tests passed!
```
### 验证内容:
1. **JSON文件完整性**: 所有文件格式正确,语法有效
2. **翻译完整性**: 各语言翻译键值一致,无缺失
3. **服务端功能**: Python i18n服务正常加载和翻译
4. **参数插值**: 动态参数替换功能正常
## 优势与改进 (Benefits & Improvements)
### 1. 维护性提升
- **简化格式**: JSON比JavaScript对象更易于编辑和维护
- **工具支持**: 更好的编辑器语法高亮和验证支持
- **版本控制**: 更清晰的diff显示便于追踪更改
### 2. 性能优化
- **按需加载**: 只加载当前所需语言,减少初始加载时间
- **缓存友好**: JSON文件可以被浏览器和CDN更好地缓存
- **压缩效率**: JSON格式压缩率通常更高
### 3. 开发体验
- **动态切换**: 支持运行时语言切换,无需重新加载页面
- **易于扩展**: 添加新语言只需增加JSON文件
- **调试友好**: 更容易定位翻译问题和缺失键
### 4. 部署便利
- **静态资源**: JSON文件可以作为静态资源部署
- **CDN支持**: 可以通过CDN分发翻译文件
- **版本管理**: 更容易管理不同版本的翻译
## 兼容性保证 (Compatibility Assurance)
- **API兼容**: 所有现有的JavaScript API保持不变
- **调用方式**: 现有代码无需修改即可工作
- **错误处理**: 增强的回退机制确保用户体验
- **性能**: 新系统性能与旧系统相当或更好
## 后续建议 (Future Recommendations)
1. **监控**: 部署后监控翻译加载性能和错误率
2. **优化**: 考虑实施翻译缓存策略以进一步提升性能
3. **扩展**: 可以考虑添加翻译管理界面,便于非技术人员更新翻译
4. **自动化**: 实施CI/CD流程自动验证翻译完整性
---
**迁移完成时间**: 2024年
**影响文件数量**: 21个文件 (9个新JSON + 2个JS更新 + 1个Python更新 + 1个服务器配置)
**翻译键总数**: 386个 × 9种语言 = 3,474个翻译条目
**测试状态**: ✅ 全部通过

View File

@@ -194,7 +194,7 @@
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {
"default": "Standard",
"medium": "Mittel",
"medium": "Mittel",
"compact": "Kompakt"
},
"displayDensityHelp": "Wählen Sie, wie viele Karten pro Zeile angezeigt werden sollen:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Flache Struktur",
"byBaseModel": "Nach Basis-Modell",
"byAuthor": "Nach Autor",
"byAuthor": "Nach Autor",
"byFirstTag": "Nach erstem Tag",
"baseModelFirstTag": "Basis-Modell + Erster Tag",
"baseModelAuthor": "Basis-Modell + Autor",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Benutzerdefinierte Vorlage eingeben (z.B. {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Basis-Modell-Pfad-Zuordnungen",
@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "{count} ausgewählt",
"selectedSuffix": "ausgewählt",
"viewSelected": "Klicken Sie, um ausgewählte Elemente anzuzeigen",
"sendToWorkflow": "An Workflow senden",
"copyAll": "Alle kopieren",
"refreshAll": "Alle aktualisieren",
"moveAll": "Alle verschieben",
"deleteAll": "Alle löschen",
"clear": "Leeren"
"viewSelected": "Auswahl anzeigen",
"addTags": "Allen Tags hinzufügen",
"setBaseModel": "Basis-Modell für alle festlegen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren",
"deleteAll": "Alle Modelle löschen",
"clear": "Auswahl löschen",
"autoOrganizeProgress": {
"initializing": "Automatische Organisation wird initialisiert...",
"starting": "Automatische Organisation für {type} wird gestartet...",
"processing": "Verarbeitung ({processed}/{total}) {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen",
"cleaning": "Leere Verzeichnisse werden bereinigt...",
"completed": "Abgeschlossen: {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen",
"complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}"
}
},
"contextMenu": {
"refreshMetadata": "Civitai-Daten aktualisieren",
@@ -572,6 +583,24 @@
"countMessage": "Modelle werden dauerhaft gelöscht.",
"action": "Alle löschen"
},
"bulkAddTags": {
"title": "Tags zu mehreren Modellen hinzufügen",
"description": "Tags hinzufügen zu",
"models": "Modelle",
"tagsToAdd": "Hinzugefügte Tags",
"placeholder": "Tag eingeben und Enter drücken...",
"appendTags": "Tags anhängen",
"replaceTags": "Tags ersetzen",
"saveChanges": "Änderungen speichern"
},
"bulkBaseModel": {
"title": "Basis-Modell für mehrere Modelle festlegen",
"description": "Basis-Modell festlegen für",
"models": "Modelle",
"selectBaseModel": "Basis-Modell auswählen",
"save": "Basis-Modell aktualisieren",
"cancel": "Abbrechen"
},
"exampleAccess": {
"title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.",
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen"
"downloadCompleted": "Download erfolgreich abgeschlossen",
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
"noModelsSelected": "Keine Modelle ausgewählt"
},
"recipes": {
"fetchFailed": "Fehler beim Abrufen der Rezepte: {message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "Fehler: {error}",
"deleteFailedGeneral": "Fehler beim Löschen der Modelle",
"selectedAdditional": "{count} zusätzliche {type}(s) ausgewählt",
"marqueeSelectionComplete": "{count} {type}(s) mit Rahmenauswahl ausgewählt",
"refreshMetadataFailed": "Fehler beim Aktualisieren der Metadaten",
"nameCannotBeEmpty": "Modellname darf nicht leer sein",
"nameUpdatedSuccessfully": "Modellname erfolgreich aktualisiert",
"nameUpdateFailed": "Fehler beim Aktualisieren des Modellnamens",
"baseModelUpdated": "Basis-Modell erfolgreich aktualisiert",
"baseModelUpdateFailed": "Fehler beim Aktualisieren des Basis-Modells",
"baseModelNotSelected": "Bitte ein Basis-Modell auswählen",
"bulkBaseModelUpdating": "Basis-Modell wird für {count} Modell(e) aktualisiert...",
"bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert",
"bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen",
"bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen",
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "Diese Gruppe wurde bereits verifiziert",
"verificationCompleteMismatch": "Verifikation abgeschlossen. {count} Datei(en) haben unterschiedliche tatsächliche Hashes.",
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}"
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
"noTagsToAdd": "Keine Tags zum Hinzufügen",
"tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt",
"tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
"tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
"tagsReplaceFailed": "Fehler beim Ersetzen von Tags für {count} Modell(e)",
"bulkTagsAddFailed": "Fehler beim Hinzufügen von Tags zu Modellen",
"bulkTagsReplaceFailed": "Fehler beim Ersetzen von Tags für Modelle"
},
"search": {
"atLeastOneOption": "Mindestens eine Suchoption muss ausgewählt werden"

View File

@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "{count} selected",
"selectedSuffix": "selected",
"viewSelected": "Click to view selected items",
"sendToWorkflow": "Send to Workflow",
"copyAll": "Copy All",
"refreshAll": "Refresh All",
"moveAll": "Move All",
"deleteAll": "Delete All",
"clear": "Clear"
"viewSelected": "View Selected",
"addTags": "Add Tags to All",
"setBaseModel": "Set Base Model for All",
"copyAll": "Copy All Syntax",
"refreshAll": "Refresh All Metadata",
"moveAll": "Move All to Folder",
"autoOrganize": "Auto-Organize Selected",
"deleteAll": "Delete All Models",
"clear": "Clear Selection",
"autoOrganizeProgress": {
"initializing": "Initializing auto-organize...",
"starting": "Starting auto-organize for {type}...",
"processing": "Processing ({processed}/{total}) - {success} moved, {skipped} skipped, {failures} failed",
"cleaning": "Cleaning up empty directories...",
"completed": "Completed: {success} moved, {skipped} skipped, {failures} failed",
"complete": "Auto-organize complete",
"error": "Error: {error}"
}
},
"contextMenu": {
"refreshMetadata": "Refresh Civitai Data",
@@ -572,6 +583,24 @@
"countMessage": "models will be permanently deleted.",
"action": "Delete All"
},
"bulkAddTags": {
"title": "Add Tags to Multiple Models",
"description": "Add tags to",
"models": "models",
"tagsToAdd": "Tags to Add",
"placeholder": "Enter tag and press Enter...",
"appendTags": "Append Tags",
"replaceTags": "Replace Tags",
"saveChanges": "Save changes"
},
"bulkBaseModel": {
"title": "Set Base Model for Multiple Models",
"description": "Set base model for",
"models": "models",
"selectBaseModel": "Select Base Model",
"save": "Update Base Model",
"cancel": "Cancel"
},
"exampleAccess": {
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
"pleaseSelectVersion": "Please select a version",
"versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully"
"downloadCompleted": "Download completed successfully",
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}",
"noModelsSelected": "No models selected"
},
"recipes": {
"fetchFailed": "Failed to fetch recipes: {message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "Error: {error}",
"deleteFailedGeneral": "Failed to delete models",
"selectedAdditional": "Selected {count} additional {type}(s)",
"marqueeSelectionComplete": "Selected {count} {type}(s) with marquee selection",
"refreshMetadataFailed": "Failed to refresh metadata",
"nameCannotBeEmpty": "Model name cannot be empty",
"nameUpdatedSuccessfully": "Model name updated successfully",
"nameUpdateFailed": "Failed to update model name",
"baseModelUpdated": "Base model updated successfully",
"baseModelUpdateFailed": "Failed to update base model",
"baseModelNotSelected": "Please select a base model",
"bulkBaseModelUpdating": "Updating base model for {count} model(s)...",
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
"invalidCharactersRemoved": "Invalid characters removed from filename",
"filenameCannotBeEmpty": "File name cannot be empty",
"renameFailed": "Failed to rename file: {message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "This group has already been verified",
"verificationCompleteMismatch": "Verification complete. {count} file(s) have different actual hashes.",
"verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.",
"verificationFailed": "Failed to verify hashes: {message}"
"verificationFailed": "Failed to verify hashes: {message}",
"noTagsToAdd": "No tags to add",
"tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)",
"tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)",
"tagsAddFailed": "Failed to add tags to {count} model(s)",
"tagsReplaceFailed": "Failed to replace tags for {count} model(s)",
"bulkTagsAddFailed": "Failed to add tags to models",
"bulkTagsReplaceFailed": "Failed to replace tags for models"
},
"search": {
"atLeastOneOption": "At least one search option must be selected"

View File

@@ -194,7 +194,7 @@
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {
"default": "Predeterminado",
"medium": "Medio",
"medium": "Medio",
"compact": "Compacto"
},
"displayDensityHelp": "Elige cuántas tarjetas mostrar por fila:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Estructura plana",
"byBaseModel": "Por modelo base",
"byAuthor": "Por autor",
"byAuthor": "Por autor",
"byFirstTag": "Por primera etiqueta",
"baseModelFirstTag": "Modelo base + primera etiqueta",
"baseModelAuthor": "Modelo base + autor",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Introduce plantilla personalizada (ej., {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Mapeos de rutas de modelo base",
@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "{count} seleccionados",
"selectedSuffix": "seleccionados",
"viewSelected": "Clic para ver elementos seleccionados",
"sendToWorkflow": "Enviar al flujo de trabajo",
"copyAll": "Copiar todo",
"refreshAll": "Actualizar todo",
"moveAll": "Mover todo",
"deleteAll": "Eliminar todo",
"clear": "Limpiar"
"viewSelected": "Ver seleccionados",
"addTags": "Añadir etiquetas a todos",
"setBaseModel": "Establecer modelo base para todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados",
"deleteAll": "Eliminar todos los modelos",
"clear": "Limpiar selección",
"autoOrganizeProgress": {
"initializing": "Inicializando auto-organización...",
"starting": "Iniciando auto-organización para {type}...",
"processing": "Procesando ({processed}/{total}) - {success} movidos, {skipped} omitidos, {failures} fallidos",
"cleaning": "Limpiando directorios vacíos...",
"completed": "Completado: {success} movidos, {skipped} omitidos, {failures} fallidos",
"complete": "Auto-organización completada",
"error": "Error: {error}"
}
},
"contextMenu": {
"refreshMetadata": "Actualizar datos de Civitai",
@@ -572,6 +583,24 @@
"countMessage": "modelos serán eliminados permanentemente.",
"action": "Eliminar todo"
},
"bulkAddTags": {
"title": "Añadir etiquetas a múltiples modelos",
"description": "Añadir etiquetas a",
"models": "modelos",
"tagsToAdd": "Etiquetas a añadir",
"placeholder": "Introduce una etiqueta y presiona Enter...",
"appendTags": "Añadir etiquetas",
"replaceTags": "Reemplazar etiquetas",
"saveChanges": "Guardar cambios"
},
"bulkBaseModel": {
"title": "Establecer modelo base para múltiples modelos",
"description": "Establecer modelo base para",
"models": "modelos",
"selectBaseModel": "Seleccionar modelo base",
"save": "Actualizar modelo base",
"cancel": "Cancelar"
},
"exampleAccess": {
"title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.",
"pleaseSelectVersion": "Por favor selecciona una versión",
"versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente"
"downloadCompleted": "Descarga completada exitosamente",
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}",
"noModelsSelected": "No hay modelos seleccionados"
},
"recipes": {
"fetchFailed": "Error al obtener recetas: {message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "Error: {error}",
"deleteFailedGeneral": "Error al eliminar modelos",
"selectedAdditional": "Seleccionados {count} {type}(s) adicionales",
"marqueeSelectionComplete": "Seleccionados {count} {type}(s) con selección de marco",
"refreshMetadataFailed": "Error al actualizar metadatos",
"nameCannotBeEmpty": "El nombre del modelo no puede estar vacío",
"nameUpdatedSuccessfully": "Nombre del modelo actualizado exitosamente",
"nameUpdateFailed": "Error al actualizar nombre del modelo",
"baseModelUpdated": "Modelo base actualizado exitosamente",
"baseModelUpdateFailed": "Error al actualizar modelo base",
"baseModelNotSelected": "Por favor selecciona un modelo base",
"bulkBaseModelUpdating": "Actualizando modelo base para {count} modelo(s)...",
"bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)",
"bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)",
"bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados",
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
"renameFailed": "Error al renombrar archivo: {message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "Este grupo ya ha sido verificado",
"verificationCompleteMismatch": "Verificación completa. {count} archivo(s) tienen hashes reales diferentes.",
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
"verificationFailed": "Error al verificar hashes: {message}"
"verificationFailed": "Error al verificar hashes: {message}",
"noTagsToAdd": "No hay etiquetas para añadir",
"tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)",
"tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
"tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
"tagsReplaceFailed": "Error al reemplazar etiquetas para {count} modelo(s)",
"bulkTagsAddFailed": "Error al añadir etiquetas a los modelos",
"bulkTagsReplaceFailed": "Error al reemplazar etiquetas para los modelos"
},
"search": {
"atLeastOneOption": "Al menos una opción de búsqueda debe estar seleccionada"

View File

@@ -194,7 +194,7 @@
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {
"default": "Par défaut",
"medium": "Moyen",
"medium": "Moyen",
"compact": "Compact"
},
"displayDensityHelp": "Choisissez combien de cartes afficher par ligne :",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Structure plate",
"byBaseModel": "Par modèle de base",
"byAuthor": "Par auteur",
"byAuthor": "Par auteur",
"byFirstTag": "Par premier tag",
"baseModelFirstTag": "Modèle de base + Premier tag",
"baseModelAuthor": "Modèle de base + Auteur",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Entrez un modèle personnalisé (ex: {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Mappages de chemin de modèle de base",
@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "{count} sélectionné(s)",
"selectedSuffix": "sélectionné(s)",
"viewSelected": "Cliquez pour voir les éléments sélectionnés",
"sendToWorkflow": "Envoyer vers le workflow",
"copyAll": "Tout copier",
"refreshAll": "Tout actualiser",
"moveAll": "Tout déplacer",
"deleteAll": "Tout supprimer",
"clear": "Effacer"
"viewSelected": "Voir la sélection",
"addTags": "Ajouter des tags à tous",
"setBaseModel": "Définir le modèle de base pour tous",
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"moveAll": "Déplacer tout vers un dossier",
"autoOrganize": "Auto-organiser la sélection",
"deleteAll": "Supprimer tous les modèles",
"clear": "Effacer la sélection",
"autoOrganizeProgress": {
"initializing": "Initialisation de l'auto-organisation...",
"starting": "Démarrage de l'auto-organisation pour {type}...",
"processing": "Traitement ({processed}/{total}) - {success} déplacés, {skipped} ignorés, {failures} échecs",
"cleaning": "Nettoyage des répertoires vides...",
"completed": "Terminé : {success} déplacés, {skipped} ignorés, {failures} échecs",
"complete": "Auto-organisation terminée",
"error": "Erreur : {error}"
}
},
"contextMenu": {
"refreshMetadata": "Actualiser les données Civitai",
@@ -572,6 +583,24 @@
"countMessage": "modèles seront définitivement supprimés.",
"action": "Tout supprimer"
},
"bulkAddTags": {
"title": "Ajouter des tags à plusieurs modèles",
"description": "Ajouter des tags à",
"models": "modèles",
"tagsToAdd": "Tags à ajouter",
"placeholder": "Entrez un tag et appuyez sur Entrée...",
"appendTags": "Ajouter les tags",
"replaceTags": "Remplacer les tags",
"saveChanges": "Enregistrer les modifications"
},
"bulkBaseModel": {
"title": "Définir le modèle de base pour plusieurs modèles",
"description": "Définir le modèle de base pour",
"models": "modèles",
"selectBaseModel": "Sélectionner le modèle de base",
"save": "Mettre à jour le modèle de base",
"cancel": "Annuler"
},
"exampleAccess": {
"title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.",
"pleaseSelectVersion": "Veuillez sélectionner une version",
"versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès"
"downloadCompleted": "Téléchargement terminé avec succès",
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
"noModelsSelected": "Aucun modèle sélectionné"
},
"recipes": {
"fetchFailed": "Échec de la récupération des recipes : {message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "Erreur : {error}",
"deleteFailedGeneral": "Échec de la suppression des modèles",
"selectedAdditional": "{count} {type}(s) supplémentaire(s) sélectionné(s)",
"marqueeSelectionComplete": "{count} {type}(s) sélectionné(s) avec la sélection par glisser-déposer",
"refreshMetadataFailed": "Échec de l'actualisation des métadonnées",
"nameCannotBeEmpty": "Le nom du modèle ne peut pas être vide",
"nameUpdatedSuccessfully": "Nom du modèle mis à jour avec succès",
"nameUpdateFailed": "Échec de la mise à jour du nom du modèle",
"baseModelUpdated": "Modèle de base mis à jour avec succès",
"baseModelUpdateFailed": "Échec de la mise à jour du modèle de base",
"baseModelNotSelected": "Veuillez sélectionner un modèle de base",
"bulkBaseModelUpdating": "Mise à jour du modèle de base pour {count} modèle(s)...",
"bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)",
"bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec",
"bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés",
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
"renameFailed": "Échec du renommage du fichier : {message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "Ce groupe a déjà été vérifié",
"verificationCompleteMismatch": "Vérification terminée. {count} fichier(s) ont des hash différents.",
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
"verificationFailed": "Échec de la vérification des hash : {message}"
"verificationFailed": "Échec de la vérification des hash : {message}",
"noTagsToAdd": "Aucun tag à ajouter",
"tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)",
"tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)",
"tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)",
"tagsReplaceFailed": "Échec du remplacement des tags pour {count} modèle(s)",
"bulkTagsAddFailed": "Échec de l'ajout des tags aux modèles",
"bulkTagsReplaceFailed": "Échec du remplacement des tags pour les modèles"
},
"search": {
"atLeastOneOption": "Au moins une option de recherche doit être sélectionnée"

View File

@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "{count} 選択中",
"selectedSuffix": "選択中",
"viewSelected": "選択したアイテムを表示するにはクリック",
"sendToWorkflow": "ワークフローに送信",
"copyAll": "すべてコピー",
"refreshAll": "すべて更新",
"moveAll": "すべて移動",
"deleteAll": "すべて削除",
"clear": "クリア"
"viewSelected": "選択中を表示",
"addTags": "すべてにタグを追加",
"setBaseModel": "すべてにベースモデルを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行",
"deleteAll": "すべてのモデルを削除",
"clear": "選択をクリア",
"autoOrganizeProgress": {
"initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...",
"processing": "処理中({processed}/{total}- {success} 移動、{skipped} スキップ、{failures} 失敗",
"cleaning": "空のディレクトリをクリーンアップ中...",
"completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗",
"complete": "自動整理が完了しました",
"error": "エラー:{error}"
}
},
"contextMenu": {
"refreshMetadata": "Civitaiデータを更新",
@@ -572,6 +583,24 @@
"countMessage": "モデルが完全に削除されます。",
"action": "すべて削除"
},
"bulkAddTags": {
"title": "複数モデルにタグを追加",
"description": "タグを追加するモデル:",
"models": "モデル",
"tagsToAdd": "追加するタグ",
"placeholder": "タグを入力してEnterを押してください...",
"appendTags": "タグを追加",
"replaceTags": "タグを置換",
"saveChanges": "変更を保存"
},
"bulkBaseModel": {
"title": "複数モデルのベースモデルを設定",
"description": "ベースモデルを設定するモデル:",
"models": "モデル",
"selectBaseModel": "ベースモデルを選択",
"save": "ベースモデルを更新",
"cancel": "キャンセル"
},
"exampleAccess": {
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
"pleaseSelectVersion": "バージョンを選択してください",
"versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました"
"downloadCompleted": "ダウンロードが正常に完了しました",
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
"noModelsSelected": "モデルが選択されていません"
},
"recipes": {
"fetchFailed": "レシピの取得に失敗しました:{message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "エラー:{error}",
"deleteFailedGeneral": "モデルの削除に失敗しました",
"selectedAdditional": "{count} 追加{type}が選択されました",
"marqueeSelectionComplete": "マーキー選択で {count} の{type}が選択されました",
"refreshMetadataFailed": "メタデータの更新に失敗しました",
"nameCannotBeEmpty": "モデル名を空にすることはできません",
"nameUpdatedSuccessfully": "モデル名が正常に更新されました",
"nameUpdateFailed": "モデル名の更新に失敗しました",
"baseModelUpdated": "ベースモデルが正常に更新されました",
"baseModelUpdateFailed": "ベースモデルの更新に失敗しました",
"baseModelNotSelected": "ベースモデルを選択してください",
"bulkBaseModelUpdating": "{count} モデルのベースモデルを更新中...",
"bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました",
"bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました",
"bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました",
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
"renameFailed": "ファイル名の変更に失敗しました:{message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "このグループは既に検証済みです",
"verificationCompleteMismatch": "検証完了。{count} ファイルの実際のハッシュが異なります。",
"verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。",
"verificationFailed": "ハッシュの検証に失敗しました:{message}"
"verificationFailed": "ハッシュの検証に失敗しました:{message}",
"noTagsToAdd": "追加するタグがありません",
"tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました",
"tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました",
"tagsAddFailed": "{count} モデルへのタグ追加に失敗しました",
"tagsReplaceFailed": "{count} モデルのタグ置換に失敗しました",
"bulkTagsAddFailed": "モデルへのタグ追加に失敗しました",
"bulkTagsReplaceFailed": "モデルのタグ置換に失敗しました"
},
"search": {
"atLeastOneOption": "少なくとも1つの検索オプションを選択する必要があります"

View File

@@ -194,7 +194,7 @@
"displayDensity": "표시 밀도",
"displayDensityOptions": {
"default": "기본",
"medium": "중간",
"medium": "중간",
"compact": "조밀"
},
"displayDensityHelp": "한 줄에 표시할 카드 수를 선택하세요:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "플랫 구조",
"byBaseModel": "베이스 모델별",
"byAuthor": "제작자별",
"byAuthor": "제작자별",
"byFirstTag": "첫 번째 태그별",
"baseModelFirstTag": "베이스 모델 + 첫 번째 태그",
"baseModelAuthor": "베이스 모델 + 제작자",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "사용자 정의 템플릿 입력 (예: {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "베이스 모델 경로 매핑",
@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "{count}개 선택됨",
"selectedSuffix": "개 선택됨",
"viewSelected": "선택 항목 보기",
"sendToWorkflow": "워크플로로 전송",
"copyAll": "모두 복사",
"refreshAll": "모두 새로고침",
"moveAll": "모두 이동",
"deleteAll": "모두 삭제",
"clear": "지우기"
"viewSelected": "선택 항목 보기",
"addTags": "모두에 태그 추가",
"setBaseModel": "모두에 베이스 모델 설정",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택",
"deleteAll": "모든 모델 삭제",
"clear": "선택 지우기",
"autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...",
"processing": "처리 중 ({processed}/{total}) - {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
"cleaning": "빈 디렉토리 정리 중...",
"completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
"complete": "자동 정리 완료",
"error": "오류: {error}"
}
},
"contextMenu": {
"refreshMetadata": "Civitai 데이터 새로고침",
@@ -572,6 +583,24 @@
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
"action": "모두 삭제"
},
"bulkAddTags": {
"title": "여러 모델에 태그 추가",
"description": "다음에 태그를 추가합니다:",
"models": "모델",
"tagsToAdd": "추가할 태그",
"placeholder": "태그를 입력하고 Enter를 누르세요...",
"appendTags": "태그 추가",
"replaceTags": "태그 교체",
"saveChanges": "변경사항 저장"
},
"bulkBaseModel": {
"title": "여러 모델의 베이스 모델 설정",
"description": "다음 모델의 베이스 모델을 설정합니다:",
"models": "모델",
"selectBaseModel": "베이스 모델 선택",
"save": "베이스 모델 업데이트",
"cancel": "취소"
},
"exampleAccess": {
"title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
"pleaseSelectVersion": "버전을 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다"
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}",
"noModelsSelected": "선택된 모델이 없습니다"
},
"recipes": {
"fetchFailed": "레시피 가져오기 실패: {message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "오류: {error}",
"deleteFailedGeneral": "모델 삭제에 실패했습니다",
"selectedAdditional": "추가로 {count}개의 {type}이(가) 선택되었습니다",
"marqueeSelectionComplete": "마키 선택으로 {count}개의 {type}이(가) 선택되었습니다",
"refreshMetadataFailed": "메타데이터 새로고침에 실패했습니다",
"nameCannotBeEmpty": "모델 이름은 비어있을 수 없습니다",
"nameUpdatedSuccessfully": "모델 이름이 성공적으로 업데이트되었습니다",
"nameUpdateFailed": "모델 이름 업데이트에 실패했습니다",
"baseModelUpdated": "베이스 모델이 성공적으로 업데이트되었습니다",
"baseModelUpdateFailed": "베이스 모델 업데이트에 실패했습니다",
"baseModelNotSelected": "베이스 모델을 선택해주세요",
"bulkBaseModelUpdating": "{count}개의 모델에 베이스 모델을 업데이트 중...",
"bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다",
"bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다",
"bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다",
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
"renameFailed": "파일 이름 변경 실패: {message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "이 그룹은 이미 검증되었습니다",
"verificationCompleteMismatch": "검증 완료. {count}개 파일의 실제 해시가 다릅니다.",
"verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.",
"verificationFailed": "해시 검증 실패: {message}"
"verificationFailed": "해시 검증 실패: {message}",
"noTagsToAdd": "추가할 태그가 없습니다",
"tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다",
"tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다",
"tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다",
"tagsReplaceFailed": "{count}개의 모델의 태그 교체에 실패했습니다",
"bulkTagsAddFailed": "모델에 태그 추가에 실패했습니다",
"bulkTagsReplaceFailed": "모델의 태그 교체에 실패했습니다"
},
"search": {
"atLeastOneOption": "최소 하나의 검색 옵션을 선택해야 합니다"

View File

@@ -194,7 +194,7 @@
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
"default": "По умолчанию",
"medium": "Средняя",
"medium": "Средняя",
"compact": "Компактная"
},
"displayDensityHelp": "Выберите количество карточек для отображения в ряду:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Плоская структура",
"byBaseModel": "По базовой модели",
"byAuthor": "По автору",
"byAuthor": "По автору",
"byFirstTag": "По первому тегу",
"baseModelFirstTag": "Базовая модель + Первый тег",
"baseModelAuthor": "Базовая модель + Автор",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Введите пользовательский шаблон (например, {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Сопоставления путей базовых моделей",
@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "Выбрано {count}",
"selectedSuffix": "выбрано",
"viewSelected": "Нажмите для просмотра выбранных элементов",
"sendToWorkflow": "Отправить в Workflow",
"copyAll": "Копировать все",
"refreshAll": "Обновить все",
"moveAll": "Переместить все",
"deleteAll": "Удалить все",
"clear": "Очистить"
"viewSelected": "Просмотреть выбранные",
"addTags": "Добавить теги ко всем",
"setBaseModel": "Установить базовую модель для всех",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные",
"deleteAll": "Удалить все модели",
"clear": "Очистить выбор",
"autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...",
"processing": "Обработка ({processed}/{total}) — {success} перемещено, {skipped} пропущено, {failures} не удалось",
"cleaning": "Очистка пустых директорий...",
"completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось",
"complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}"
}
},
"contextMenu": {
"refreshMetadata": "Обновить данные Civitai",
@@ -572,6 +583,24 @@
"countMessage": "моделей будут удалены навсегда.",
"action": "Удалить все"
},
"bulkAddTags": {
"title": "Добавить теги к нескольким моделям",
"description": "Добавить теги к",
"models": "моделям",
"tagsToAdd": "Теги для добавления",
"placeholder": "Введите тег и нажмите Enter...",
"appendTags": "Добавить теги",
"replaceTags": "Заменить теги",
"saveChanges": "Сохранить изменения"
},
"bulkBaseModel": {
"title": "Установить базовую модель для нескольких моделей",
"description": "Установить базовую модель для",
"models": "моделей",
"selectBaseModel": "Выбрать базовую модель",
"save": "Обновить базовую модель",
"cancel": "Отмена"
},
"exampleAccess": {
"title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
"pleaseSelectVersion": "Пожалуйста, выберите версию",
"versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена"
"downloadCompleted": "Загрузка успешно завершена",
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
"noModelsSelected": "Модели не выбраны"
},
"recipes": {
"fetchFailed": "Не удалось получить рецепты: {message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "Ошибка: {error}",
"deleteFailedGeneral": "Не удалось удалить модели",
"selectedAdditional": "Выбрано дополнительно {count} {type}(ей)",
"marqueeSelectionComplete": "Выбрано {count} {type} с помощью выделения рамкой",
"refreshMetadataFailed": "Не удалось обновить метаданные",
"nameCannotBeEmpty": "Название модели не может быть пустым",
"nameUpdatedSuccessfully": "Название модели успешно обновлено",
"nameUpdateFailed": "Не удалось обновить название модели",
"baseModelUpdated": "Базовая модель успешно обновлена",
"baseModelUpdateFailed": "Не удалось обновить базовую модель",
"baseModelNotSelected": "Пожалуйста, выберите базовую модель",
"bulkBaseModelUpdating": "Обновление базовой модели для {count} моделей...",
"bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей",
"bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей",
"bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей",
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
"renameFailed": "Не удалось переименовать файл: {message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "Эта группа уже была проверена",
"verificationCompleteMismatch": "Проверка завершена. {count} файл(ов) имеют разные фактические хеши.",
"verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.",
"verificationFailed": "Не удалось проверить хеши: {message}"
"verificationFailed": "Не удалось проверить хеши: {message}",
"noTagsToAdd": "Нет тегов для добавления",
"tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)",
"tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)",
"tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)",
"tagsReplaceFailed": "Не удалось заменить теги для {count} модель(ей)",
"bulkTagsAddFailed": "Не удалось добавить теги к моделям",
"bulkTagsReplaceFailed": "Не удалось заменить теги для моделей"
},
"search": {
"atLeastOneOption": "Должен быть выбран хотя бы один вариант поиска"

View File

@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "已选中 {count} 项",
"selectedSuffix": "已选中",
"viewSelected": "点击查看已选项目",
"sendToWorkflow": "发送到工作流",
"copyAll": "全部复制",
"refreshAll": "全部刷新",
"moveAll": "全部移动",
"deleteAll": "全部删除",
"clear": "清除"
"viewSelected": "查看已选",
"addTags": "为所有添加标签",
"setBaseModel": "为所有设置基础模型",
"copyAll": "复制全部语法",
"refreshAll": "刷新全部元数据",
"moveAll": "全部移动到文件夹",
"autoOrganize": "自动整理所选模型",
"deleteAll": "删除所有模型",
"clear": "清除选择",
"autoOrganizeProgress": {
"initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...",
"processing": "处理中({processed}/{total}- 已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"cleaning": "正在清理空文件夹...",
"completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"complete": "自动整理已完成",
"error": "错误:{error}"
}
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 数据",
@@ -572,6 +583,24 @@
"countMessage": "模型将被永久删除。",
"action": "全部删除"
},
"bulkAddTags": {
"title": "批量添加标签",
"description": "为多个模型添加标签",
"models": "个模型",
"tagsToAdd": "要添加的标签",
"placeholder": "输入标签并按回车...",
"appendTags": "追加标签",
"replaceTags": "替换标签",
"saveChanges": "保存更改"
},
"bulkBaseModel": {
"title": "批量设置基础模型",
"description": "为多个模型设置基础模型",
"models": "个模型",
"selectBaseModel": "选择基础模型",
"save": "更新基础模型",
"cancel": "取消"
},
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
"pleaseSelectVersion": "请选择版本",
"versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成"
"downloadCompleted": "下载成功完成",
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}",
"noModelsSelected": "未选中模型"
},
"recipes": {
"fetchFailed": "获取配方失败:{message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "错误:{error}",
"deleteFailedGeneral": "删除模型失败",
"selectedAdditional": "已选中 {count} 个额外 {type}",
"marqueeSelectionComplete": "框选已选中 {count} 个 {type}",
"refreshMetadataFailed": "刷新元数据失败",
"nameCannotBeEmpty": "模型名称不能为空",
"nameUpdatedSuccessfully": "模型名称更新成功",
"nameUpdateFailed": "模型名称更新失败",
"baseModelUpdated": "基础模型更新成功",
"baseModelUpdateFailed": "基础模型更新失败",
"baseModelNotSelected": "请选择基础模型",
"bulkBaseModelUpdating": "正在为 {count} 个模型更新基础模型...",
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
"invalidCharactersRemoved": "文件名中的无效字符已移除",
"filenameCannotBeEmpty": "文件名不能为空",
"renameFailed": "重命名文件失败:{message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "此组已验证过",
"verificationCompleteMismatch": "验证完成。{count} 个文件实际哈希不同。",
"verificationCompleteSuccess": "验证完成。所有文件均为重复项。",
"verificationFailed": "验证哈希失败:{message}"
"verificationFailed": "验证哈希失败:{message}",
"noTagsToAdd": "没有可添加的标签",
"tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签",
"tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签",
"tagsAddFailed": "为 {count} 个模型添加标签失败",
"tagsReplaceFailed": "为 {count} 个模型替换标签失败",
"bulkTagsAddFailed": "批量添加标签失败",
"bulkTagsReplaceFailed": "批量替换标签失败"
},
"search": {
"atLeastOneOption": "至少选择一个搜索选项"

View File

@@ -318,13 +318,24 @@
"bulkOperations": {
"selected": "已選擇 {count} 項",
"selectedSuffix": "已選擇",
"viewSelected": "點擊檢視已選項目",
"sendToWorkflow": "傳送到工作流",
"copyAll": "全部複製",
"refreshAll": "全部刷新",
"moveAll": "全部移動",
"deleteAll": "全部刪除",
"clear": "清除"
"viewSelected": "檢視已選",
"addTags": "新增標籤到全部",
"setBaseModel": "設定全部基礎模型",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型",
"deleteAll": "刪除全部模型",
"clear": "清除選取",
"autoOrganizeProgress": {
"initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...",
"processing": "處理中({processed}/{total}- 已移動 {success},已略過 {skipped},失敗 {failures}",
"cleaning": "正在清理空資料夾...",
"completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}",
"complete": "自動整理完成",
"error": "錯誤:{error}"
}
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 資料",
@@ -572,6 +583,24 @@
"countMessage": "模型將被永久刪除。",
"action": "全部刪除"
},
"bulkAddTags": {
"title": "新增標籤到多個模型",
"description": "新增標籤到",
"models": "個模型",
"tagsToAdd": "要新增的標籤",
"placeholder": "輸入標籤並按 Enter...",
"appendTags": "附加標籤",
"replaceTags": "取代標籤",
"saveChanges": "儲存變更"
},
"bulkBaseModel": {
"title": "設定多個模型的基礎模型",
"description": "設定基礎模型給",
"models": "個模型",
"selectBaseModel": "選擇基礎模型",
"save": "更新基礎模型",
"cancel": "取消"
},
"exampleAccess": {
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
@@ -923,7 +952,11 @@
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
"pleaseSelectVersion": "請選擇一個版本",
"versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成"
"downloadCompleted": "下載成功完成",
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}",
"noModelsSelected": "未選擇任何模型"
},
"recipes": {
"fetchFailed": "取得配方失敗:{message}",
@@ -972,12 +1005,18 @@
"deleteFailed": "錯誤:{error}",
"deleteFailedGeneral": "刪除模型失敗",
"selectedAdditional": "已選擇 {count} 個額外 {type}",
"marqueeSelectionComplete": "框選已選擇 {count} 個 {type}",
"refreshMetadataFailed": "刷新 metadata 失敗",
"nameCannotBeEmpty": "模型名稱不可為空",
"nameUpdatedSuccessfully": "模型名稱已成功更新",
"nameUpdateFailed": "更新模型名稱失敗",
"baseModelUpdated": "基礎模型已成功更新",
"baseModelUpdateFailed": "更新基礎模型失敗",
"baseModelNotSelected": "請選擇基礎模型",
"bulkBaseModelUpdating": "正在為 {count} 個模型更新基礎模型...",
"bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型",
"bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗",
"bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗",
"invalidCharactersRemoved": "已移除檔名中的無效字元",
"filenameCannotBeEmpty": "檔案名稱不可為空",
"renameFailed": "重新命名檔案失敗:{message}",
@@ -987,7 +1026,14 @@
"verificationAlreadyDone": "此群組已驗證過",
"verificationCompleteMismatch": "驗證完成。{count} 個檔案的實際雜湊不同。",
"verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。",
"verificationFailed": "驗證雜湊失敗:{message}"
"verificationFailed": "驗證雜湊失敗:{message}",
"noTagsToAdd": "沒有可新增的標籤",
"tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}",
"tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤",
"tagsAddFailed": "新增標籤到 {count} 個模型失敗",
"tagsReplaceFailed": "取代 {count} 個模型的標籤失敗",
"bulkTagsAddFailed": "批量新增標籤到模型失敗",
"bulkTagsReplaceFailed": "批量取代模型標籤失敗"
},
"search": {
"atLeastOneOption": "至少需選擇一個搜尋選項"

View File

@@ -237,6 +237,7 @@ class LoraManager:
# Run post-initialization tasks
post_tasks = [
asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'),
asyncio.create_task(cls._cleanup_example_images_folders(), name='cleanup_example_images'),
# Add more post-initialization tasks here as needed
# asyncio.create_task(cls._another_post_task(), name='another_task'),
]
@@ -346,6 +347,123 @@ class LoraManager:
return deleted_count, size_freed
@classmethod
async def _cleanup_example_images_folders(cls):
"""Clean up invalid or empty folders in example images directory"""
try:
example_images_path = settings.get('example_images_path')
if not example_images_path or not os.path.exists(example_images_path):
logger.debug("Example images path not configured or doesn't exist, skipping cleanup")
return
logger.debug(f"Starting cleanup of example images folders in: {example_images_path}")
# Get all scanner instances to check hash validity
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
total_folders_checked = 0
empty_folders_removed = 0
invalid_hash_folders_removed = 0
# Scan the example images directory
try:
with os.scandir(example_images_path) as it:
for entry in it:
if not entry.is_dir(follow_symlinks=False):
continue
folder_name = entry.name
folder_path = entry.path
total_folders_checked += 1
try:
# Check if folder is empty
is_empty = cls._is_folder_empty(folder_path)
if is_empty:
logger.debug(f"Removing empty example images folder: {folder_name}")
await cls._remove_folder_safely(folder_path)
empty_folders_removed += 1
continue
# Check if folder name is a valid SHA256 hash (64 hex characters)
if len(folder_name) != 64 or not all(c in '0123456789abcdefABCDEF' for c in folder_name):
logger.debug(f"Removing invalid hash folder: {folder_name}")
await cls._remove_folder_safely(folder_path)
invalid_hash_folders_removed += 1
continue
# Check if hash exists in any of the scanners
hash_exists = (
lora_scanner.has_hash(folder_name) or
checkpoint_scanner.has_hash(folder_name) or
embedding_scanner.has_hash(folder_name)
)
if not hash_exists:
logger.debug(f"Removing example images folder for deleted model: {folder_name}")
await cls._remove_folder_safely(folder_path)
invalid_hash_folders_removed += 1
continue
logger.debug(f"Keeping valid example images folder: {folder_name}")
except Exception as e:
logger.error(f"Error processing example images folder {folder_name}: {e}")
# Yield control periodically
await asyncio.sleep(0.01)
except Exception as e:
logger.error(f"Error scanning example images directory: {e}")
return
# Log final cleanup report
total_removed = empty_folders_removed + invalid_hash_folders_removed
if total_removed > 0:
logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
f"removed {empty_folders_removed} empty folders and {invalid_hash_folders_removed} "
f"folders for deleted/invalid models (total: {total_removed} removed)")
else:
logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
f"no cleanup needed")
except Exception as e:
logger.error(f"Error during example images cleanup: {e}", exc_info=True)
@classmethod
def _is_folder_empty(cls, folder_path: str) -> bool:
"""Check if a folder is empty
Args:
folder_path: Path to the folder to check
Returns:
bool: True if folder is empty, False otherwise
"""
try:
with os.scandir(folder_path) as it:
return not any(it)
except Exception as e:
logger.debug(f"Error checking if folder is empty {folder_path}: {e}")
return False
@classmethod
async def _remove_folder_safely(cls, folder_path: str):
"""Safely remove a folder and all its contents
Args:
folder_path: Path to the folder to remove
"""
try:
import shutil
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, shutil.rmtree, folder_path)
except Exception as e:
logger.warning(f"Failed to remove folder {folder_path}: {e}")
@classmethod
async def _cleanup(cls, app):
"""Cleanup resources using ServiceRegistry"""

View File

@@ -0,0 +1,128 @@
from comfy.comfy_types import IO
import folder_paths
from ..utils.utils import get_lora_info
from .utils import any_type
import logging
# 初始化日志记录器
logger = logging.getLogger(__name__)
# 定义新节点的类
class WanVideoLoraSelectFromText:
# 节点在UI中显示的名称
NAME = "WanVideo Lora Select From Text (LoraManager)"
# 节点所属的分类
CATEGORY = "Lora Manager/stackers"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
"lora_syntax": (IO.STRING, {
"multiline": True,
"defaultInput": True,
"forceInput": True,
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
}),
},
"optional": {
"prev_lora": ("WANVIDLORA",),
"blocks": ("BLOCKS",)
}
}
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_loras_from_syntax"
def process_loras_from_syntax(self, lora_syntax, low_mem_load=False, merge_lora=True, **kwargs):
text_to_process = lora_syntax
blocks = kwargs.get('blocks', {})
selected_blocks = blocks.get("selected_blocks", {})
layer_filter = blocks.get("layer_filter", "")
loras_list = []
all_trigger_words = []
active_loras = []
prev_lora = kwargs.get('prev_lora', None)
if prev_lora is not None:
loras_list.extend(prev_lora)
if not merge_lora:
low_mem_load = False
parts = text_to_process.split('<lora:')
for part in parts[1:]:
end_index = part.find('>')
if end_index == -1:
continue
content = part[:end_index]
lora_parts = content.split(':')
lora_name_raw = ""
model_strength = 1.0
clip_strength = 1.0
if len(lora_parts) == 2:
lora_name_raw = lora_parts[0].strip()
try:
model_strength = float(lora_parts[1])
clip_strength = model_strength
except (ValueError, IndexError):
logger.warning(f"Invalid strength for LoRA '{lora_name_raw}'. Skipping.")
continue
elif len(lora_parts) >= 3:
lora_name_raw = lora_parts[0].strip()
try:
model_strength = float(lora_parts[1])
clip_strength = float(lora_parts[2])
except (ValueError, IndexError):
logger.warning(f"Invalid strengths for LoRA '{lora_name_raw}'. Skipping.")
continue
else:
continue
lora_path, trigger_words = get_lora_info(lora_name_raw)
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"strength": model_strength,
"name": lora_path.split(".")[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,
"merge_loras": merge_lora,
}
loras_list.append(lora_item)
active_loras.append((lora_name_raw, model_strength, clip_strength))
all_trigger_words.extend(trigger_words)
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
formatted_loras = []
for name, model_strength, clip_strength in active_loras:
if abs(model_strength - clip_strength) > 0.001:
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}:{str(clip_strength).strip()}>")
else:
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}>")
active_loras_text = " ".join(formatted_loras)
return (loras_list, trigger_words_text, active_loras_text)
NODE_CLASS_MAPPINGS = {
"WanVideoLoraSelectFromText": WanVideoLoraSelectFromText
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WanVideoLoraSelectFromText": "WanVideo Lora Select From Text (LoraManager)"
}

View File

@@ -49,12 +49,14 @@ class BaseModelRoutes(ABC):
app.router.add_post(f'/api/{prefix}/relink-civitai', self.relink_civitai)
app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview)
app.router.add_post(f'/api/{prefix}/save-metadata', self.save_metadata)
app.router.add_post(f'/api/{prefix}/add-tags', self.add_tags)
app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
app.router.add_post(f'/api/{prefix}/auto-organize', self.auto_organize_models)
app.router.add_get(f'/api/{prefix}/auto-organize-progress', self.get_auto_organize_progress)
# Common query routes
@@ -272,6 +274,10 @@ class BaseModelRoutes(ABC):
"""Handle saving metadata updates"""
return await ModelRouteUtils.handle_save_metadata(request, self.service.scanner)
async def add_tags(self, request: web.Request) -> web.Response:
"""Handle adding tags to model metadata"""
return await ModelRouteUtils.handle_add_tags(request, self.service.scanner)
async def rename_model(self, request: web.Request) -> web.Response:
"""Handle renaming a model file and its associated files"""
return await ModelRouteUtils.handle_rename_model(request, self.service.scanner)
@@ -768,7 +774,7 @@ class BaseModelRoutes(ABC):
return web.Response(text=str(e), status=500)
async def auto_organize_models(self, request: web.Request) -> web.Response:
"""Auto-organize all models based on current settings"""
"""Auto-organize all models or a specific set of models based on current settings"""
try:
# Check if auto-organize is already running
if ws_manager.is_auto_organize_running():
@@ -786,8 +792,17 @@ class BaseModelRoutes(ABC):
'error': 'Auto-organize is already running. Please wait for it to complete.'
}, status=409)
# Get specific file paths from request if this is a POST with selected models
file_paths = None
if request.method == 'POST':
try:
data = await request.json()
file_paths = data.get('file_paths')
except Exception:
pass # Continue with all models if no valid JSON
async with auto_organize_lock:
return await self._perform_auto_organize()
return await self._perform_auto_organize(file_paths)
except Exception as e:
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
@@ -804,20 +819,33 @@ class BaseModelRoutes(ABC):
'error': str(e)
}, status=500)
async def _perform_auto_organize(self) -> web.Response:
"""Perform the actual auto-organize operation"""
async def _perform_auto_organize(self, file_paths=None) -> web.Response:
"""Perform the actual auto-organize operation
Args:
file_paths: Optional list of specific file paths to organize.
If None, organizes all models.
"""
try:
# Get all models from cache
cache = await self.service.scanner.get_cached_data()
all_models = cache.raw_data
# Filter models if specific file paths are provided
if file_paths:
all_models = [model for model in all_models if model.get('file_path') in file_paths]
operation_type = 'bulk'
else:
operation_type = 'all'
# Get model roots for this scanner
model_roots = self.service.get_model_roots()
if not model_roots:
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'error',
'error': 'No model roots configured'
'error': 'No model roots configured',
'operation_type': operation_type
})
return web.json_response({
'success': False,
@@ -844,7 +872,8 @@ class BaseModelRoutes(ABC):
'processed': 0,
'success': 0,
'failures': 0,
'skipped': 0
'skipped': 0,
'operation_type': operation_type
})
# Process models in batches
@@ -975,7 +1004,8 @@ class BaseModelRoutes(ABC):
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count
'skipped': skipped_count,
'operation_type': operation_type
})
# Small delay between batches to prevent overwhelming the system
@@ -990,7 +1020,8 @@ class BaseModelRoutes(ABC):
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'message': 'Cleaning up empty directories...'
'message': 'Cleaning up empty directories...',
'operation_type': operation_type
})
# Clean up empty directories after organizing
@@ -1009,20 +1040,22 @@ class BaseModelRoutes(ABC):
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'cleanup': cleanup_counts
'cleanup': cleanup_counts,
'operation_type': operation_type
})
# Prepare response with limited details
response_data = {
'success': True,
'message': f'Auto-organize completed: {success_count} moved, {skipped_count} skipped, {failure_count} failed out of {total_models} total',
'message': f'Auto-organize {operation_type} completed: {success_count} moved, {skipped_count} skipped, {failure_count} failed out of {total_models} total',
'summary': {
'total': total_models,
'success': success_count,
'skipped': skipped_count,
'failures': failure_count,
'organization_type': 'flat' if is_flat_structure else 'structured',
'cleaned_dirs': cleanup_counts
'cleaned_dirs': cleanup_counts,
'operation_type': operation_type
}
}
@@ -1042,7 +1075,8 @@ class BaseModelRoutes(ABC):
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'error',
'error': str(e)
'error': str(e),
'operation_type': operation_type if 'operation_type' in locals() else 'unknown'
})
raise e

View File

@@ -54,7 +54,7 @@ AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming
# Civitai model tags in priority order for subfolder organization
CIVITAI_MODEL_TAGS = [
'character', 'style', 'concept', 'clothing',
# 'base model', # exclude 'base model'
'realistic', 'anime', 'toon', 'furry',
'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action'
]

View File

@@ -870,11 +870,11 @@ class ModelRouteUtils:
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
# Compare hashes
stored_hash = metadata.get('sha256', '').lower()
stored_hash = metadata.get('sha256', '').lower();
# Set expected hash from first file if not yet set
if not expected_hash:
expected_hash = stored_hash
expected_hash = stored_hash;
# Check if hash matches expected hash
if actual_hash != expected_hash:
@@ -978,7 +978,7 @@ class ModelRouteUtils:
if os.path.exists(metadata_path):
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
hash_value = metadata.get('sha256')
logger.info(f"hash_value: {hash_value}, metadata_path: {metadata_path}, metadata: {metadata}")
# Rename all files
renamed_files = []
new_metadata_path = None
@@ -1093,3 +1093,63 @@ class ModelRouteUtils:
except Exception as e:
logger.error(f"Error saving metadata: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
@staticmethod
async def handle_add_tags(request: web.Request, scanner) -> web.Response:
"""Handle adding tags to model metadata
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_tags = data.get('tags', [])
if not file_path:
return web.Response(text='File path is required', status=400)
if not isinstance(new_tags, list):
return web.Response(text='Tags must be a list', status=400)
# Get metadata file path
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
# Load existing metadata
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
# Get existing tags (case insensitive)
existing_tags = metadata.get('tags', [])
existing_tags_lower = [tag.lower() for tag in existing_tags]
# Add new tags that don't already exist (case insensitive check)
tags_added = []
for tag in new_tags:
if isinstance(tag, str) and tag.strip():
tag_stripped = tag.strip()
if tag_stripped.lower() not in existing_tags_lower:
existing_tags.append(tag_stripped)
existing_tags_lower.append(tag_stripped.lower())
tags_added.append(tag_stripped)
# Update metadata with combined tags
metadata['tags'] = existing_tags
# Save updated metadata
await MetadataManager.save_metadata(file_path, metadata)
# Update cache
await scanner.update_single_model_cache(file_path, file_path, metadata)
return web.json_response({
'success': True,
'tags': existing_tags
})
except Exception as e:
logger.error(f"Error adding tags: {e}", exc_info=True)
return web.Response(text=str(e), status=500)

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.0"
version = "0.9.2"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
Translation Key Synchronization Script
This script synchronizes new translation keys from en.json to all other locale files
while maintaining exact formatting consistency to pass test_i18n.py validation.
Features:
- Preserves exact line-by-line formatting
- Maintains proper indentation and structure
- Adds missing keys with placeholder translations
- Handles nested objects correctly
- Ensures all locale files have identical structure
Usage:
python scripts/sync_translation_keys.py [--dry-run] [--verbose]
"""
import os
import sys
import json
import re
import argparse
from typing import Dict, List, Set, Tuple, Any, Optional
from collections import OrderedDict
# Add the parent directory to the path so we can import modules if needed
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
class TranslationKeySynchronizer:
"""Synchronizes translation keys across locale files while maintaining formatting."""
def __init__(self, locales_dir: str, verbose: bool = False):
self.locales_dir = locales_dir
self.verbose = verbose
self.reference_locale = 'en'
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
def log(self, message: str, level: str = 'INFO'):
"""Log a message if verbose mode is enabled."""
if self.verbose or level == 'ERROR':
print(f"[{level}] {message}")
def load_json_preserve_order(self, file_path: str) -> Tuple[Dict[str, Any], List[str]]:
"""
Load a JSON file preserving the exact order and formatting.
Returns both the parsed data and the original lines.
"""
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
content = ''.join(lines)
# Parse JSON while preserving order
data = json.loads(content, object_pairs_hook=OrderedDict)
return data, lines
def get_all_leaf_keys(self, data: Any, prefix: str = '') -> Dict[str, Any]:
"""
Extract all leaf keys (non-object values) with their full paths.
Returns a dictionary mapping full key paths to their values.
"""
keys = {}
if isinstance(data, (dict, OrderedDict)):
for key, value in data.items():
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, (dict, OrderedDict)):
# Recursively get nested keys
keys.update(self.get_all_leaf_keys(value, full_key))
else:
# Leaf node - actual translatable value
keys[full_key] = value
return keys
def merge_json_structures(self, reference_data: Dict[str, Any], target_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Merge the reference JSON structure with existing target translations.
This creates a new structure that matches the reference exactly but preserves
existing translations where available. Keys not in reference are removed.
"""
def merge_recursive(ref_obj, target_obj):
if isinstance(ref_obj, (dict, OrderedDict)):
result = OrderedDict()
# Only include keys that exist in the reference
for key, ref_value in ref_obj.items():
if key in target_obj and isinstance(target_obj[key], type(ref_value)):
# Key exists in target with same type
if isinstance(ref_value, (dict, OrderedDict)):
# Recursively merge nested objects
result[key] = merge_recursive(ref_value, target_obj[key])
else:
# Use existing translation
result[key] = target_obj[key]
else:
# Key missing in target or type mismatch
if isinstance(ref_value, (dict, OrderedDict)):
# Recursively handle nested objects
result[key] = merge_recursive(ref_value, {})
else:
# Create placeholder translation
result[key] = f"[TODO: Translate] {ref_value}"
return result
else:
# For non-dict values, use reference (this shouldn't happen at root level)
return ref_obj
return merge_recursive(reference_data, target_data)
def format_json_like_reference(self, data: Dict[str, Any], reference_lines: List[str]) -> List[str]:
"""
Format the merged JSON data to match the reference file's formatting exactly.
"""
# Use json.dumps with proper formatting to match the reference style
formatted_json = json.dumps(data, indent=4, ensure_ascii=False, separators=(',', ': '))
# Split into lines and ensure consistent line endings
formatted_lines = [line + '\n' for line in formatted_json.split('\n')]
# Make sure the last line doesn't have extra newlines
if formatted_lines and formatted_lines[-1].strip() == '':
formatted_lines = formatted_lines[:-1]
# Ensure the last line ends with just a newline
if formatted_lines and not formatted_lines[-1].endswith('\n'):
formatted_lines[-1] += '\n'
return formatted_lines
def synchronize_locale_simple(self, locale: str, reference_data: Dict[str, Any],
reference_lines: List[str], dry_run: bool = False) -> bool:
"""
Synchronize a locale file using JSON structure merging.
Handles both addition of missing keys and removal of obsolete keys.
"""
locale_file = os.path.join(self.locales_dir, f'{locale}.json')
if not os.path.exists(locale_file):
self.log(f"Locale file {locale_file} does not exist!", 'ERROR')
return False
try:
target_data, _ = self.load_json_preserve_order(locale_file)
except Exception as e:
self.log(f"Error loading {locale_file}: {e}", 'ERROR')
return False
# Get keys to check for differences
ref_keys = self.get_all_leaf_keys(reference_data)
target_keys = self.get_all_leaf_keys(target_data)
missing_keys = set(ref_keys.keys()) - set(target_keys.keys())
obsolete_keys = set(target_keys.keys()) - set(ref_keys.keys())
if not missing_keys and not obsolete_keys:
self.log(f"Locale {locale} is already up to date")
return False
# Report changes
if missing_keys:
self.log(f"Found {len(missing_keys)} missing keys in {locale}:")
for key in sorted(missing_keys):
self.log(f" + {key}")
if obsolete_keys:
self.log(f"Found {len(obsolete_keys)} obsolete keys in {locale}:")
for key in sorted(obsolete_keys):
self.log(f" - {key}")
if dry_run:
total_changes = len(missing_keys) + len(obsolete_keys)
self.log(f"DRY RUN: Would update {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
return True
# Merge the structures (this will both add missing keys and remove obsolete ones)
try:
merged_data = self.merge_json_structures(reference_data, target_data)
# Format to match reference style
new_lines = self.format_json_like_reference(merged_data, reference_lines)
# Validate that the result is valid JSON
reconstructed_content = ''.join(new_lines)
json.loads(reconstructed_content) # This will raise an exception if invalid
# Write the updated file
with open(locale_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
total_changes = len(missing_keys) + len(obsolete_keys)
self.log(f"Successfully updated {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
return True
except json.JSONDecodeError as e:
self.log(f"Generated invalid JSON for {locale}: {e}", 'ERROR')
return False
except Exception as e:
self.log(f"Error updating {locale_file}: {e}", 'ERROR')
return False
def synchronize_all(self, dry_run: bool = False) -> bool:
"""
Synchronize all locale files with the reference.
Returns True if all operations were successful.
"""
# Load reference file
reference_file = os.path.join(self.locales_dir, f'{self.reference_locale}.json')
if not os.path.exists(reference_file):
self.log(f"Reference file {reference_file} does not exist!", 'ERROR')
return False
try:
reference_data, reference_lines = self.load_json_preserve_order(reference_file)
reference_keys = self.get_all_leaf_keys(reference_data)
except Exception as e:
self.log(f"Error loading reference file: {e}", 'ERROR')
return False
self.log(f"Loaded reference file with {len(reference_keys)} keys")
success = True
changes_made = False
# Synchronize each target locale
for locale in self.target_locales:
try:
if self.synchronize_locale_simple(locale, reference_data, reference_lines, dry_run):
changes_made = True
except Exception as e:
self.log(f"Error synchronizing {locale}: {e}", 'ERROR')
success = False
if changes_made:
self.log("Synchronization completed with changes")
else:
self.log("All locale files are already up to date")
return success
def main():
"""Main entry point for the script."""
parser = argparse.ArgumentParser(
description='Synchronize translation keys from en.json to all other locale files'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be changed without making actual changes'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--locales-dir',
default=None,
help='Path to locales directory (default: auto-detect from script location)'
)
args = parser.parse_args()
# Determine locales directory
if args.locales_dir:
locales_dir = args.locales_dir
else:
# Auto-detect based on script location
script_dir = os.path.dirname(os.path.abspath(__file__))
locales_dir = os.path.join(os.path.dirname(script_dir), 'locales')
if not os.path.exists(locales_dir):
print(f"ERROR: Locales directory not found: {locales_dir}")
sys.exit(1)
print(f"Translation Key Synchronization")
print(f"Locales directory: {locales_dir}")
print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE UPDATE'}")
print("-" * 50)
# Create synchronizer and run
synchronizer = TranslationKeySynchronizer(locales_dir, args.verbose)
try:
success = synchronizer.synchronize_all(args.dry_run)
if success:
print("\n✅ Synchronization completed successfully!")
if not args.dry_run:
print("💡 Run 'python test_i18n.py' to verify formatting consistency")
else:
print("\n❌ Synchronization completed with errors!")
sys.exit(1)
except KeyboardInterrupt:
print("\n⚠️ Operation cancelled by user")
sys.exit(1)
except Exception as e:
print(f"\n❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -1,81 +1,3 @@
/* Bulk Operations Styles */
.bulk-operations-panel {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateY(100px) translateX(-50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
min-width: 420px;
max-width: 900px;
width: auto;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
.bulk-operations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 20px; /* Increase space between count and buttons */
}
#selectedCount {
font-weight: 500;
background: var(--bg-color);
padding: 6px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
min-width: 80px;
text-align: center;
}
.bulk-operations-actions {
display: flex;
gap: 8px;
}
.bulk-operations-actions button {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
font-size: 14px;
white-space: nowrap;
min-height: 36px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.bulk-operations-actions button:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Danger button style - updated to use proper theme variables */
.bulk-operations-actions button.danger-btn {
background: oklch(70% 0.2 29); /* Light red background that works in both themes */
color: oklch(98% 0.01 0); /* Almost white text for good contrast */
border-color: var(--lora-error);
}
.bulk-operations-actions button.danger-btn:hover {
background: var(--lora-error);
color: oklch(100% 0 0); /* Pure white text on hover for maximum contrast */
}
/* Style for selected cards */
.model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
@@ -99,203 +21,61 @@
z-index: 1;
}
/* Update bulk operations button to match others when active */
#bulkOperationsBtn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
@media (max-width: 768px) {
.bulk-operations-panel {
width: calc(100% - 40px);
min-width: unset;
max-width: unset;
left: 20px;
transform: none;
border-radius: var(--border-radius-sm);
}
.bulk-operations-actions {
flex-wrap: wrap;
}
}
.bulk-operations-panel.visible {
transform: translateY(0) translateX(-50%);
opacity: 1;
}
/* Thumbnail Strip Styles */
.selected-thumbnails-strip {
/* Marquee selection styles */
.marquee-selection {
position: fixed;
bottom: 80px; /* Position above the bulk operations panel */
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: calc(var(--z-overlay) - 1); /* Just below the bulk panel z-index */
padding: 16px;
max-width: 80%;
width: auto;
transition: all 0.3s ease;
opacity: 0;
overflow: hidden;
border: 2px dashed var(--lora-accent, #007bff);
background: rgba(0, 123, 255, 0.1);
pointer-events: none;
z-index: 9999;
border-radius: 2px;
}
.selected-thumbnails-strip.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
/* Visual feedback when marquee selecting */
.marquee-selecting {
cursor: crosshair;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.thumbnails-container {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px; /* Space for scrollbar */
/* Prevent text selection during marquee */
.marquee-selecting * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Remove bulk base model modal specific styles - now using shared components */
/* Use shared metadata editing styles instead */
/* Override for bulk base model select to ensure proper width */
.bulk-base-model-select {
width: 100%;
max-width: 100%;
align-items: flex-start;
}
.selected-thumbnail {
position: relative;
width: 80px;
min-width: 80px; /* Prevent shrinking */
padding: 6px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
overflow: hidden;
cursor: pointer;
background: var(--bg-color);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.selected-thumbnail:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.selected-thumbnail img,
.selected-thumbnail video {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
.thumbnail-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 10px;
padding: 3px 5px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.thumbnail-remove {
position: absolute;
top: 3px;
right: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 10px;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.thumbnail-remove:hover {
opacity: 1;
background: var(--lora-error);
}
.strip-close-btn {
position: absolute;
top: 5px;
right: 5px;
width: 20px;
height: 20px;
background: none;
border: none;
background-color: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease;
font-size: 0.95em;
height: 32px;
}
.strip-close-btn:hover {
opacity: 1;
.bulk-base-model-select:focus {
border-color: var(--lora-accent);
outline: none;
}
/* Style the selectedCount to indicate it's clickable */
.selectable-count {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
transition: background-color 0.2s ease;
/* Dark theme support for bulk base model select */
[data-theme="dark"] .bulk-base-model-select {
background-color: rgba(30, 30, 30, 0.9);
color: var(--text-color);
}
.selectable-count:hover {
background: var(--lora-border);
}
.dropdown-caret {
font-size: 12px;
visibility: hidden; /* Will be shown via JS when items are selected */
}
/* Scrollbar styling for the thumbnails container */
.thumbnails-container::-webkit-scrollbar {
height: 6px;
}
.thumbnails-container::-webkit-scrollbar-track {
background: var(--bg-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb:hover {
background: var(--lora-accent);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.selected-thumbnails-strip {
width: calc(100% - 40px);
max-width: none;
left: 20px;
transform: translateY(20px);
border-radius: var(--border-radius-sm);
}
.selected-thumbnails-strip.visible {
transform: translateY(0);
}
.selected-thumbnail {
width: 70px;
min-width: 70px;
}
[data-theme="dark"] .bulk-base-model-select option {
background-color: #2d2d2d;
color: var(--text-color);
}

View File

@@ -176,11 +176,6 @@
background: linear-gradient(45deg, #4a90e2, #357abd);
}
/* Remove old node-color-indicator styles */
.node-color-indicator {
display: none;
}
.send-all-item {
border-top: 1px solid var(--border-color);
font-weight: 500;
@@ -217,4 +212,24 @@
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
/* Bulk Context Menu Header */
.bulk-context-header {
padding: 10px 12px;
background: var(--card-bg); /* Use card background for subtlety */
color: var(--text-color); /* Use standard text color */
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
border-radius: var(--border-radius-xs) var(--border-radius-xs) 0 0;
border-bottom: 1px solid var(--border-color); /* Add subtle separator */
}
.bulk-context-header i {
width: 16px;
text-align: center;
color: var(--lora-accent); /* Accent only the icon for a hint of color */
}

View File

@@ -37,6 +37,10 @@ body.modal-open {
overflow-x: hidden; /* 防止水平滚动条 */
}
.modal-content-large {
min-height: 480px;
}
/* 当 modal 打开时锁定 body */
body.modal-open {
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */

View File

@@ -80,6 +80,7 @@
align-items: flex-start;
margin-bottom: var(--space-2);
width: 100%;
min-height: 30px; /* Ensure some height even if empty to prevent layout shifts */
}
/* Individual Item */
@@ -153,17 +154,42 @@
}
.metadata-save-btn,
.save-tags-btn {
.save-tags-btn,
.append-tags-btn,
.replace-tags-btn {
background: var(--lora-accent) !important;
color: white !important;
border-color: var(--lora-accent) !important;
}
.metadata-save-btn:hover,
.save-tags-btn:hover {
.save-tags-btn:hover,
.append-tags-btn:hover,
.replace-tags-btn:hover {
opacity: 0.9;
}
/* Specific styling for bulk tag action buttons */
.bulk-append-tags-btn {
background: var(--lora-accent) !important;
color: white !important;
border-color: var(--lora-accent) !important;
}
.bulk-replace-tags-btn {
background: var(--lora-warning, #f59e0b) !important;
color: white !important;
border-color: var(--lora-warning, #f59e0b) !important;
}
.bulk-append-tags-btn:hover {
opacity: 0.9;
}
.bulk-replace-tags-btn:hover {
background: var(--lora-warning-dark, #d97706) !important;
}
/* Add Form */
.metadata-add-form {
display: flex;

View File

@@ -63,6 +63,9 @@ export function getApiEndpoints(modelType) {
// Bulk operations
bulkDelete: `/api/${modelType}/bulk-delete`,
// Tag operations
addTags: `/api/${modelType}/add-tags`,
// Move operations (now common for all model types that support move)
moveModel: `/api/${modelType}/move_model`,
@@ -91,6 +94,10 @@ export function getApiEndpoints(modelType) {
metadata: `/api/${modelType}/metadata`,
modelDescription: `/api/${modelType}/model-description`,
// Auto-organize operations
autoOrganize: `/api/${modelType}/auto-organize`,
autoOrganizeProgress: `/api/${modelType}/auto-organize-progress`,
// Model-specific endpoints (will be merged with specific configs)
specific: {}
};

View File

@@ -306,6 +306,34 @@ export class BaseModelApiClient {
}
}
async addTags(filePath, data) {
try {
const response = await fetch(this.apiConfig.endpoints.addTags, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to add tags');
}
const result = await response.json();
if (result.success && result.tags) {
state.virtualScroller.updateSingleItem(filePath, { tags: result.tags });
}
return result;
} catch (error) {
console.error('Error adding tags:', error);
throw error;
}
}
async refreshModels(fullRebuild = false) {
try {
state.loadingManager.showSimpleLoading(
@@ -1002,4 +1030,129 @@ export class BaseModelApiClient {
throw error;
}
}
/**
* Auto-organize models based on current path template settings
* @param {Array} filePaths - Optional array of file paths to organize. If not provided, organizes all models.
* @returns {Promise} - Promise that resolves when the operation is complete
*/
async autoOrganizeModels(filePaths = null) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
try {
// Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type !== 'auto_organize_progress') return;
switch(data.status) {
case 'started':
loading.setProgress(0);
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.starting', { type: operationType }, `Starting auto-organize for ${operationType}...`));
break;
case 'processing':
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
loading.setProgress(percent);
loading.setStatus(
translate('loras.bulkOperations.autoOrganizeProgress.processing', {
processed: data.processed,
total: data.total,
success: data.success,
failures: data.failures,
skipped: data.skipped
}, `Processing (${data.processed}/${data.total}) - ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
);
break;
case 'cleaning':
loading.setProgress(95);
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.cleaning', {}, 'Cleaning up empty directories...'));
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
translate('loras.bulkOperations.autoOrganizeProgress.completed', {
success: data.success,
skipped: data.skipped,
failures: data.failures,
total: data.total
}, `Completed: ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
);
setTimeout(() => {
resolve(data);
}, 1500);
break;
case 'error':
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`));
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
console.error('WebSocket error during auto-organize:', error);
reject(new Error('Connection error'));
};
});
// Start the auto-organize operation
const endpoint = this.apiConfig.endpoints.autoOrganize;
const requestOptions = {
method: filePaths ? 'POST' : 'GET',
headers: filePaths ? { 'Content-Type': 'application/json' } : {}
};
if (filePaths) {
requestOptions.body = JSON.stringify({ file_paths: filePaths });
}
const response = await fetch(endpoint, requestOptions);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to start auto-organize operation');
}
// Wait for the operation to complete via WebSocket
const result = await operationComplete;
// Show appropriate success message based on results
if (result.failures === 0) {
showToast('toast.loras.autoOrganizeSuccess', {
count: result.success,
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
}, 'success');
} else {
showToast('toast.loras.autoOrganizePartialSuccess', {
success: result.success,
failures: result.failures,
total: result.total
}, 'warning');
}
} catch (error) {
console.error('Error during auto-organize:', error);
showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error');
throw error;
} finally {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
}, {
initialMessage: translate('loras.bulkOperations.autoOrganizeProgress.initializing', {}, 'Initializing auto-organize...'),
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
});
}
}

View File

@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
@@ -30,10 +29,7 @@ class CheckpointsPageManager {
}
async initialize() {
// Initialize context menu
new CheckpointContextMenu();
// Initialize common page features
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Checkpoints Manager initialized');
@@ -48,4 +44,4 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize checkpoints page
const checkpointsPage = new CheckpointsPageManager();
await checkpointsPage.initialize();
});
});

View File

@@ -15,17 +15,6 @@ export class BaseContextMenu {
init() {
// Hide menu on regular clicks
document.addEventListener('click', () => this.hideMenu());
// Show menu on right-click on cards
document.addEventListener('contextmenu', (e) => {
const card = e.target.closest(this.cardSelector);
if (!card) {
this.hideMenu();
return;
}
e.preventDefault();
this.showMenu(e.clientX, e.clientY, card);
});
// Handle menu item clicks
this.menu.addEventListener('click', (e) => {

View File

@@ -0,0 +1,117 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { state } from '../../state/index.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText } from '../../utils/i18nHelpers.js';
export class BulkContextMenu extends BaseContextMenu {
constructor() {
super('bulkContextMenu', '.model-card.selected');
this.setupBulkMenuItems();
}
setupBulkMenuItems() {
if (!this.menu) return;
// Update menu items visibility based on current model type
this.updateMenuItemsForModelType();
// Update selected count in header
this.updateSelectedCountHeader();
}
updateMenuItemsForModelType() {
const currentModelType = state.currentPageType;
const config = bulkManager.actionConfig[currentModelType];
if (!config) return;
// Update button visibility based on model type
const addTagsItem = this.menu.querySelector('[data-action="add-tags"]');
const setBaseModelItem = this.menu.querySelector('[data-action="set-base-model"]');
const sendToWorkflowAppendItem = this.menu.querySelector('[data-action="send-to-workflow-append"]');
const sendToWorkflowReplaceItem = this.menu.querySelector('[data-action="send-to-workflow-replace"]');
const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]');
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (sendToWorkflowReplaceItem) {
sendToWorkflowReplaceItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
}
if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
}
if (refreshAllItem) {
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
}
if (moveAllItem) {
moveAllItem.style.display = config.moveAll ? 'flex' : 'none';
}
if (autoOrganizeItem) {
autoOrganizeItem.style.display = config.autoOrganize ? 'flex' : 'none';
}
if (deleteAllItem) {
deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none';
}
if (addTagsItem) {
addTagsItem.style.display = config.addTags ? 'flex' : 'none';
}
if (setBaseModelItem) {
setBaseModelItem.style.display = 'flex'; // Base model editing is available for all model types
}
}
updateSelectedCountHeader() {
const headerElement = this.menu.querySelector('.bulk-context-header');
if (headerElement) {
updateElementText(headerElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
}
}
showMenu(x, y, card) {
this.updateMenuItemsForModelType();
this.updateSelectedCountHeader();
super.showMenu(x, y, card);
}
handleMenuAction(action, menuItem) {
switch (action) {
case 'add-tags':
bulkManager.showBulkAddTagsModal();
break;
case 'set-base-model':
bulkManager.showBulkBaseModelModal();
break;
case 'send-to-workflow-append':
bulkManager.sendAllModelsToWorkflow(false);
break;
case 'send-to-workflow-replace':
bulkManager.sendAllModelsToWorkflow(true);
break;
case 'copy-all':
bulkManager.copyAllModelsSyntax();
break;
case 'refresh-all':
bulkManager.refreshAllMetadata();
break;
case 'move-all':
window.moveManager.showMoveModal('bulk');
break;
case 'auto-organize':
bulkManager.autoOrganizeSelectedModels();
break;
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'clear':
bulkManager.clearSelection();
break;
default:
console.warn(`Unknown bulk action: ${action}`);
}
}
}

View File

@@ -2,4 +2,25 @@ export { LoraContextMenu } from './LoraContextMenu.js';
export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { LoraContextMenu } from './LoraContextMenu.js';
import { RecipeContextMenu } from './RecipeContextMenu.js';
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
// Factory method to create page-specific context menu instances
export function createPageContextMenu(pageType) {
switch (pageType) {
case 'loras':
return new LoraContextMenu();
case 'recipes':
return new RecipeContextMenu();
case 'checkpoints':
return new CheckpointContextMenu();
case 'embeddings':
return new EmbeddingContextMenu();
default:
return null;
}
}

View File

@@ -43,6 +43,14 @@ export class CheckpointsControls extends PageControls {
showDownloadModal: () => {
downloadManager.showDownloadModal();
},
toggleBulkMode: () => {
if (window.bulkManager) {
window.bulkManager.toggleBulkMode();
} else {
console.error('Bulk manager not available');
}
},
// No clearCustomFilter implementation is needed for checkpoints
// as custom filters are currently only used for LoRAs

View File

@@ -43,6 +43,14 @@ export class EmbeddingsControls extends PageControls {
showDownloadModal: () => {
downloadManager.showDownloadModal();
},
toggleBulkMode: () => {
if (window.bulkManager) {
window.bulkManager.toggleBulkMode();
} else {
console.error('Bulk manager not available');
}
},
// No clearCustomFilter implementation is needed for embeddings
// as custom filters are currently only used for LoRAs

View File

@@ -70,7 +70,6 @@ export class PageControls {
async initSidebarManager() {
try {
await this.sidebarManager.initialize(this);
console.log('SidebarManager initialized');
} catch (error) {
console.error('Failed to initialize SidebarManager:', error);
}
@@ -186,12 +185,9 @@ export class PageControls {
duplicatesButton.addEventListener('click', () => this.findDuplicates());
}
if (this.pageType === 'loras') {
// Bulk operations button - LoRAs only
const bulkButton = document.querySelector('[data-action="bulk"]');
if (bulkButton) {
bulkButton.addEventListener('click', () => this.toggleBulkMode());
}
const bulkButton = document.querySelector('[data-action="bulk"]');
if (bulkButton) {
bulkButton.addEventListener('click', () => this.toggleBulkMode());
}
// Favorites filter button handler
@@ -350,14 +346,9 @@ export class PageControls {
}
/**
* Toggle bulk mode (LoRAs only)
* Toggle bulk mode
*/
toggleBulkMode() {
if (this.pageType !== 'loras' || !this.api) {
console.error('Bulk mode is only available for LoRAs');
return;
}
this.api.toggleBulkMode();
}

View File

@@ -9,48 +9,46 @@ import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { translate } from '../../utils/i18nHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
// Add global event delegation handlers
// Add global event delegation handlers using event manager
export function setupModelCardEventDelegation(modelType) {
const gridElement = document.getElementById('modelGrid');
if (!gridElement) return;
// Remove any existing handler first
eventManager.removeHandler('click', 'modelCard-delegation');
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', gridElement._handleModelCardEvent);
// Create event handler with modelType context
const handleModelCardEvent = (event) => handleModelCardEvent_internal(event, modelType);
// Add the event delegation handler
gridElement.addEventListener('click', handleModelCardEvent);
// Store reference to the handler for cleanup
gridElement._handleModelCardEvent = handleModelCardEvent;
// Register model card event delegation with event manager
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
return handleModelCardEvent_internal(event, modelType);
}, {
priority: 60, // Medium priority for model card interactions
targetSelector: '#modelGrid',
skipWhenModalOpen: false // Allow model card interactions even when modals are open (for some actions)
});
}
// Event delegation handler for all model card events
function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element
const card = event.target.closest('.model-card');
if (!card) return;
if (!card) return false; // Continue with other handlers
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-globe')) {
@@ -58,41 +56,42 @@ function handleModelCardEvent_internal(event, modelType) {
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
handleCopyAction(card, modelType);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
getModelApiClient().replaceModelPreview(card.dataset.filepath);
return;
return true; // Stop propagation
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card, modelType);
return;
return true; // Stop propagation
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection)
}
// Helper functions for event handling

View File

@@ -2,10 +2,10 @@
* ModelMetadata.js
* Handles model metadata editing functionality - General version
*/
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
/**
* Set up model name editing functionality
@@ -172,28 +172,8 @@ export function setupBaseModelEditing(filePath) {
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODELS constants
const baseModelCategories = {
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO,
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B
],
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA],
'Other Models': [
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.UNKNOWN
]
};
// Add options from BASE_MODEL_CATEGORIES constants
const baseModelCategories = BASE_MODEL_CATEGORIES;
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {

View File

@@ -4,14 +4,7 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
// Preset tag suggestions
const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
import { PRESET_TAGS } from '../../utils/constants.js';
// Create a named function so we can remove it later
let saveTagsHandler = null;
@@ -139,7 +132,7 @@ export function setupTagEditMode() {
// ...existing helper functions...
/**
* Save tags - 支持LoRA和Checkpoint
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');

View File

@@ -15,6 +15,9 @@ import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js';
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
import { createPageContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.js';
// Core application class
export class AppCore {
@@ -51,9 +54,15 @@ export class AppCore {
window.headerManager = new HeaderManager();
initTheme();
initBackToTop();
// Initialize the bulk manager and context menu only if not on recipes page
if (state.currentPageType !== 'recipes') {
bulkManager.initialize();
// Initialize the bulk manager
bulkManager.initialize();
// Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu);
}
// Initialize the example images manager
exampleImagesManager.initialize();
@@ -62,6 +71,8 @@ export class AppCore {
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
initializeEventManagement();
// Mark as initialized
this.initialized = true;
@@ -88,13 +99,19 @@ export class AppCore {
initializePageFeatures() {
const pageType = this.getPageType();
// Initialize virtual scroll for pages that need it
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
this.initializeContextMenus(pageType);
initializeInfiniteScroll(pageType);
}
return this;
}
// Initialize context menus for the current page
initializeContextMenus(pageType) {
// Create page-specific context menu
window.pageContextMenu = createPageContextMenu(pageType);
}
}
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { EmbeddingContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
@@ -30,10 +29,7 @@ class EmbeddingsPageManager {
}
async initialize() {
// Initialize context menu
new EmbeddingContextMenu();
// Initialize common page features
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Embeddings Manager initialized');

View File

@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
@@ -37,13 +36,10 @@ class LoraPageManager {
}
async initialize() {
// Initialize page-specific components
new LoraContextMenu();
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize common page features (virtual scroll)
// Initialize common page features (including context menus and virtual scroll)
appCore.initializePageFeatures();
}
}

View File

@@ -136,10 +136,10 @@ class BannerService {
const actionsHtml = banner.actions ? banner.actions.map(action => {
const actionAttribute = action.action ? `data-action="${action.action}"` : '';
const href = action.url ? `href="${action.url}"` : '#';
const href = action.url ? `href="${action.url}"` : 'href="#"';
const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : '';
return `<a ${href ? `href="${href}"` : ''} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
return `<a ${href} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
<i class="${action.icon}"></i>
<span>${action.text}</span>
</a>`;

File diff suppressed because it is too large Load Diff

View File

@@ -234,6 +234,32 @@ export class ModalManager {
});
}
// Add bulkAddTagsModal registration
const bulkAddTagsModal = document.getElementById('bulkAddTagsModal');
if (bulkAddTagsModal) {
this.registerModal('bulkAddTagsModal', {
element: bulkAddTagsModal,
onClose: () => {
this.getModal('bulkAddTagsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Register bulkBaseModelModal
const bulkBaseModelModal = document.getElementById('bulkBaseModelModal');
if (bulkBaseModelModal) {
this.registerModal('bulkBaseModelModal', {
element: bulkBaseModelModal,
onClose: () => {
this.getModal('bulkBaseModelModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}

View File

@@ -71,6 +71,8 @@ export const state = {
pageSize: 20,
showFavoritesOnly: false,
duplicatesMode: false,
bulkMode: false,
selectedModels: new Set(),
},
[MODEL_TYPES.CHECKPOINT]: {

View File

@@ -1,156 +0,0 @@
/**
* i18n System Test
* Simple test to verify internationalization functionality
*/
import { i18n } from '../i18n/index.js';
import { initializePageI18n, t, formatFileSize, formatDate, formatNumber } from '../utils/i18nHelpers.js';
import { findUnusedTranslationKeys, findMissingTranslationKeys, extractLeafKeys } from '../i18n/validator.js';
// Mock DOM elements for testing
function createMockDOM() {
// Create a test container
const container = document.createElement('div');
container.innerHTML = `
<div data-i18n="header.appTitle">LoRA Manager</div>
<input data-i18n="header.search.placeholder" data-i18n-target="placeholder" placeholder="Search..." />
<button data-i18n="common.actions.save">Save</button>
<span data-i18n="loras.bulkOperations.selected" data-i18n-params='{"count": 5}'>5 selected</span>
`;
document.body.appendChild(container);
return container;
}
// Test basic translation functionality
function testBasicTranslation() {
console.log('=== Testing Basic Translation ===');
// Test simple translation
const saveText = t('common.actions.save');
console.log(`Save button text: ${saveText}`);
// Test translation with parameters
const selectedText = t('loras.bulkOperations.selected', { count: 3 });
console.log(`Selection text: ${selectedText}`);
// Test non-existent key (should return the key itself)
const missingKey = t('non.existent.key');
console.log(`Missing key: ${missingKey}`);
}
// Test DOM translation
function testDOMTranslation() {
console.log('=== Testing DOM Translation ===');
const container = createMockDOM();
// Apply translations
initializePageI18n();
// Check if translations were applied
const titleElement = container.querySelector('[data-i18n="header.appTitle"]');
const inputElement = container.querySelector('input[data-i18n="header.search.placeholder"]');
const buttonElement = container.querySelector('[data-i18n="common.actions.save"]');
console.log(`Title: ${titleElement.textContent}`);
console.log(`Input placeholder: ${inputElement.placeholder}`);
console.log(`Button: ${buttonElement.textContent}`);
// Clean up
document.body.removeChild(container);
}
// Test formatting functions
function testFormatting() {
console.log('=== Testing Formatting Functions ===');
// Test file size formatting
const sizes = [0, 1024, 1048576, 1073741824];
sizes.forEach(size => {
const formatted = formatFileSize(size);
console.log(`${size} bytes = ${formatted}`);
});
// Test date formatting
const date = new Date('2024-01-15T10:30:00');
const formattedDate = formatDate(date, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
console.log(`Date: ${formattedDate}`);
// Test number formatting
const number = 1234.567;
const formattedNumber = formatNumber(number, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
console.log(`Number: ${formattedNumber}`);
}
// Test language detection
function testLanguageDetection() {
console.log('=== Testing Language Detection ===');
console.log(`Detected language: ${i18n.getCurrentLocale()}`);
console.log(`Is RTL: ${i18n.isRTL()}`);
console.log(`Browser language: ${navigator.language}`);
}
// Test unused translations detection
function testUnusedTranslationsDetection() {
console.log('=== Testing Unused Translations Detection ===');
// Mock used keys
const mockUsedKeys = [
'common.actions.save',
'common.actions.cancel',
'header.appTitle'
];
// Get all translations
const allTranslations = i18n.getTranslations();
// Find unused keys (only considering leaf nodes)
const unusedKeys = findUnusedTranslationKeys(allTranslations, mockUsedKeys);
console.log(`Found ${unusedKeys.length} unused translation keys`);
console.log('First 5 unused keys:', unusedKeys.slice(0, 5));
// Find missing keys
const missingKeys = findMissingTranslationKeys(allTranslations, [
...mockUsedKeys,
'non.existent.key'
]);
console.log(`Found ${missingKeys.length} missing translation keys:`, missingKeys);
}
// Run all tests
function runTests() {
console.log('Starting i18n System Tests...');
console.log('=====================================');
testLanguageDetection();
testBasicTranslation();
testFormatting();
// Only test DOM if we're in a browser environment
if (typeof document !== 'undefined') {
testDOMTranslation();
}
// Test unused translations detection
testUnusedTranslationsDetection();
console.log('=====================================');
console.log('i18n System Tests Completed!');
}
// Export for manual testing
export { runTests };
// Auto-run tests if this module is loaded directly
if (typeof window !== 'undefined' && window.location.search.includes('test=i18n')) {
document.addEventListener('DOMContentLoaded', runTests);
}

View File

@@ -0,0 +1,201 @@
/**
* Centralized manager for handling DOM events across the application
*/
export class EventManager {
constructor() {
// Store registered handlers
this.handlers = new Map();
// Track active modals/states
this.activeStates = {
bulkMode: false,
marqueeActive: false,
modalOpen: false,
nodeSelectorActive: false
};
// Store references to cleanup functions
this.cleanupFunctions = new Map();
}
/**
* Register an event handler with priority and conditional execution
* @param {string} eventType - The DOM event type (e.g., 'click', 'mousedown')
* @param {string} source - Source identifier (e.g., 'bulkManager', 'contextMenu')
* @param {Function} handler - Event handler function
* @param {Object} options - Additional options including priority and conditions
*/
addHandler(eventType, source, handler, options = {}) {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
// Set up the actual DOM listener once
this.setupDOMListener(eventType);
}
const handlerList = this.handlers.get(eventType);
const handlerEntry = {
source,
handler,
priority: options.priority || 0,
options,
// Store cleanup function if provided
cleanup: options.cleanup || null
};
handlerList.push(handlerEntry);
// Sort by priority
handlerList.sort((a, b) => b.priority - a.priority);
return handlerEntry;
}
/**
* Remove an event handler
*/
removeHandler(eventType, source) {
if (!this.handlers.has(eventType)) return;
const handlerList = this.handlers.get(eventType);
// Find and cleanup handler before removing
const handlerToRemove = handlerList.find(h => h.source === source);
if (handlerToRemove && handlerToRemove.cleanup) {
try {
handlerToRemove.cleanup();
} catch (error) {
console.warn(`Error during cleanup for ${source}:`, error);
}
}
const newList = handlerList.filter(h => h.source !== source);
if (newList.length === 0) {
// Remove the DOM listener if no handlers remain
this.cleanupDOMListener(eventType);
this.handlers.delete(eventType);
} else {
this.handlers.set(eventType, newList);
}
}
/**
* Setup actual DOM event listener
*/
setupDOMListener(eventType) {
const listener = (event) => this.handleEvent(eventType, event);
document.addEventListener(eventType, listener);
this._domListeners = this._domListeners || {};
this._domListeners[eventType] = listener;
}
/**
* Clean up DOM event listener
*/
cleanupDOMListener(eventType) {
if (this._domListeners && this._domListeners[eventType]) {
document.removeEventListener(eventType, this._domListeners[eventType]);
delete this._domListeners[eventType];
}
}
/**
* Process an event through registered handlers
*/
handleEvent(eventType, event) {
if (!this.handlers.has(eventType)) return;
const handlers = this.handlers.get(eventType);
for (const {handler, options} of handlers) {
// Apply conditional execution based on app state
if (options.onlyInBulkMode && !this.activeStates.bulkMode) continue;
if (options.onlyWhenMarqueeActive && !this.activeStates.marqueeActive) continue;
if (options.skipWhenModalOpen && this.activeStates.modalOpen) continue;
if (options.skipWhenNodeSelectorActive && this.activeStates.nodeSelectorActive) continue;
if (options.onlyWhenNodeSelectorActive && !this.activeStates.nodeSelectorActive) continue;
// Apply element-based filters
if (options.targetSelector && !this._matchesSelector(event.target, options.targetSelector)) continue;
if (options.excludeSelector && this._matchesSelector(event.target, options.excludeSelector)) continue;
// Apply button filters
if (options.button !== undefined && event.button !== options.button) continue;
try {
// Execute handler
const result = handler(event);
// Stop propagation if handler returns true
if (result === true) break;
} catch (error) {
console.error(`Error in event handler for ${eventType}:`, error);
}
}
}
/**
* Helper function to check if an element matches or is contained within an element matching the selector
* This improves the robustness of the selector matching
*/
_matchesSelector(element, selector) {
if (element.matches && element.matches(selector)) {
return true;
}
if (element.closest && element.closest(selector)) {
return true;
}
return false;
}
/**
* Update application state
*/
setState(state, value) {
if (this.activeStates.hasOwnProperty(state)) {
this.activeStates[state] = value;
} else {
console.warn(`Unknown state: ${state}`);
}
}
/**
* Get current application state
*/
getState(state) {
return this.activeStates[state];
}
/**
* Remove all handlers for a specific source
*/
removeAllHandlersForSource(source) {
const eventTypes = Array.from(this.handlers.keys());
eventTypes.forEach(eventType => {
this.removeHandler(eventType, source);
});
}
/**
* Clean up all event listeners (useful for app teardown)
*/
cleanup() {
const eventTypes = Array.from(this.handlers.keys());
eventTypes.forEach(eventType => {
const handlers = this.handlers.get(eventType);
// Run cleanup functions
handlers.forEach(h => {
if (h.cleanup) {
try {
h.cleanup();
} catch (error) {
console.warn(`Error during cleanup for ${h.source}:`, error);
}
}
});
this.cleanupDOMListener(eventType);
});
this.handlers.clear();
this.cleanupFunctions.clear();
}
}
export const eventManager = new EventManager();

View File

@@ -163,3 +163,34 @@ export const NODE_TYPE_ICONS = {
// Default ComfyUI node color when bgcolor is null
export const DEFAULT_NODE_COLOR = "#353535";
// Base model categories for organized selection
export const BASE_MODEL_CATEGORIES = {
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO,
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B
],
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA],
'Other Models': [
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.UNKNOWN
]
};
// Preset tag suggestions
export const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',
'realistic', 'anime', 'toon', 'furry',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];

View File

@@ -0,0 +1,226 @@
/**
* Event Management Initialization
*
* This module handles the initialization and coordination of the centralized
* event management system across the application.
*/
import { eventManager } from './EventManager.js';
import { modalManager } from '../managers/ModalManager.js';
import { state } from '../state/index.js';
/**
* Initialize the centralized event management system
*/
export function initializeEventManagement() {
console.log('Initializing centralized event management system...');
// Initialize modal state tracking
initializeModalStateTracking();
// Set up global error handling for event handlers
setupGlobalEventErrorHandling();
// Set up cleanup on page unload
setupPageUnloadCleanup();
// Register global event handlers that need coordination
registerContextMenuEvents();
registerGlobalClickHandlers();
console.log('Event management system initialized successfully');
}
/**
* Initialize modal state tracking with the event manager
*/
function initializeModalStateTracking() {
// Override modalManager methods to update event manager state
const originalShowModal = modalManager.showModal.bind(modalManager);
const originalCloseModal = modalManager.closeModal.bind(modalManager);
const originalIsAnyModalOpen = modalManager.isAnyModalOpen.bind(modalManager);
modalManager.showModal = function(...args) {
const result = originalShowModal(...args);
eventManager.setState('modalOpen', this.isAnyModalOpen());
return result;
};
modalManager.closeModal = function(...args) {
const result = originalCloseModal(...args);
eventManager.setState('modalOpen', this.isAnyModalOpen());
return result;
};
}
/**
* Set up global error handling for event handlers
*/
function setupGlobalEventErrorHandling() {
// Override the handleEvent method to add better error handling
const originalHandleEvent = eventManager.handleEvent.bind(eventManager);
eventManager.handleEvent = function(eventType, event) {
try {
return originalHandleEvent(eventType, event);
} catch (error) {
console.error(`Critical error in event management for ${eventType}:`, error);
// Don't let event handling errors crash the app
}
};
}
/**
* Set up cleanup when the page is unloaded
*/
function setupPageUnloadCleanup() {
window.addEventListener('beforeunload', () => {
console.log('Cleaning up event management system...');
eventManager.cleanup();
});
}
/**
* Register context menu related events with proper priority
*/
function registerContextMenuEvents() {
eventManager.addHandler('contextmenu', 'contextMenu-coordination', (e) => {
const card = e.target.closest('.model-card');
if (!card) {
// Hide all menus if not right-clicking on a card
window.pageContextMenu?.hideMenu();
window.bulkManager?.bulkContextMenu?.hideMenu();
return false;
}
e.preventDefault();
// Hide all menus first
window.pageContextMenu?.hideMenu();
window.bulkManager?.bulkContextMenu?.hideMenu();
// Determine which menu to show based on bulk mode and selection state
if (state.bulkMode && card.classList.contains('selected')) {
// Show bulk menu for selected cards in bulk mode
window.bulkManager?.bulkContextMenu?.showMenu(e.clientX, e.clientY, card);
} else if (!state.bulkMode) {
// Show regular menu when not in bulk mode
window.pageContextMenu?.showMenu(e.clientX, e.clientY, card);
}
// Don't show any menu for unselected cards in bulk mode
return true; // Stop propagation
}, {
priority: 200, // Higher priority than bulk manager events
skipWhenModalOpen: true
});
}
/**
* Register global click handlers for context menu hiding
*/
function registerGlobalClickHandlers() {
eventManager.addHandler('click', 'contextMenu-hide', (e) => {
// Hide context menus when clicking elsewhere
if (!e.target.closest('.context-menu')) {
window.pageContextMenu?.hideMenu();
window.bulkManager?.bulkContextMenu?.hideMenu();
}
return false; // Allow other handlers to process
}, {
priority: 50,
skipWhenModalOpen: true
});
}
/**
* Register common application-wide event handlers
*/
export function registerGlobalEventHandlers() {
// Escape key handler for closing modals/panels
eventManager.addHandler('keydown', 'global-escape', (e) => {
if (e.key === 'Escape') {
// Check if any modal is open and close it
if (eventManager.getState('modalOpen')) {
modalManager.closeCurrentModal();
return true; // Stop propagation
}
// Check if node selector is active and close it
if (eventManager.getState('nodeSelectorActive')) {
// The node selector should handle its own escape key
return false; // Continue with other handlers
}
}
return false; // Continue with other handlers
}, {
priority: 250 // Very high priority for escape handling
});
// Global focus management
eventManager.addHandler('focusin', 'global-focus', (e) => {
// Track focus for accessibility and keyboard navigation
window.lastFocusedElement = e.target;
}, {
priority: 10 // Low priority for tracking
});
// Global click tracking for analytics (if needed)
eventManager.addHandler('click', 'global-analytics', (e) => {
// Track clicks for usage analytics
// This runs last and doesn't interfere with other handlers
trackUserInteraction(e);
}, {
priority: 1 // Lowest priority
});
}
/**
* Example analytics tracking function
*/
function trackUserInteraction(event) {
// Implement analytics tracking here
// This is just a placeholder
if (window.analytics && typeof window.analytics.track === 'function') {
const element = event.target;
const elementInfo = {
tag: element.tagName.toLowerCase(),
class: element.className,
id: element.id,
text: element.textContent?.substring(0, 50)
};
window.analytics.track('ui_interaction', elementInfo);
}
}
/**
* Utility function to check if event management is properly initialized
*/
export function isEventManagementInitialized() {
return eventManager && typeof eventManager.addHandler === 'function';
}
/**
* Get event management statistics for debugging
*/
export function getEventManagementStats() {
const stats = {
totalEventTypes: eventManager.handlers.size,
totalHandlers: 0,
handlersBySource: {},
currentStates: { ...eventManager.activeStates }
};
eventManager.handlers.forEach((handlers, eventType) => {
stats.totalHandlers += handlers.length;
handlers.forEach(handler => {
if (!stats.handlersBySource[handler.source]) {
stats.handlersBySource[handler.source] = 0;
}
stats.handlersBySource[handler.source]++;
});
});
return stats;
}

View File

@@ -2,6 +2,7 @@ import { translate } from './i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
import { eventManager } from './EventManager.js';
/**
* Utility function to copy text to clipboard with fallback for older browsers
@@ -528,12 +529,15 @@ function showNodeSelector(nodes, loraSyntax, replaceMode, syntaxType) {
selector.style.display = 'block';
nodeSelectorState.isActive = true;
// Setup event listeners with proper cleanup
// Update event manager state
eventManager.setState('nodeSelectorActive', true);
// Setup event listeners with proper cleanup through event manager
setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, syntaxType);
}
/**
* Setup event listeners for node selector
* Setup event listeners for node selector using event manager
* @param {HTMLElement} selector - The selector element
* @param {Object} nodes - Registry nodes data
* @param {string} loraSyntax - The LoRA syntax to send
@@ -544,17 +548,21 @@ function setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, synta
// Clean up any existing event listeners
cleanupNodeSelectorEvents();
// Handle clicks outside to close
nodeSelectorState.clickHandler = (e) => {
// Register click outside handler with event manager
eventManager.addHandler('click', 'nodeSelector-outside', (e) => {
if (!selector.contains(e.target)) {
hideNodeSelector();
return true; // Stop propagation
}
};
}, {
priority: 200, // High priority to handle before other click handlers
onlyWhenNodeSelectorActive: true
});
// Handle node selection
nodeSelectorState.selectorClickHandler = async (e) => {
// Register node selection handler with event manager
eventManager.addHandler('click', 'nodeSelector-selection', async (e) => {
const nodeItem = e.target.closest('.node-item');
if (!nodeItem) return;
if (!nodeItem) return false; // Continue with other handlers
e.stopPropagation();
@@ -571,33 +579,25 @@ function setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, synta
}
hideNodeSelector();
};
// Add event listeners with a small delay to prevent immediate triggering
setTimeout(() => {
if (nodeSelectorState.isActive) {
document.addEventListener('click', nodeSelectorState.clickHandler);
selector.addEventListener('click', nodeSelectorState.selectorClickHandler);
}
}, 100);
return true; // Stop propagation
}, {
priority: 150, // High priority but lower than outside click
targetSelector: '#nodeSelector',
onlyWhenNodeSelectorActive: true
});
}
/**
* Clean up node selector event listeners
*/
function cleanupNodeSelectorEvents() {
if (nodeSelectorState.clickHandler) {
document.removeEventListener('click', nodeSelectorState.clickHandler);
nodeSelectorState.clickHandler = null;
}
// Remove event handlers from event manager
eventManager.removeHandler('click', 'nodeSelector-outside');
eventManager.removeHandler('click', 'nodeSelector-selection');
if (nodeSelectorState.selectorClickHandler) {
const selector = document.getElementById('nodeSelector');
if (selector) {
selector.removeEventListener('click', nodeSelectorState.selectorClickHandler);
}
nodeSelectorState.selectorClickHandler = null;
}
// Clear legacy references
nodeSelectorState.clickHandler = null;
nodeSelectorState.selectorClickHandler = null;
}
/**
@@ -613,6 +613,9 @@ function hideNodeSelector() {
// Clean up event listeners
cleanupNodeSelectorEvents();
nodeSelectorState.isActive = false;
// Update event manager state
eventManager.setState('nodeSelectorActive', false);
}
/**
@@ -651,11 +654,21 @@ function positionNearMouse(element) {
element.style.visibility = 'visible';
}
// Track mouse position for node selector positioning
document.addEventListener('mousemove', (e) => {
window.lastMouseX = e.clientX;
window.lastMouseY = e.clientY;
});
/**
* Initialize mouse tracking for positioning elements
*/
export function initializeMouseTracking() {
// Register mouse tracking with event manager
eventManager.addHandler('mousemove', 'uiHelpers-mouseTracking', (e) => {
window.lastMouseX = e.clientX;
window.lastMouseY = e.clientY;
}, {
priority: 10 // Low priority since this is just tracking
});
}
// Initialize mouse tracking when module loads
initializeMouseTracking();
/**
* Opens the example images folder for a specific model

View File

@@ -44,6 +44,46 @@
</div>
</div>
<div id="bulkContextMenu" class="context-menu">
<div class="bulk-context-header">
<i class="fas fa-th-large"></i>
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-append">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-replace">
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
</div>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div>
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</div>
<div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div>
<div class="context-menu-item" data-action="auto-organize">
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-all">
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="clear">
<i class="fas fa-times"></i> <span>{{ t('loras.bulkOperations.clear') }}</span>
</div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector">
<div class="nsfw-level-header">
<h3>{{ t('modals.contentRating.title') }}</h3>

View File

@@ -98,33 +98,4 @@
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div>
<!-- Add bulk operations panel (initially hidden) -->
<div id="bulkOperationsPanel" class="bulk-operations-panel hidden">
<div class="bulk-operations-header">
<span id="selectedCount" class="selectable-count" title="{{ t('loras.bulkOperations.viewSelected') }}">
0 {{ t('loras.bulkOperations.selectedSuffix') }} <i class="fas fa-caret-down dropdown-caret"></i>
</span>
<div class="bulk-operations-actions">
<button data-action="send-to-workflow" title="{{ t('loras.bulkOperations.sendToWorkflow') }}">
<i class="fas fa-arrow-right"></i> <span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
</button>
<button data-action="copy-all" title="{{ t('loras.bulkOperations.copyAll') }}">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</button>
<button data-action="refresh-all" title="{{ t('loras.bulkOperations.refreshAll') }}">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</button>
<button data-action="move-all" title="{{ t('loras.bulkOperations.moveAll') }}">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</button>
<button data-action="delete-all" title="{{ t('loras.bulkOperations.deleteAll') }}" class="danger-btn">
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
</button>
<button data-action="clear" title="{{ t('loras.bulkOperations.clear') }}">
<i class="fas fa-times"></i> <span>{{ t('loras.bulkOperations.clear') }}</span>
</button>
</div>
</div>
</div>

View File

@@ -9,4 +9,6 @@
{% include 'components/modals/relink_civitai_modal.html' %}
{% include 'components/modals/example_access_modal.html' %}
{% include 'components/modals/download_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %}
{% include 'components/modals/bulk_base_model_modal.html' %}

View File

@@ -0,0 +1,38 @@
<div id="bulkAddTagsModal" class="modal" style="display: none;">
<div class="modal-content modal-content-large">
<div class="modal-header">
<h2>{{ t('modals.bulkAddTags.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkAddTagsModal')">&times;</span>
</div>
<div class="modal-body">
<div class="bulk-add-tags-info">
<p>{{ t('modals.bulkAddTags.description') }} <span id="bulkAddTagsCount">0</span> {{ t('modals.bulkAddTags.models') }}</p>
</div>
<div class="model-tags-container bulk-tags-container edit-mode">
<div class="metadata-edit-container" style="display: block;">
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>{{ t('modals.bulkAddTags.tagsToAdd') }}</label>
</div>
<div class="metadata-items" id="bulkTagsItems">
<!-- Tags will be added here dynamically -->
</div>
<div class="metadata-edit-controls">
<button class="append-tags-btn bulk-append-tags-btn" title="{{ t('modals.bulkAddTags.appendTags') }}">
<i class="fas fa-plus"></i> {{ t('modals.bulkAddTags.appendTags') }}
</button>
<button class="replace-tags-btn bulk-replace-tags-btn" title="{{ t('modals.bulkAddTags.replaceTags') }}">
<i class="fas fa-exchange-alt"></i> {{ t('modals.bulkAddTags.replaceTags') }}
</button>
</div>
<div class="metadata-add-form">
<input type="text" class="metadata-input bulk-metadata-input" placeholder="{{ t('modals.bulkAddTags.placeholder') }}">
</div>
<!-- Suggestions dropdown will be added here -->
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,38 @@
<div id="bulkBaseModelModal" class="modal" style="display: none;">
<div class="modal-content modal-content">
<div class="modal-header">
<h2>{{ t('modals.bulkBaseModel.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkBaseModelModal')">&times;</span>
</div>
<div class="modal-body">
<div class="bulk-add-tags-info">
<p>{{ t('modals.bulkBaseModel.description') }} <span id="bulkBaseModelCount">0</span> {{ t('modals.bulkBaseModel.models') }}</p>
</div>
<div class="model-tags-container bulk-tags-container edit-mode">
<div class="metadata-edit-container" style="display: block;">
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>{{ t('modals.bulkBaseModel.selectBaseModel') }}</label>
</div>
<div class="setting-control">
<div class="select-control">
<select id="bulkBaseModelSelect" class="bulk-base-model-select">
<!-- Options will be populated dynamically -->
</select>
</div>
</div>
<div class="metadata-edit-controls">
<button class="metadata-save-btn bulk-save-base-model-btn" onclick="bulkManager.saveBulkBaseModel()">
<i class="fas fa-save"></i> {{ t('modals.bulkBaseModel.save') }}
</button>
<button class="btn btn-secondary" onclick="modalManager.closeModal('bulkBaseModelModal')">
{{ t('modals.bulkBaseModel.cancel') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -16,8 +16,6 @@
<div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here -->
</div>
<!-- Bulk operations panel will be inserted here by JavaScript -->
{% endblock %}
{% block overlay %}

View File

@@ -2,6 +2,61 @@
---
### v0.8.25
* **LoRA List Reordering**
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
- Keyboard Shortcuts:
- Arrow keys: Navigate between LoRAs
- Ctrl/Cmd + Arrow: Move selected LoRA up/down
- Ctrl/Cmd + Home/End: Move selected LoRA to top/bottom
- Delete/Backspace: Remove selected LoRA
- Context Menu: Right-click for quick actions like Move Up, Move Down, Move to Top, Move to Bottom.
* **Bulk Operations for Checkpoints & Embeddings**
- Bulk Mode: Select multiple checkpoints or embeddings for batch actions.
- Bulk Refresh: Update Civitai metadata for selected models.
- Bulk Delete: Remove multiple models at once.
- Bulk Move (Embeddings): Move selected embeddings to a different folder.
* **New Setting: Auto Download Example Images**
- Automatically fetch example images for models missing previews (requires download location to be set). Enabled by default.
* **General Improvements**
- Various user experience enhancements and stability fixes.
### v0.8.22
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
* **Custom Download Path Templates & Base Model Mapping** - Implemented UI settings for configuring download path templates and base model path mappings, allowing customized model organization and storage location when downloading models via LM Civitai Extension.
* **LM Civitai Extension Enhancements** - Improved concurrent download performance and stability, with new support for canceling active downloads directly from the extension interface.
* **Update Feature** - Added update functionality, allowing users to update LoRA Manager to the latest release version directly from the LoRA Manager UI.
* **Bulk Operations: Refresh All** - Added bulk refresh functionality, allowing users to update Civitai metadata across multiple LoRAs.
### v0.8.20
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding
* **WanVideo Integration** - Introduced WanVideo Lora Select (LoraManager) node compatible with ComfyUI-WanVideoWrapper for streamlined lora usage in video workflows, including a template workflow to help you get started quickly
### v0.8.19
* **Analytics Dashboard** - Added new Statistics page providing comprehensive visual analysis of model collection and usage patterns for better library insights
* **Target Node Selection** - Enhanced workflow integration with intelligent target choosing when sending LoRAs/recipes to workflows with multiple loader/stacker nodes; a visual selector now appears showing node color, type, ID, and title for precise targeting
* **Enhanced NSFW Controls** - Added support for setting NSFW levels on recipes with automatic content blurring based on user preferences
* **Customizable Card Display** - New display settings allowing users to choose whether card information and action buttons are always visible or only revealed on hover
* **Expanded Compatibility** - Added support for efficiency-nodes-comfyui in Save Recipe and Save Image nodes, plus fixed compatibility with ComfyUI_Custom_Nodes_AlekPet
### v0.8.18
* **Custom Example Images** - Added ability to import your own example images for LoRAs and checkpoints with automatic metadata extraction from embedded information
* **Enhanced Example Management** - New action buttons to set specific examples as previews or delete custom examples
* **Improved Duplicate Detection** - Enhanced "Find Duplicates" with hash verification feature to eliminate false positives when identifying duplicate models
* **Tag Management** - Added tag editing functionality allowing users to customize and manage model tags
* **Advanced Selection Controls** - Implemented Ctrl+A shortcut for quickly selecting all filtered LoRAs, automatically entering bulk mode when needed
* **Note**: Cache file functionality temporarily disabled pending rework
### v0.8.17
* **Duplicate Model Detection** - Added "Find Duplicates" functionality for LoRAs and checkpoints using model file hash detection, enabling convenient viewing and batch deletion of duplicate models
* **Enhanced URL Recipe Imports** - Optimized import recipe via URL functionality using CivitAI API calls instead of web scraping, now supporting all rated images (including NSFW) for recipe imports
* **Improved TriggerWord Control** - Enhanced TriggerWord Toggle node with new default_active switch to set the initial state (active/inactive) when trigger words are added
* **Centralized Example Management** - Added "Migrate Existing Example Images" feature to consolidate downloaded example images from model folders into central storage with customizable naming patterns
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
### v0.8.16
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart