Compare commits

..

11 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
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
27 changed files with 643 additions and 379 deletions

View File

@@ -34,6 +34,10 @@ 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.

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

@@ -324,8 +324,18 @@
"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"
"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",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"copyAll": "Copy All Syntax",
"refreshAll": "Refresh All Metadata",
"moveAll": "Move All to Folder",
"autoOrganize": "Auto-Organize Selected",
"deleteAll": "Delete All Models",
"clear": "Clear Selection"
"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",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"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"
"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",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"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"
"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",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行",
"deleteAll": "すべてのモデルを削除",
"clear": "選択をクリア"
"clear": "選択をクリア",
"autoOrganizeProgress": {
"initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...",
"processing": "処理中({processed}/{total}- {success} 移動、{skipped} スキップ、{failures} 失敗",
"cleaning": "空のディレクトリをクリーンアップ中...",
"completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗",
"complete": "自動整理が完了しました",
"error": "エラー:{error}"
}
},
"contextMenu": {
"refreshMetadata": "Civitaiデータを更新",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택",
"deleteAll": "모든 모델 삭제",
"clear": "선택 지우기"
"clear": "선택 지우기",
"autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...",
"processing": "처리 중 ({processed}/{total}) - {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
"cleaning": "빈 디렉토리 정리 중...",
"completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
"complete": "자동 정리 완료",
"error": "오류: {error}"
}
},
"contextMenu": {
"refreshMetadata": "Civitai 데이터 새로고침",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные",
"deleteAll": "Удалить все модели",
"clear": "Очистить выбор"
"clear": "Очистить выбор",
"autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...",
"processing": "Обработка ({processed}/{total}) — {success} перемещено, {skipped} пропущено, {failures} не удалось",
"cleaning": "Очистка пустых директорий...",
"completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось",
"complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}"
}
},
"contextMenu": {
"refreshMetadata": "Обновить данные Civitai",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"copyAll": "复制全部语法",
"refreshAll": "刷新全部元数据",
"moveAll": "全部移动到文件夹",
"autoOrganize": "自动整理所选模型",
"deleteAll": "删除所有模型",
"clear": "清除选择"
"clear": "清除选择",
"autoOrganizeProgress": {
"initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...",
"processing": "处理中({processed}/{total}- 已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"cleaning": "正在清理空文件夹...",
"completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"complete": "自动整理已完成",
"error": "错误:{error}"
}
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 数据",
@@ -942,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}",

View File

@@ -324,8 +324,18 @@
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型",
"deleteAll": "刪除全部模型",
"clear": "清除選取"
"clear": "清除選取",
"autoOrganizeProgress": {
"initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...",
"processing": "處理中({processed}/{total}- 已移動 {success},已略過 {skipped},失敗 {failures}",
"cleaning": "正在清理空資料夾...",
"completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}",
"complete": "自動整理完成",
"error": "錯誤:{error}"
}
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 資料",
@@ -942,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}",

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

@@ -56,6 +56,7 @@ class BaseModelRoutes(ABC):
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
@@ -773,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():
@@ -791,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)
@@ -809,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,
@@ -849,7 +872,8 @@ class BaseModelRoutes(ABC):
'processed': 0,
'success': 0,
'failures': 0,
'skipped': 0
'skipped': 0,
'operation_type': operation_type
})
# Process models in batches
@@ -980,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
@@ -995,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
@@ -1014,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
}
}
@@ -1047,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

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

View File

@@ -94,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

@@ -1030,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

@@ -33,6 +33,7 @@ export class BulkContextMenu extends BaseContextMenu {
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) {
@@ -50,6 +51,9 @@ export class BulkContextMenu extends BaseContextMenu {
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';
}
@@ -97,6 +101,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'move-all':
window.moveManager.showMoveModal('bulk');
break;
case 'auto-organize':
bulkManager.autoOrganizeSelectedModels();
break;
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;

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

@@ -185,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
@@ -349,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

@@ -54,13 +54,15 @@ export class AppCore {
window.headerManager = new HeaderManager();
initTheme();
initBackToTop();
// Initialize the bulk manager
bulkManager.initialize();
// Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu);
// Initialize the bulk manager and context menu only if not on recipes page
if (state.currentPageType !== 'recipes') {
bulkManager.initialize();
// Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu);
}
// Initialize the example images manager
exampleImagesManager.initialize();

View File

@@ -2,7 +2,7 @@ import { state, getCurrentPageState } from '../state/index.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { PRESET_TAGS, BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { eventManager } from '../utils/EventManager.js';
@@ -34,6 +34,7 @@ export class BulkManager {
copyAll: true,
refreshAll: true,
moveAll: true,
autoOrganize: true,
deleteAll: true
},
[MODEL_TYPES.EMBEDDING]: {
@@ -42,6 +43,7 @@ export class BulkManager {
copyAll: false,
refreshAll: true,
moveAll: true,
autoOrganize: true,
deleteAll: true
},
[MODEL_TYPES.CHECKPOINT]: {
@@ -50,12 +52,16 @@ export class BulkManager {
copyAll: false,
refreshAll: true,
moveAll: false,
autoOrganize: true,
deleteAll: true
}
};
}
initialize() {
// Do not initialize on recipes page
if (state.currentPageType === 'recipes') return;
// Register with event manager for coordinated event handling
this.registerEventHandlers();
@@ -962,6 +968,35 @@ export class BulkManager {
}
}
/**
* Auto-organize selected models based on current path template settings
*/
async autoOrganizeSelectedModels() {
if (state.selectedModels.size === 0) {
showToast('toast.loras.noModelsSelected', {}, 'error');
return;
}
try {
// Get selected file paths
const filePaths = Array.from(state.selectedModels);
// Get the API client for the current model type
const apiClient = getModelApiClient();
// Call the auto-organize method with selected file paths
await apiClient.autoOrganizeModels(filePaths);
setTimeout(() => {
resetAndReload(true);
}, 1000);
} catch (error) {
console.error('Error during bulk auto-organize:', error);
showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error');
}
}
/**
* Handle marquee start through event manager
*/

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

@@ -189,7 +189,8 @@ export const BASE_MODEL_CATEGORIES = {
// Preset tag suggestions
export const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',
'poses', 'background', 'vehicle', 'buildings',
'character', 'style', 'concept', 'clothing',
'realistic', 'anime', 'toon', 'furry',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];

View File

@@ -71,6 +71,9 @@
<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>