From ee466113d5094b06227adbfc9386d1473fbe2517 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 14 Mar 2026 21:17:36 +0800 Subject: [PATCH] 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 --- .docs/batch-import-design.md | 153 ++++ README.md | 6 + locales/de.json | 67 +- locales/en.json | 53 +- locales/es.json | 67 +- locales/fr.json | 67 +- locales/he.json | 67 +- locales/ja.json | 67 +- locales/ko.json | 67 +- locales/ru.json | 67 +- locales/zh-CN.json | 51 +- locales/zh-TW.json | 67 +- py/routes/handlers/recipe_handlers.py | 138 +++- py/routes/recipe_route_registrar.py | 4 + py/services/batch_import_service.py | 22 +- py/services/download_manager.py | 43 +- py/utils/models.py | 227 +++--- standalone.py | 1 + static/css/components/batch-import-modal.css | 677 ++++++++++++++++ static/js/managers/BatchImportManager.js | 795 +++++++++++++++++++ static/js/managers/ModalManager.js | 13 + static/js/recipes.js | 5 + templates/components/batch_import_modal.html | 206 +++++ templates/recipes.html | 6 + 24 files changed, 2791 insertions(+), 145 deletions(-) create mode 100644 .docs/batch-import-design.md create mode 100644 static/css/components/batch-import-modal.css create mode 100644 static/js/managers/BatchImportManager.js create mode 100644 templates/components/batch_import_modal.html diff --git a/.docs/batch-import-design.md b/.docs/batch-import-design.md new file mode 100644 index 00000000..9bcf77cc --- /dev/null +++ b/.docs/batch-import-design.md @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index cddef4d6..58c5b478 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/locales/de.json b/locales/de.json index ca9a1c3d..139d6808 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 2780fd34..0127c7fa 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 52ea3016..55872266 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index 2e239fe7..f8909a99 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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é", diff --git a/locales/he.json b/locales/he.json index d59ba0b5..1a91e49b 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "לא נבחרו מודלים", diff --git a/locales/ja.json b/locales/ja.json index 2147a686..a3bd009c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "モデルが選択されていません", diff --git a/locales/ko.json b/locales/ko.json index 32fc9946..251c200e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "선택된 모델이 없습니다", diff --git a/locales/ru.json b/locales/ru.json index 161e4927..6dea34db 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Модели не выбраны", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 3ea49e1c..5d49180b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "未选中模型", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 6d396dbe..e9607271 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "未選擇模型", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 021b9fc3..339fdf46 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -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) diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index aa098687..3fa30834 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -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"), ) diff --git a/py/services/batch_import_service.py b/py/services/batch_import_service.py index 62737ad9..d501094e 100644 --- a/py/services/batch_import_service.py +++ b/py/services/batch_import_service.py @@ -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 []))) diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 75401531..56b3efa6 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -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) diff --git a/py/utils/models.py b/py/utils/models.py index 378b567b..14d639f6 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -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, ) - diff --git a/standalone.py b/standalone.py index 62806cb0..7bd1a201 100644 --- a/standalone.py +++ b/standalone.py @@ -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()) diff --git a/static/css/components/batch-import-modal.css b/static/css/components/batch-import-modal.css new file mode 100644 index 00000000..c795444d --- /dev/null +++ b/static/css/components/batch-import-modal.css @@ -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'; +} diff --git a/static/js/managers/BatchImportManager.js b/static/js/managers/BatchImportManager.js new file mode 100644 index 00000000..ff100398 --- /dev/null +++ b/static/js/managers/BatchImportManager.js @@ -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 = ` +
+ +
+
+
${this.escapeHtml(item.source || item.current_item || 'Unknown')}
+ ${item.error_message ? `
${this.escapeHtml(item.error_message)}
` : ''} +
+ `; + + 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 = ` + + ${this.escapeHtml(name)} + `; + + 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 = ` + + ${this.escapeHtml(name)} + ${this.formatFileSize(size)} + `; + + 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(); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index fe24cc3e..ea9cdb17 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -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) { diff --git a/static/js/recipes.js b/static/js/recipes.js index a834faa7..6e0cd2c6 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -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(); diff --git a/templates/components/batch_import_modal.html b/templates/components/batch_import_modal.html new file mode 100644 index 00000000..f40e5e0f --- /dev/null +++ b/templates/components/batch_import_modal.html @@ -0,0 +1,206 @@ + diff --git a/templates/recipes.html b/templates/recipes.html index 4cdbe1ac..8fc54173 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -7,10 +7,12 @@ + {% endblock %} {% block additional_components %} {% include 'components/import_modal.html' %} +{% include 'components/batch_import_modal.html' %} {% include 'components/recipe_modal.html' %} +
+ +