feat: implement batch import recipe functionality (frontend + backend fixes)

Backend fixes:
- Add missing API route for /api/lm/recipes/batch-import/progress (GET)
- Add missing API route for /api/lm/recipes/batch-import/directory (POST)
- Add missing API route for /api/lm/recipes/browse-directory (POST)
- Register WebSocket endpoint for batch import progress
- Fix skip_no_metadata default value (True -> False) to allow no-LoRA imports
- Add items array to BatchImportProgress.to_dict() for detailed results

Frontend implementation:
- Create BatchImportManager.js with complete batch import workflow
- Add directory browser UI for selecting folders
- Add batch import modal with URL list and directory input modes
- Implement real-time progress tracking (WebSocket + HTTP polling)
- Add results summary with success/failed/skipped statistics
- Add expandable details view showing individual item status
- Auto-refresh recipe list after import completion

UI improvements:
- Add spinner animation for importing status
- Simplify results summary UI to match progress stats styling
- Fix current item text alignment
- Fix dark theme styling for directory browser button
- Fix batch import button styling consistency

Translations:
- Add batch import related i18n keys to all locale files
- Run sync_translation_keys.py to sync all translations

Fixes:
- Batch import now allows images without LoRAs (matches single import behavior)
- Progress endpoint now returns complete items array with status details
- Results view correctly displays skipped items with error messages
This commit is contained in:
Will Miao
2026-03-14 21:17:36 +08:00
parent f86651652c
commit ee466113d5
24 changed files with 2791 additions and 145 deletions

View File

