mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 13:12:12 -03:00
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:
153
.docs/batch-import-design.md
Normal file
153
.docs/batch-import-design.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "לא נבחרו מודלים",
|
||||
|
||||
@@ -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": "モデルが選択されていません",
|
||||
|
||||
@@ -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": "선택된 모델이 없습니다",
|
||||
|
||||
@@ -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": "Модели не выбраны",
|
||||
|
||||
@@ -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": "请输入目录路径",
|
||||
@@ -800,7 +822,7 @@
|
||||
"emptyFolderName": "请输入文件夹名称",
|
||||
"invalidFolderName": "文件夹名称包含无效字符",
|
||||
"noDragState": "未找到待处理的拖放操作"
|
||||
},
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到文件夹",
|
||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||
@@ -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": "未选中模型",
|
||||
|
||||
@@ -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": "未選擇模型",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 [])))
|
||||
|
||||
@@ -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:
|
||||
@@ -464,7 +470,7 @@ class DownloadManager:
|
||||
# 2. Get file information
|
||||
files = version_info.get("files", [])
|
||||
file_info = None
|
||||
|
||||
|
||||
# If file_params is provided, try to find matching file
|
||||
if file_params and model_version_id:
|
||||
target_type = file_params.get("type", "Model")
|
||||
@@ -472,23 +478,28 @@ class DownloadManager:
|
||||
target_size = file_params.get("size", "full")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
|
||||
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
|
||||
for f in files:
|
||||
f_type = f.get("type", "")
|
||||
f_meta = f.get("metadata", {})
|
||||
|
||||
|
||||
# Check type match
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
|
||||
# Check metadata match
|
||||
if f_meta.get("format") != target_format:
|
||||
continue
|
||||
@@ -496,10 +507,10 @@ class DownloadManager:
|
||||
continue
|
||||
if target_fp and f_meta.get("fp") != target_fp:
|
||||
continue
|
||||
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
|
||||
# Fallback to primary file if no match found
|
||||
if not file_info:
|
||||
file_info = next(
|
||||
@@ -510,7 +521,7 @@ class DownloadManager:
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
if not file_info:
|
||||
return {"success": False, "error": "No suitable file found in metadata"}
|
||||
mirrors = file_info.get("mirrors") or []
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,32 +4,40 @@ 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
|
||||
size: int # File size in bytes
|
||||
modified: float # Timestamp when the model was added to the management system
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
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
|
||||
tags: List[str] = None # Model tags
|
||||
|
||||
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
|
||||
size: int # File size in bytes
|
||||
modified: float # Timestamp when the model was added to the management system
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
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
|
||||
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
|
||||
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
|
||||
)
|
||||
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,211 +48,238 @@ 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
|
||||
|
||||
|
||||
known_fields = cls._known_fields_cache
|
||||
|
||||
|
||||
# Extract fields that match our class attributes
|
||||
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)
|
||||
|
||||
|
||||
# Add unknown fields as a separate attribute
|
||||
instance._unknown_fields = unknown_fields
|
||||
|
||||
|
||||
return instance
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
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
|
||||
|
||||
def update_civitai_info(self, civitai_data: Dict) -> None:
|
||||
"""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):
|
||||
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
|
||||
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)
|
||||
# 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:
|
||||
target_dir: Target directory path
|
||||
base_name: Base filename without extension
|
||||
extension: File extension including the dot
|
||||
hash_provider: A callable that returns the SHA256 hash when needed
|
||||
|
||||
|
||||
Returns:
|
||||
str: Unique filename that doesn't conflict with existing files
|
||||
"""
|
||||
original_filename = f"{base_name}{extension}"
|
||||
target_path = os.path.join(target_dir, original_filename)
|
||||
|
||||
|
||||
# If no conflict, return original filename
|
||||
if not os.path.exists(target_path):
|
||||
return original_filename
|
||||
|
||||
|
||||
# Only compute hash when needed
|
||||
if hash_provider:
|
||||
sha256_hash = hash_provider()
|
||||
else:
|
||||
sha256_hash = "0000"
|
||||
|
||||
|
||||
# Generate short hash (first 4 characters of SHA256)
|
||||
short_hash = sha256_hash[:4] if sha256_hash else "0000"
|
||||
|
||||
|
||||
# Try with short hash suffix
|
||||
unique_filename = f"{base_name}-{short_hash}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
|
||||
|
||||
# If still conflicts, add incremental number
|
||||
counter = 1
|
||||
while os.path.exists(unique_path):
|
||||
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
counter += 1
|
||||
|
||||
|
||||
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
|
||||
|
||||
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_nsfw_level=0, # 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
677
static/css/components/batch-import-modal.css
Normal file
677
static/css/components/batch-import-modal.css
Normal 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';
|
||||
}
|
||||
795
static/js/managers/BatchImportManager.js
Normal file
795
static/js/managers/BatchImportManager.js
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
206
templates/components/batch_import_modal.html
Normal file
206
templates/components/batch_import_modal.html
Normal 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')">×</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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user