@@ -0,0 +1,153 @@
# Recipe Batch Import Feature Design
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
├─────────────────────────────────────────────────────────────────┤
│ BatchImportManager.js │
│ ├── InputCollector (收集URL列表/目录路径) │
│ ├── ConcurrencyController (自适应并发控制) │
│ ├── ProgressTracker (进度追踪) │
│ └── ResultAggregator (结果汇总) │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_modal.html │
│ └── 批量导入UI组件 │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_progress.css │
│ └── 进度显示样式 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
├─────────────────────────────────────────────────────────────────┤
│ py/routes/handlers/recipe_handlers.py │
│ ├── start_batch_import() - 启动批量导入 │
│ ├── get_batch_import_progress() - 查询进度 │
│ └── cancel_batch_import() - 取消导入 │
├─────────────────────────────────────────────────────────────────┤
│ py/services/batch_import_service.py │
│ ├── 自适应并发执行 │
│ ├── 结果汇总 │
│ └── WebSocket进度广播 │
└─────────────────────────────────────────────────────────────────┘
```
## API Endpoints
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
## Backend Implementation Details
### BatchImportService
Location: `py/services/batch_import_service.py`
Key classes:
- `BatchImportItem`: Dataclass for individual import item
- `BatchImportProgress`: Dataclass for tracking progress
- `BatchImportService`: Main service class
Features:
- Adaptive concurrency control (adjusts based on success/failure rate)
- WebSocket progress broadcasting
- Graceful error handling (individual failures don't stop the batch)
- Result aggregation
### WebSocket Message Format
```json
{
"type": "batch_import_progress",
"operation_id": "xxx",
"total": 50,
"completed": 23,
"success": 21,
"failed": 2,
"skipped": 0,
"current_item": "image_024.png",
"status": "running"
}
```
### Input Types
1. **URL List**: Array of URLs (http/https)
2. **Local Paths**: Array of local file paths
3. **Directory**: Path to directory with optional recursive flag
### Error Handling
- Invalid URLs/paths: Skip and record error
- Download failures: Record error, continue
- Metadata extraction failures: Mark as "no metadata"
- Duplicate detection: Option to skip duplicates
## Frontend Implementation Details (TODO)
### UI Components
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
2. **ProgressDisplay**: Real-time progress bar and status
3. **ResultsSummary**: Final results with success/failure breakdown
### Adaptive Concurrency Controller
```javascript
class AdaptiveConcurrencyController {
constructor(options = {}) {
this.minConcurrency = options.minConcurrency || 1;
this.maxConcurrency = options.maxConcurrency || 5;
this.currentConcurrency = options.initialConcurrency || 3;
}
adjustConcurrency(taskDuration, success) {
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
}
if (!success || taskDuration > 10000) {
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
}
return this.currentConcurrency;
}
}
```
## File Structure
```
Backend (implemented):
├── py/services/batch_import_service.py # 后端服务
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
├── tests/services/test_batch_import_service.py # 单元测试
└── tests/routes/test_batch_import_routes.py # API集成测试
Frontend (TODO):
├── static/js/managers/BatchImportManager.js # 主管理器
├── static/js/managers/batch/ # 子模块
│ ├── ConcurrencyController.js # 并发控制
│ ├── ProgressTracker.js # 进度追踪
│ └── ResultAggregator.js # 结果汇总
├── static/css/components/batch-import-modal.css # 样式
└── templates/components/batch_import_modal.html # Modal模板
```
## Implementation Status
- [x] Backend BatchImportService
- [x] Backend API handlers
- [x] WebSocket progress broadcasting
- [x] Unit tests
- [x] Integration tests
- [ ] Frontend BatchImportManager
- [ ] Frontend UI components
- [ ] E2E tests

View File

@@ -321,6 +321,12 @@ npm run test:coverage
---
## Documentation
- **[metadata.json Schema Documentation](docs/metadata-json-schema.md)** — Complete reference for the `.metadata.json` sidecar file format, including all fields, types, and examples for LoRA, Checkpoint, and Embedding models.
---
## Contributing
Thank you for your interest in contributing to ComfyUI LoRA Manager! As this project is currently in its early stages and undergoing rapid development and refactoring, we are temporarily not accepting pull requests.

View File

@@ -729,6 +729,64 @@
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
"importFailed": "Import fehlgeschlagen: {message}",
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums"
"folderTreeError": "Fehler beim Laden des Ordnerbaums",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "Keine Modelle ausgewählt",

View File

@@ -733,33 +733,55 @@
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlsMode": "URLs / Paths",
"directoryMode": "Directory",
"urlsDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"directoryLabel": "Directory Path",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"addTagPlaceholder": "Add a tag",
"addTag": "Add",
"noTags": "No tags added",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing Recipes...",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Batch import cancelled",
"completed": "Import Completed",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
@@ -1474,7 +1496,14 @@
"recipeSaveFailed": "Failed to save recipe: {error}",
"importFailed": "Import failed: {message}",
"folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree"
"folderTreeError": "Error loading folder tree",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}"
},
"models": {
"noModelsSelected": "No models selected",

View File

@@ -729,6 +729,64 @@
"failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "Error al guardar receta: {error}",
"importFailed": "Importación falló: {message}",
"folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error cargando árbol de carpetas"
"folderTreeError": "Error cargando árbol de carpetas",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "No hay modelos seleccionados",

View File

@@ -729,6 +729,64 @@
"failed": "Échec de la réparation de la recette : {message}",
"missingId": "Impossible de réparer la recette : ID de recette manquant"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
"importFailed": "Échec de l'importation : {message}",
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers"
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "Aucun modèle sélectionné",

View File

@@ -729,6 +729,64 @@
"failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
"importFailed": "הייבוא נכשל: {message}",
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות"
"folderTreeError": "שגיאה בטעינת עץ התיקיות",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "לא נבחרו מודלים",

View File

@@ -729,6 +729,64 @@
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー"
"folderTreeError": "フォルダツリー読み込みエラー",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "モデルが選択されていません",

View File

@@ -729,6 +729,64 @@
"failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "레시피 저장 실패: {error}",
"importFailed": "가져오기 실패: {message}",
"folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류"
"folderTreeError": "폴더 트리 로딩 오류",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "선택된 모델이 없습니다",

View File

@@ -729,6 +729,64 @@
"failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
"importFailed": "Импорт не удался: {message}",
"folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок"
"folderTreeError": "Ошибка загрузки дерева папок",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "Модели не выбраны",

View File

@@ -722,7 +722,7 @@
"getInfoFailed": "获取缺失 LoRA 信息失败",
"prepareError": "准备下载 LoRA 时出错:{message}"
},
"repair": {
"repair": {
"starting": "正在修复配方元数据...",
"success": "配方元数据修复成功",
"skipped": "配方已是最新版本,无需修复",
@@ -733,33 +733,55 @@
"batchImport": {
"title": "批量导入配方",
"action": "批量导入",
"urlsMode": "URL / 路径",
"directoryMode": "目录",
"urlsDescription": "输入图片 URL 或本地文件路径(每行一个)。每个将作为配方导入。",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"urlsLabel": "图片 URL 或本地路径",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"directoryLabel": "目录路径",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "/图片/文件夹/路径",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "标签(可选,应用于所有配方)",
"addTagPlaceholder": "添加标签",
"addTag": "添加",
"noTags": "未添加标签",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "跳过无元数据的图片",
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
"start": "[TODO: Translate] Start Import",
"startImport": "开始导入",
"importing": "正在导入配方...",
"progress": "进度",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "当前",
"preparing": "准备中...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "取消",
"cancelled": "批量导入已取消",
"completed": "导入完成",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "成功导入 {count} 个配方",
"successCount": "成功",
"failedCount": "失败",
"skippedCount": "跳过",
"totalProcessed": "总计处理",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "请至少输入一个 URL 或路径",
"enterDirectory": "请输入目录路径",
@@ -1474,7 +1496,14 @@
"recipeSaveFailed": "保存配方失败:{error}",
"importFailed": "导入失败:{message}",
"folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错"
"folderTreeError": "加载文件夹树出错",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "未选中模型",

View File

@@ -729,6 +729,64 @@
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
}
},
"checkpoints": {
@@ -1438,7 +1496,14 @@
"recipeSaveFailed": "儲存配方失敗:{error}",
"importFailed": "匯入失敗:{message}",
"folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤"
"folderTreeError": "載入資料夾樹狀結構錯誤",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
},
"models": {
"noModelsSelected": "未選擇模型",

View File

@@ -9,6 +9,7 @@ import re
import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
from aiohttp import web
@@ -89,6 +90,8 @@ class RecipeHandlerSet:
"start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import,
"start_directory_import": self.batch_import.start_directory_import,
"browse_directory": self.batch_import.browse_directory,
}
@@ -1426,7 +1429,7 @@ class BatchImportHandler:
data = await request.json()
items = data.get("items", [])
tags = data.get("tags", [])
skip_no_metadata = data.get("skip_no_metadata", True)
skip_no_metadata = data.get("skip_no_metadata", False)
if not items:
return web.json_response(
@@ -1564,3 +1567,136 @@ class BatchImportHandler:
except Exception as exc:
self._logger.error("Error cancelling batch import: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
if not directory_path:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
# Normalize the path
path = Path(directory_path).expanduser().resolve()
# Security check: ensure path is within allowed directories
# Allow common image/model directories
allowed_roots = [
Path.home(),
Path("/"), # Allow browsing from root for flexibility
]
# Check if path is within any allowed root
is_allowed = False
for root in allowed_roots:
try:
path.relative_to(root)
is_allowed = True
break
except ValueError:
continue
if not is_allowed:
return web.json_response(
{"success": False, "error": "Access denied to this directory"},
status=403,
)
if not path.exists():
return web.json_response(
{"success": False, "error": "Directory does not exist"},
status=404,
)
if not path.is_dir():
return web.json_response(
{"success": False, "error": "Path is not a directory"},
status=400,
)
# List directory contents
directories = []
image_files = []
image_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in image_extensions:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
# Sort directories and files alphabetically
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Add parent directory if not at root
parent_path = path.parent
show_parent = str(path) != str(path.root)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": str(parent_path) if show_parent else None,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
}
)
except PermissionError:
return web.json_response(
{"success": False, "error": "Permission denied"},
status=403,
)
except OSError as exc:
return web.json_response(
{"success": False, "error": f"Error reading directory: {str(exc)}"},
status=500,
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"},
status=400,
)
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)

View File

@@ -63,6 +63,10 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/cancel", "cancel_batch_import"
),
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
)

View File

@@ -69,7 +69,7 @@ class BatchImportProgress:
finished_at: Optional[float] = None
items: List[BatchImportItem] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
skip_no_metadata: bool = True
skip_no_metadata: bool = False
skip_duplicates: bool = False
def to_dict(self) -> Dict[str, Any]:
@@ -87,6 +87,19 @@ class BatchImportProgress:
"progress_percent": round((self.completed / self.total) * 100, 1)
if self.total > 0
else 0,
"items": [
{
"id": item.id,
"source": item.source,
"item_type": item.item_type.value,
"status": item.status.value,
"error_message": item.error_message,
"recipe_name": item.recipe_name,
"recipe_id": item.recipe_id,
"duration": item.duration,
}
for item in self.items
],
}
@@ -226,7 +239,7 @@ class BatchImportService:
civitai_client_getter: Callable[[], Any],
items: List[Dict[str, str]],
tags: Optional[List[str]] = None,
skip_no_metadata: bool = True,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
operation_id = str(uuid.uuid4())
@@ -278,7 +291,7 @@ class BatchImportService:
directory: str,
recursive: bool = True,
tags: Optional[List[str]] = None,
skip_no_metadata: bool = True,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
image_paths = await self._discover_images(directory, recursive)
@@ -494,7 +507,8 @@ class BatchImportService:
"skipped": True,
"error": "No LoRAs found in image",
}
return {"success": False, "error": "No LoRAs found in image"}
# When skip_no_metadata is False, allow importing images without LoRAs
# Continue with empty loras list
recipe_name = self._generate_recipe_name(item, payload)
all_tags = list(set(tags + (payload.get("tags", []) or [])))

View File

@@ -10,7 +10,11 @@ import uuid
from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.utils import sanitize_folder_name
@@ -352,10 +356,12 @@ class DownloadManager:
# Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False
if model_type == "checkpoint":
base_model_value = version_info.get('baseModel', '')
base_model_value = version_info.get("baseModel", "")
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True
logger.info(f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder")
logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# Case 2: model_version_id was None, check after getting version_info
if model_version_id is None:
@@ -476,8 +482,13 @@ class DownloadManager:
if is_primary:
# Find primary file
file_info = next(
(f for f in files if f.get("primary") and f.get("type") in ("Model", "Negative")),
None
(
f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative")
),
None,
)
else:
# Match by metadata
@@ -1220,7 +1231,13 @@ class DownloadManager:
entries: List = []
for index, file_path in enumerate(file_paths):
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
entry.update_file_info(file_path)
# Update file paths without modifying size and modified timestamps
# modified should remain as the download start time (import time)
# size will be updated below to reflect actual downloaded file size
entry.file_path = file_path.replace(os.sep, "/")
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size
entry.size = os.path.getsize(file_path)
entry.sha256 = await calculate_sha256(file_path)
entries.append(entry)

View File

@@ -4,9 +4,11 @@ from datetime import datetime
import os
from .model_utils import determine_base_model
@dataclass
class BaseModelMetadata:
"""Base class for all model metadata structures"""
file_name: str # The filename without extension
model_name: str # The model's name defined by the creator
file_path: str # Full path to the model file
@@ -18,18 +20,24 @@ class BaseModelMetadata:
preview_nsfw_level: int = 0 # NSFW level of the preview image
notes: str = "" # Additional notes
from_civitai: bool = True # Whether from Civitai
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available
civitai: Dict[str, Any] = field(
default_factory=dict
) # Civitai API data if available
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
db_checked: bool = False # Whether checked in archive DB
skip_metadata_refresh: bool = False # Whether to skip this model during bulk metadata refresh
skip_metadata_refresh: bool = (
False # Whether to skip this model during bulk metadata refresh
)
metadata_source: Optional[str] = None # Last provider that supplied metadata
last_checked_at: float = 0 # Last checked timestamp
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
_unknown_fields: Dict[str, Any] = field(
default_factory=dict, repr=False, compare=False
) # Store unknown fields
def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue
@@ -40,15 +48,15 @@ class BaseModelMetadata:
self.tags = []
@classmethod
def from_dict(cls, data: Dict) -> 'BaseModelMetadata':
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
"""Create instance from dictionary"""
data_copy = data.copy()
# Use cached fields if available, otherwise compute them
if not hasattr(cls, '_known_fields_cache'):
if not hasattr(cls, "_known_fields_cache"):
known_fields = set()
for c in cls.mro():
if hasattr(c, '__annotations__'):
if hasattr(c, "__annotations__"):
known_fields.update(c.__annotations__.keys())
cls._known_fields_cache = known_fields
@@ -58,7 +66,11 @@ class BaseModelMetadata:
fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields}
# Store unknown fields separately
unknown_fields = {k: v for k, v in data_copy.items() if k not in known_fields and not k.startswith('_')}
unknown_fields = {
k: v
for k, v in data_copy.items()
if k not in known_fields and not k.startswith("_")
}
# Create instance with known fields
instance = cls(**fields_to_use)
@@ -73,10 +85,10 @@ class BaseModelMetadata:
result = asdict(self)
# Remove private fields
result = {k: v for k, v in result.items() if not k.startswith('_')}
result = {k: v for k, v in result.items() if not k.startswith("_")}
# Add back unknown fields if they exist
if hasattr(self, '_unknown_fields'):
if hasattr(self, "_unknown_fields"):
result.update(self._unknown_fields)
return result
@@ -85,17 +97,29 @@ class BaseModelMetadata:
"""Update Civitai information"""
self.civitai = civitai_data
def update_file_info(self, file_path: str) -> None:
"""Update metadata with actual file information"""
def update_file_info(self, file_path: str, update_timestamps: bool = False) -> None:
"""
Update metadata with actual file information.
Args:
file_path: Path to the model file
update_timestamps: If True, update size and modified from filesystem.
If False (default), only update file_path and file_name.
Set to True only when file has been moved/relocated.
"""
if os.path.exists(file_path):
if update_timestamps:
# Only update size and modified when file has been relocated
self.size = os.path.getsize(file_path)
self.modified = os.path.getmtime(file_path)
self.file_path = file_path.replace(os.sep, '/')
# Update file_name when file_path changes
# Always update paths when this method is called
self.file_path = file_path.replace(os.sep, "/")
self.file_name = os.path.splitext(os.path.basename(file_path))[0]
@staticmethod
def generate_unique_filename(target_dir: str, base_name: str, extension: str, hash_provider: callable = None) -> str:
def generate_unique_filename(
target_dir: str, base_name: str, extension: str, hash_provider: callable = None
) -> str:
"""Generate a unique filename to avoid conflicts
Args:
@@ -136,115 +160,126 @@ class BaseModelMetadata:
return unique_filename
@dataclass
class LoraMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Lora model"""
usage_tips: str = "{}" # Usage tips for the model, json string
@classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'LoraMetadata':
def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "LoraMetadata":
"""Create LoraMetadata instance from Civitai version info"""
file_name = file_info.get('name', '')
base_model = determine_base_model(version_info.get('baseModel', ''))
file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get("baseModel", ""))
# Extract tags and description if available
tags = []
description = ""
model_data = version_info.get('model') or {}
if 'tags' in model_data:
tags = model_data['tags']
if 'description' in model_data:
description = model_data['description']
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model,
preview_url='', # Will be updated after preview download
preview_url="", # Will be updated after preview download
preview_nsfw_level=0, # Will be updated after preview download
from_civitai=True,
civitai=version_info,
tags=tags,
modelDescription=description
modelDescription=description,
)
@dataclass
class CheckpointMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Checkpoint model"""
sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.)
@classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'CheckpointMetadata':
def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "CheckpointMetadata":
"""Create CheckpointMetadata instance from Civitai version info"""
file_name = file_info.get('name', '')
base_model = determine_base_model(version_info.get('baseModel', ''))
sub_type = version_info.get('type', 'checkpoint')
file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get("type", "checkpoint")
# Extract tags and description if available
tags = []
description = ""
model_data = version_info.get('model') or {}
if 'tags' in model_data:
tags = model_data['tags']
if 'description' in model_data:
description = model_data['description']
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model,
preview_url='', # Will be updated after preview download
preview_url="", # Will be updated after preview download
preview_nsfw_level=0,
from_civitai=True,
civitai=version_info,
sub_type=sub_type,
tags=tags,
modelDescription=description
modelDescription=description,
)
@dataclass
class EmbeddingMetadata(BaseModelMetadata):
"""Represents the metadata structure for an Embedding model"""
sub_type: str = "embedding"
@classmethod
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'EmbeddingMetadata':
def from_civitai_info(
cls, version_info: Dict, file_info: Dict, save_path: str
) -> "EmbeddingMetadata":
"""Create EmbeddingMetadata instance from Civitai version info"""
file_name = file_info.get('name', '')
base_model = determine_base_model(version_info.get('baseModel', ''))
sub_type = version_info.get('type', 'embedding')
file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get("type", "embedding")
# Extract tags and description if available
tags = []
description = ""
model_data = version_info.get('model') or {}
if 'tags' in model_data:
tags = model_data['tags']
if 'description' in model_data:
description = model_data['description']
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
base_model=base_model,
preview_url='', # Will be updated after preview download
preview_url="", # Will be updated after preview download
preview_nsfw_level=0,
from_civitai=True,
civitai=version_info,
sub_type=sub_type,
tags=tags,
modelDescription=description
modelDescription=description,
)

View File

@@ -345,6 +345,7 @@ class StandaloneLoraManager(LoraManager):
"/ws/download-progress", ws_manager.handle_download_connection
)
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
app.router.add_get("/ws/batch-import-progress", ws_manager.handle_connection)
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -0,0 +1,677 @@
/* Batch Import Modal Styles */
/* Step Containers */
.batch-import-step {
margin: var(--space-2) 0;
}
/* Section Description */
.section-description {
color: var(--text-color);
opacity: 0.8;
margin-bottom: var(--space-2);
font-size: 0.95em;
}
/* Hint Text */
.input-hint {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-color);
opacity: 0.7;
font-size: 0.85em;
margin-top: 6px;
}
.input-hint i {
color: var(--lora-accent);
}
/* Textarea Styling */
#batchUrlInput {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 0.9em;
resize: vertical;
transition: border-color 0.2s, box-shadow 0.2s;
}
#batchUrlInput:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
/* Checkbox Group */
.checkbox-group {
margin-top: var(--space-2);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-color);
font-size: 0.95em;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: var(--bg-color);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
/* Batch Options */
.batch-options {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
/* Input with Button */
.input-with-button {
display: flex;
gap: 8px;
}
.input-with-button input {
flex: 1;
min-width: 0;
}
.input-with-button button {
flex-shrink: 0;
white-space: nowrap;
padding: 8px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: background-color 0.2s;
}
.input-with-button button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
/* Dark theme adjustments for input-with-button */
[data-theme="dark"] .input-with-button button {
background: var(--lora-accent);
color: var(--lora-text);
}
[data-theme="dark"] .input-with-button button:hover {
background: oklch(from var(--lora-accent) calc(l - 0.1) c h);
}
/* Directory Browser */
.directory-browser {
margin-top: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
overflow: hidden;
}
.browser-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--lora-accent);
background: var(--bg-color);
}
.back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current-path {
flex: 1;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.browser-content {
max-height: 300px;
overflow-y: auto;
padding: 12px;
}
.browser-section {
margin-bottom: 16px;
}
.browser-section:last-child {
margin-bottom: 0;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 0.85em;
color: var(--text-color);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.section-label i {
color: var(--lora-accent);
}
.folder-list,
.file-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.folder-item,
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.folder-item:hover,
.file-item:hover {
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
border-color: var(--lora-accent);
}
.folder-item.selected,
.file-item.selected {
background: oklch(from var(--lora-accent) l c h / 0.15);
border-color: var(--lora-accent);
}
.folder-item i {
color: #fbbf24;
font-size: 1.1em;
}
.file-item i {
color: var(--text-color);
opacity: 0.6;
font-size: 1em;
}
.item-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-size {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.browser-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
.stats {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
}
.stats span {
font-weight: 600;
color: var(--lora-accent);
}
/* Dark theme adjustments */
[data-theme="dark"] .directory-browser {
background: var(--card-bg);
}
[data-theme="dark"] .browser-header,
[data-theme="dark"] .browser-footer {
background: var(--lora-surface);
}
[data-theme="dark"] .folder-item i {
color: #fcd34d;
}
/* Progress Container */
.batch-progress-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.progress-status {
display: flex;
align-items: center;
gap: 10px;
}
.status-icon {
color: var(--lora-accent);
font-size: 1.1em;
}
.status-icon i {
animation: fa-spin 2s infinite linear;
}
.status-text {
font-weight: 500;
color: var(--text-color);
}
.progress-percentage {
font-size: 1.2em;
font-weight: 600;
color: var(--lora-accent);
}
/* Progress Bar */
.progress-bar-container {
height: 8px;
background: var(--bg-color);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--space-3);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
border-radius: 4px;
transition: width 0.3s ease;
}
/* Progress Stats */
.progress-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.stat-item.success {
border-left: 3px solid #00B87A;
}
.stat-item.failed {
border-left: 3px solid var(--lora-error);
}
.stat-item.skipped {
border-left: 3px solid var(--lora-warning);
}
.stat-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.stat-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Current Item */
.current-item {
display: flex;
align-items: baseline;
gap: 10px;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
}
.current-item-label {
color: var(--text-color);
opacity: 0.7;
flex-shrink: 0;
}
.current-item-name {
color: var(--text-color);
font-weight: 500;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Results Container */
.batch-results-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.results-header {
text-align: center;
margin-bottom: var(--space-3);
}
.results-icon {
font-size: 3em;
color: #00B87A;
margin-bottom: var(--space-1);
}
.results-icon.warning {
color: var(--lora-warning);
}
.results-icon.error {
color: var(--lora-error);
}
.results-title {
font-size: 1.3em;
font-weight: 600;
color: var(--text-color);
}
/* Results Summary - Matches progress-stats styling */
.results-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.result-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
text-align: center;
}
.result-card.success {
border-left: 3px solid #00B87A;
}
.result-card.failed {
border-left: 3px solid var(--lora-error);
}
.result-card.skipped {
border-left: 3px solid var(--lora-warning);
}
.result-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.result-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Results Details */
.results-details {
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
}
.details-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
cursor: pointer;
color: var(--lora-accent);
font-weight: 500;
border-radius: var(--border-radius-xs);
transition: background 0.2s;
}
.details-toggle:hover {
background: oklch(from var(--lora-accent) l c h / 0.1);
}
.details-toggle i {
transition: transform 0.2s;
}
.details-toggle.expanded i {
transform: rotate(180deg);
}
.details-list {
max-height: 250px;
overflow-y: auto;
margin-top: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
/* Result Item in Details */
.result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
.result-item:last-child {
border-bottom: none;
}
.result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
}
.result-item-status.success {
background: oklch(from #00B87A l c h / 0.2);
color: #00B87A;
}
.result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2);
color: var(--lora-error);
}
.result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2);
color: var(--lora-warning);
}
.result-item-info {
flex: 1;
min-width: 0;
}
.result-item-name {
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-item-error {
font-size: 0.8em;
color: var(--lora-error);
margin-top: 2px;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.progress-stats,
.results-summary {
grid-template-columns: repeat(2, 1fr);
}
.batch-progress-container,
.batch-results-container {
padding: var(--space-2);
}
}
/* Dark Theme Adjustments */
[data-theme="dark"] .batch-progress-container,
[data-theme="dark"] .batch-results-container {
background: var(--card-bg);
}
[data-theme="dark"] .stat-item,
[data-theme="dark"] .result-card,
[data-theme="dark"] .current-item,
[data-theme="dark"] .details-list {
background: var(--lora-surface);
}
/* Cancelled State */
.batch-progress-container.cancelled .progress-bar {
background: var(--lora-warning);
}
.batch-progress-container.cancelled .status-icon {
color: var(--lora-warning);
}
/* Error State */
.batch-progress-container.error .progress-bar {
background: var(--lora-error);
}
.batch-progress-container.error .status-icon {
color: var(--lora-error);
}
/* Completed State */
.batch-progress-container.completed .progress-bar {
background: #00B87A;
}
.batch-progress-container.completed .status-icon {
color: #00B87A;
}
.batch-progress-container.completed .status-icon i {
animation: none;
}
.batch-progress-container.completed .status-icon i::before {
content: '\f00c';
}

View File

@@ -0,0 +1,795 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { WS_ENDPOINTS } from '../api/apiConfig.js';
/**
* Manager for batch importing recipes from multiple images
*/
export class BatchImportManager {
constructor() {
this.initialized = false;
this.inputMode = 'urls'; // 'urls' or 'directory'
this.operationId = null;
this.wsConnection = null;
this.pollingInterval = null;
this.progress = null;
this.results = null;
this.isCancelled = false;
}
/**
* Show the batch import modal
*/
showModal() {
if (!this.initialized) {
this.initialize();
}
this.resetState();
modalManager.showModal('batchImportModal');
}
/**
* Initialize the manager
*/
initialize() {
this.initialized = true;
}
/**
* Reset all state to initial values
*/
resetState() {
this.inputMode = 'urls';
this.operationId = null;
this.progress = null;
this.results = null;
this.isCancelled = false;
// Reset UI
this.showStep('batchInputStep');
this.toggleInputMode('urls');
// Clear inputs
const urlInput = document.getElementById('batchUrlInput');
if (urlInput) urlInput.value = '';
const directoryInput = document.getElementById('batchDirectoryInput');
if (directoryInput) directoryInput.value = '';
const tagsInput = document.getElementById('batchTagsInput');
if (tagsInput) tagsInput.value = '';
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
if (skipNoMetadata) skipNoMetadata.checked = true;
const recursiveCheck = document.getElementById('batchRecursiveCheck');
if (recursiveCheck) recursiveCheck.checked = true;
// Reset progress UI
this.updateProgressUI({
total: 0,
completed: 0,
success: 0,
failed: 0,
skipped: 0,
progress_percent: 0,
current_item: '',
status: 'pending'
});
// Reset results
const detailsList = document.getElementById('batchDetailsList');
if (detailsList) {
detailsList.innerHTML = '';
detailsList.style.display = 'none';
}
const toggleIcon = document.getElementById('resultsToggleIcon');
if (toggleIcon) {
toggleIcon.classList.remove('expanded');
}
// Clean up any existing connections
this.cleanupConnections();
}
/**
* Show a specific step in the modal
*/
showStep(stepId) {
document.querySelectorAll('.batch-import-step').forEach(step => {
step.style.display = 'none';
});
const step = document.getElementById(stepId);
if (step) {
step.style.display = 'block';
}
}
/**
* Toggle between URL list and directory input modes
*/
toggleInputMode(mode) {
this.inputMode = mode;
// Update toggle buttons
document.querySelectorAll('.toggle-btn[data-mode]').forEach(btn => {
btn.classList.remove('active');
});
const activeBtn = document.querySelector(`.toggle-btn[data-mode="${mode}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
}
// Show/hide appropriate sections
const urlSection = document.getElementById('urlListSection');
const directorySection = document.getElementById('directorySection');
if (urlSection && directorySection) {
if (mode === 'urls') {
urlSection.style.display = 'block';
directorySection.style.display = 'none';
} else {
urlSection.style.display = 'none';
directorySection.style.display = 'block';
}
}
}
/**
* Start the batch import process
*/
async startImport() {
const data = this.collectInputData();
if (!this.validateInput(data)) {
return;
}
try {
// Show progress step
this.showStep('batchProgressStep');
// Start the import
const response = await this.sendStartRequest(data);
if (response.success) {
this.operationId = response.operation_id;
this.isCancelled = false;
// Connect to WebSocket for real-time updates
this.connectWebSocket();
// Start polling as fallback
this.startPolling();
} else {
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
this.showStep('batchInputStep');
}
} catch (error) {
console.error('Error starting batch import:', error);
showToast('toast.recipes.batchImportFailed', { message: error.message }, 'error');
this.showStep('batchInputStep');
}
}
/**
* Collect input data from the form
*/
collectInputData() {
const data = {
mode: this.inputMode,
tags: [],
skip_no_metadata: false
};
// Collect tags
const tagsInput = document.getElementById('batchTagsInput');
if (tagsInput && tagsInput.value.trim()) {
data.tags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
}
// Collect skip_no_metadata
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
if (skipNoMetadata) {
data.skip_no_metadata = skipNoMetadata.checked;
}
if (this.inputMode === 'urls') {
const urlInput = document.getElementById('batchUrlInput');
if (urlInput) {
const urls = urlInput.value.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Convert to items format
data.items = urls.map(url => ({
source: url,
type: this.detectUrlType(url)
}));
}
} else {
const directoryInput = document.getElementById('batchDirectoryInput');
if (directoryInput) {
data.directory = directoryInput.value.trim();
}
const recursiveCheck = document.getElementById('batchRecursiveCheck');
if (recursiveCheck) {
data.recursive = recursiveCheck.checked;
}
}
return data;
}
/**
* Detect if a URL is http or local path
*/
detectUrlType(url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return 'url';
}
return 'local_path';
}
/**
* Validate the input data
*/
validateInput(data) {
if (data.mode === 'urls') {
if (!data.items || data.items.length === 0) {
showToast('toast.recipes.batchImportNoUrls', {}, 'error');
return false;
}
} else {
if (!data.directory) {
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
return false;
}
}
return true;
}
/**
* Send the start batch import request
*/
async sendStartRequest(data) {
const endpoint = data.mode === 'urls'
? '/api/lm/recipes/batch-import/start'
: '/api/lm/recipes/batch-import/directory';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
return await response.json();
}
/**
* Connect to WebSocket for real-time progress updates
*/
connectWebSocket() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/batch-import-progress?id=${this.operationId}`;
this.wsConnection = new WebSocket(wsUrl);
this.wsConnection.onopen = () => {
console.log('Connected to batch import progress WebSocket');
};
this.wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'batch_import_progress') {
this.handleProgressUpdate(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
this.wsConnection.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.wsConnection.onclose = () => {
console.log('WebSocket connection closed');
};
}
/**
* Start polling for progress updates (fallback)
*/
startPolling() {
this.pollingInterval = setInterval(async () => {
if (!this.operationId || this.isCancelled) {
return;
}
try {
const response = await fetch(`/api/lm/recipes/batch-import/progress?operation_id=${this.operationId}`);
const data = await response.json();
if (data.success && data.progress) {
this.handleProgressUpdate(data.progress);
}
} catch (error) {
console.error('Error polling progress:', error);
}
}, 1000);
}
/**
* Handle progress update from WebSocket or polling
*/
handleProgressUpdate(progress) {
this.progress = progress;
this.updateProgressUI(progress);
// Check if import is complete
if (progress.status === 'completed' || progress.status === 'cancelled' ||
(progress.total > 0 && progress.completed >= progress.total)) {
this.importComplete(progress);
}
}
/**
* Update the progress UI
*/
updateProgressUI(progress) {
// Update progress bar
const progressBar = document.getElementById('batchProgressBar');
if (progressBar) {
progressBar.style.width = `${progress.progress_percent || 0}%`;
}
// Update percentage
const progressPercent = document.getElementById('batchProgressPercent');
if (progressPercent) {
progressPercent.textContent = `${Math.round(progress.progress_percent || 0)}%`;
}
// Update stats
const totalCount = document.getElementById('batchTotalCount');
if (totalCount) totalCount.textContent = progress.total || 0;
const successCount = document.getElementById('batchSuccessCount');
if (successCount) successCount.textContent = progress.success || 0;
const failedCount = document.getElementById('batchFailedCount');
if (failedCount) failedCount.textContent = progress.failed || 0;
const skippedCount = document.getElementById('batchSkippedCount');
if (skippedCount) skippedCount.textContent = progress.skipped || 0;
// Update current item
const currentItem = document.getElementById('batchCurrentItem');
if (currentItem) {
currentItem.textContent = progress.current_item || '-';
}
// Update status text
const statusText = document.getElementById('batchStatusText');
if (statusText) {
if (progress.status === 'running') {
statusText.textContent = translate('recipes.batchImport.importing', {}, 'Importing...');
} else if (progress.status === 'completed') {
statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
} else if (progress.status === 'cancelled') {
statusText.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
}
}
// Update container classes
const progressContainer = document.querySelector('.batch-progress-container');
if (progressContainer) {
progressContainer.classList.remove('completed', 'cancelled', 'error');
if (progress.status === 'completed') {
progressContainer.classList.add('completed');
} else if (progress.status === 'cancelled') {
progressContainer.classList.add('cancelled');
} else if (progress.failed > 0 && progress.failed === progress.total) {
progressContainer.classList.add('error');
}
}
}
/**
* Handle import completion
*/
importComplete(progress) {
this.cleanupConnections();
this.results = progress;
// Refresh recipes list to show newly imported recipes
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
window.recipeManager.loadRecipes();
}
// Show results step
this.showStep('batchResultsStep');
this.updateResultsUI(progress);
}
/**
* Update the results UI
*/
updateResultsUI(progress) {
// Update summary cards
const resultsTotal = document.getElementById('resultsTotal');
if (resultsTotal) resultsTotal.textContent = progress.total || 0;
const resultsSuccess = document.getElementById('resultsSuccess');
if (resultsSuccess) resultsSuccess.textContent = progress.success || 0;
const resultsFailed = document.getElementById('resultsFailed');
if (resultsFailed) resultsFailed.textContent = progress.failed || 0;
const resultsSkipped = document.getElementById('resultsSkipped');
if (resultsSkipped) resultsSkipped.textContent = progress.skipped || 0;
// Update header based on results
const resultsHeader = document.getElementById('batchResultsHeader');
if (resultsHeader) {
const icon = resultsHeader.querySelector('.results-icon i');
const title = resultsHeader.querySelector('.results-title');
if (this.isCancelled) {
if (icon) {
icon.className = 'fas fa-stop-circle';
icon.parentElement.classList.add('warning');
}
if (title) title.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
} else if (progress.failed === 0 && progress.success > 0) {
if (icon) {
icon.className = 'fas fa-check-circle';
icon.parentElement.classList.remove('warning', 'error');
}
if (title) title.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
} else if (progress.failed > 0 && progress.success === 0) {
if (icon) {
icon.className = 'fas fa-times-circle';
icon.parentElement.classList.add('error');
}
if (title) title.textContent = translate('recipes.batchImport.failed', {}, 'Import failed');
} else {
if (icon) {
icon.className = 'fas fa-exclamation-circle';
icon.parentElement.classList.add('warning');
}
if (title) title.textContent = translate('recipes.batchImport.completedWithErrors', {}, 'Completed with errors');
}
}
}
/**
* Toggle the results details visibility
*/
toggleResultsDetails() {
const detailsList = document.getElementById('batchDetailsList');
const toggleIcon = document.getElementById('resultsToggleIcon');
const toggle = document.querySelector('.details-toggle');
if (detailsList && toggleIcon) {
if (detailsList.style.display === 'none') {
detailsList.style.display = 'block';
toggleIcon.classList.add('expanded');
if (toggle) toggle.classList.add('expanded');
// Load details if not loaded
if (detailsList.children.length === 0 && this.results && this.results.items) {
this.loadResultsDetails(this.results.items);
}
} else {
detailsList.style.display = 'none';
toggleIcon.classList.remove('expanded');
if (toggle) toggle.classList.remove('expanded');
}
}
}
/**
* Load results details into the list
*/
loadResultsDetails(items) {
const detailsList = document.getElementById('batchDetailsList');
if (!detailsList) return;
detailsList.innerHTML = '';
items.forEach(item => {
const resultItem = document.createElement('div');
resultItem.className = 'result-item';
const statusClass = item.status === 'success' ? 'success' :
item.status === 'failed' ? 'failed' : 'skipped';
const statusIcon = item.status === 'success' ? 'check' :
item.status === 'failed' ? 'times' : 'forward';
resultItem.innerHTML = `
<div class="result-item-status ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
</div>
<div class="result-item-info">
<div class="result-item-name">${this.escapeHtml(item.source || item.current_item || 'Unknown')}</div>
${item.error_message ? `<div class="result-item-error">${this.escapeHtml(item.error_message)}</div>` : ''}
</div>
`;
detailsList.appendChild(resultItem);
});
}
/**
* Cancel the current import
*/
async cancelImport() {
if (!this.operationId) return;
this.isCancelled = true;
try {
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ operation_id: this.operationId })
});
const data = await response.json();
if (data.success) {
showToast('toast.recipes.batchImportCancelling', {}, 'info');
} else {
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
}
} catch (error) {
console.error('Error cancelling import:', error);
showToast('toast.recipes.batchImportCancelFailed', { message: error.message }, 'error');
}
}
/**
* Close modal and reset state
*/
closeAndReset() {
this.cleanupConnections();
this.resetState();
modalManager.closeModal('batchImportModal');
}
/**
* Start a new import (from results step)
*/
startNewImport() {
this.resetState();
this.showStep('batchInputStep');
}
/**
* Toggle directory browser visibility
*/
toggleDirectoryBrowser() {
const browser = document.getElementById('batchDirectoryBrowser');
if (browser) {
const isVisible = browser.style.display !== 'none';
browser.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// Load initial directory when opening
const currentPath = document.getElementById('batchDirectoryInput').value;
this.loadDirectory(currentPath || '/');
}
}
}
/**
* Load directory contents
*/
async loadDirectory(path) {
try {
const response = await fetch('/api/lm/recipes/browse-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path })
});
const data = await response.json();
if (data.success) {
this.renderDirectoryBrowser(data);
} else {
showToast('toast.recipes.batchImportBrowseFailed', { message: data.error }, 'error');
}
} catch (error) {
console.error('Error loading directory:', error);
showToast('toast.recipes.batchImportBrowseFailed', { message: error.message }, 'error');
}
}
/**
* Render directory browser UI
*/
renderDirectoryBrowser(data) {
const currentPathEl = document.getElementById('batchCurrentPath');
const folderList = document.getElementById('batchFolderList');
const fileList = document.getElementById('batchFileList');
const directoryCount = document.getElementById('batchDirectoryCount');
const imageCount = document.getElementById('batchImageCount');
if (currentPathEl) {
currentPathEl.textContent = data.current_path;
}
// Render folders
if (folderList) {
folderList.innerHTML = '';
// Add parent directory if available
if (data.parent_path) {
const parentItem = this.createFolderItem('..', data.parent_path, true);
folderList.appendChild(parentItem);
}
data.directories.forEach(dir => {
folderList.appendChild(this.createFolderItem(dir.name, dir.path));
});
}
// Render files
if (fileList) {
fileList.innerHTML = '';
data.image_files.forEach(file => {
fileList.appendChild(this.createFileItem(file.name, file.path, file.size));
});
}
// Update stats
if (directoryCount) {
directoryCount.textContent = data.directory_count;
}
if (imageCount) {
imageCount.textContent = data.image_count;
}
}
/**
* Create folder item element
*/
createFolderItem(name, path, isParent = false) {
const item = document.createElement('div');
item.className = 'folder-item';
item.dataset.path = path;
item.innerHTML = `
<i class="fas fa-folder${isParent ? '' : ''}"></i>
<span class="item-name">${this.escapeHtml(name)}</span>
`;
item.addEventListener('click', () => {
if (isParent) {
this.navigateToParentDirectory();
} else {
this.loadDirectory(path);
}
});
return item;
}
/**
* Create file item element
*/
createFileItem(name, path, size) {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.path = path;
item.innerHTML = `
<i class="fas fa-image"></i>
<span class="item-name">${this.escapeHtml(name)}</span>
<span class="item-size">${this.formatFileSize(size)}</span>
`;
return item;
}
/**
* Navigate to parent directory
*/
navigateToParentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
if (currentPath) {
// Get parent path using path manipulation
const lastSeparator = currentPath.lastIndexOf('/');
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
this.loadDirectory(parentPath);
}
}
/**
* Select current directory
*/
selectCurrentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
const directoryInput = document.getElementById('batchDirectoryInput');
if (currentPath && directoryInput) {
directoryInput.value = currentPath;
this.toggleDirectoryBrowser(); // Close browser
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
}
}
/**
* Format file size for display
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Browse for directory using File System Access API (deprecated - kept for compatibility)
*/
async browseDirectory() {
// Now redirects to the new directory browser
this.toggleDirectoryBrowser();
}
/**
* Clean up WebSocket and polling connections
*/
cleanupConnections() {
if (this.wsConnection) {
if (this.wsConnection.readyState === WebSocket.OPEN ||
this.wsConnection.readyState === WebSocket.CONNECTING) {
this.wsConnection.close();
}
this.wsConnection = null;
}
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Create singleton instance
export const batchImportManager = new BatchImportManager();

View File

@@ -134,6 +134,19 @@ export class ModalManager {
});
}
// Add batchImportModal registration
const batchImportModal = document.getElementById('batchImportModal');
if (batchImportModal) {
this.registerModal('batchImportModal', {
element: batchImportModal,
onClose: () => {
this.getModal('batchImportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add recipeModal registration
const recipeModal = document.getElementById('recipeModal');
if (recipeModal) {

View File

@@ -1,6 +1,7 @@
// Recipe manager module
import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js';
import { BatchImportManager } from './managers/BatchImportManager.js';
import { RecipeModal } from './components/RecipeModal.js';
import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
@@ -46,6 +47,10 @@ class RecipeManager {
// Initialize ImportManager
this.importManager = new ImportManager();
// Initialize BatchImportManager and make it globally accessible
this.batchImportManager = new BatchImportManager();
window.batchImportManager = this.batchImportManager;
// Initialize RecipeModal
this.recipeModal = new RecipeModal();

View File

@@ -0,0 +1,206 @@
<div id="batchImportModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<button class="close" onclick="modalManager.closeModal('batchImportModal')">&times;</button>
<h2>{{ t('recipes.batchImport.title') }}</h2>
</div>
<!-- Step 1: Input Selection -->
<div class="batch-import-step" id="batchInputStep">
<div class="import-mode-toggle">
<button class="toggle-btn active" data-mode="urls" onclick="batchImportManager.toggleInputMode('urls')">
<i class="fas fa-link"></i> {{ t('recipes.batchImport.urlList') }}
</button>
<button class="toggle-btn" data-mode="directory" onclick="batchImportManager.toggleInputMode('directory')">
<i class="fas fa-folder"></i> {{ t('recipes.batchImport.directory') }}
</button>
</div>
<!-- URL List Section -->
<div class="import-section" id="urlListSection">
<p class="section-description">{{ t('recipes.batchImport.urlDescription') }}</p>
<div class="input-group">
<label for="batchUrlInput">{{ t('recipes.batchImport.urlsLabel') }}</label>
<textarea id="batchUrlInput" rows="8" placeholder="{{ t('recipes.batchImport.urlsPlaceholder') }}"></textarea>
<div class="input-hint">
<i class="fas fa-info-circle"></i>
{{ t('recipes.batchImport.urlsHint') }}
</div>
</div>
</div>
<!-- Directory Section -->
<div class="import-section" id="directorySection" style="display: none;">
<p class="section-description">{{ t('recipes.batchImport.directoryDescription') }}</p>
<div class="input-group">
<label for="batchDirectoryInput">{{ t('recipes.batchImport.directoryPath') }}</label>
<div class="input-with-button">
<input type="text" id="batchDirectoryInput" placeholder="{{ t('recipes.batchImport.directoryPlaceholder') }}" autocomplete="off">
<button class="secondary-btn" onclick="batchImportManager.toggleDirectoryBrowser()">
<i class="fas fa-folder-open"></i> {{ t('recipes.batchImport.browse') }}
</button>
</div>
</div>
<!-- Directory Browser -->
<div class="directory-browser" id="batchDirectoryBrowser" style="display: none;">
<div class="browser-header">
<button class="back-btn" onclick="batchImportManager.navigateToParentDirectory()" title="{{ t('recipes.batchImport.backToParent') }}">
<i class="fas fa-arrow-up"></i>
</button>
<div class="current-path" id="batchCurrentPath"></div>
</div>
<div class="browser-content">
<div class="browser-section">
<div class="section-label"><i class="fas fa-folder"></i> {{ t('recipes.batchImport.folders') }}</div>
<div class="folder-list" id="batchFolderList"></div>
</div>
<div class="browser-section">
<div class="section-label"><i class="fas fa-image"></i> {{ t('recipes.batchImport.imageFiles') }}</div>
<div class="file-list" id="batchFileList"></div>
</div>
</div>
<div class="browser-footer">
<div class="stats">
<span id="batchDirectoryCount">0</span> {{ t('recipes.batchImport.folders') }},
<span id="batchImageCount">0</span> {{ t('recipes.batchImport.images') }}
</div>
<button class="primary-btn" onclick="batchImportManager.selectCurrentDirectory()">
<i class="fas fa-check"></i> {{ t('recipes.batchImport.selectFolder') }}
</button>
</div>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="batchRecursiveCheck" checked>
<span class="checkmark"></span>
{{ t('recipes.batchImport.recursive') }}
</label>
</div>
</div>
<!-- Common Options -->
<div class="batch-options">
<div class="input-group">
<label for="batchTagsInput">{{ t('recipes.batchImport.tagsOptional') }}</label>
<input type="text" id="batchTagsInput" placeholder="{{ t('recipes.batchImport.tagsPlaceholder') }}">
<div class="input-hint">
<i class="fas fa-info-circle"></i>
{{ t('recipes.batchImport.tagsHint') }}
</div>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="batchSkipNoMetadata">
<span class="checkmark"></span>
{{ t('recipes.batchImport.skipNoMetadata') }}
</label>
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('batchImportModal')">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="batchImportStartBtn" onclick="batchImportManager.startImport()">
<i class="fas fa-play"></i> {{ t('recipes.batchImport.start') }}
</button>
</div>
</div>
<!-- Step 2: Progress -->
<div class="batch-import-step" id="batchProgressStep" style="display: none;">
<div class="batch-progress-container">
<div class="progress-header">
<div class="progress-status">
<span class="status-icon"><i class="fas fa-spinner fa-spin"></i></span>
<span class="status-text" id="batchStatusText">{{ t('recipes.batchImport.importing') }}</span>
</div>
<div class="progress-percentage" id="batchProgressPercent">0%</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="batchProgressBar" style="width: 0%"></div>
</div>
<div class="progress-stats">
<div class="stat-item">
<span class="stat-label">{{ t('recipes.batchImport.total') }}</span>
<span class="stat-value" id="batchTotalCount">0</span>
</div>
<div class="stat-item success">
<span class="stat-label">{{ t('recipes.batchImport.success') }}</span>
<span class="stat-value" id="batchSuccessCount">0</span>
</div>
<div class="stat-item failed">
<span class="stat-label">{{ t('recipes.batchImport.failed') }}</span>
<span class="stat-value" id="batchFailedCount">0</span>
</div>
<div class="stat-item skipped">
<span class="stat-label">{{ t('recipes.batchImport.skipped') }}</span>
<span class="stat-value" id="batchSkippedCount">0</span>
</div>
</div>
<div class="current-item" id="batchCurrentItemContainer">
<span class="current-item-label">{{ t('recipes.batchImport.current') }}</span>
<span class="current-item-name" id="batchCurrentItem">-</span>
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" id="batchCancelBtn" onclick="batchImportManager.cancelImport()">
<i class="fas fa-stop"></i> {{ t('recipes.batchImport.cancel') }}
</button>
</div>
</div>
<!-- Step 3: Results -->
<div class="batch-import-step" id="batchResultsStep" style="display: none;">
<div class="batch-results-container">
<div class="results-header" id="batchResultsHeader">
<div class="results-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="results-title">{{ t('recipes.batchImport.completed') }}</div>
</div>
<div class="results-summary">
<div class="result-card total">
<span class="result-label">{{ t('recipes.batchImport.total') }}</span>
<span class="result-value" id="resultsTotal">0</span>
</div>
<div class="result-card success">
<span class="result-label">{{ t('recipes.batchImport.success') }}</span>
<span class="result-value" id="resultsSuccess">0</span>
</div>
<div class="result-card failed">
<span class="result-label">{{ t('recipes.batchImport.failed') }}</span>
<span class="result-value" id="resultsFailed">0</span>
</div>
<div class="result-card skipped">
<span class="result-label">{{ t('recipes.batchImport.skipped') }}</span>
<span class="result-value" id="resultsSkipped">0</span>
</div>
</div>
<div class="results-details" id="batchResultsDetails">
<div class="details-toggle" onclick="batchImportManager.toggleResultsDetails()">
<i class="fas fa-chevron-down" id="resultsToggleIcon"></i>
<span>{{ t('recipes.batchImport.viewDetails') }}</span>
</div>
<div class="details-list" id="batchDetailsList" style="display: none;">
<!-- Details will be populated dynamically -->
</div>
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="batchImportManager.closeAndReset()">{{ t('common.actions.close') }}</button>
<button class="primary-btn" onclick="batchImportManager.startNewImport()">
<i class="fas fa-plus"></i> {{ t('recipes.batchImport.newImport') }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -7,10 +7,12 @@
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
{% endblock %}
{% block additional_components %}
{% include 'components/import_modal.html' %}
{% include 'components/batch_import_modal.html' %}
{% include 'components/recipe_modal.html' %}
<div id="recipeContextMenu" class="context-menu" style="display: none;">
@@ -85,6 +87,10 @@
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
t('recipes.batchImport.action') }}</button>
</div>
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>