diff --git a/locales/en.json b/locales/en.json index c518574a..32656961 100644 --- a/locales/en.json +++ b/locales/en.json @@ -319,6 +319,7 @@ "selected": "{count} selected", "selectedSuffix": "selected", "viewSelected": "Click to view selected items", + "addTags": "Add Tags", "sendToWorkflow": "Send to Workflow", "copyAll": "Copy All", "refreshAll": "Refresh All", @@ -572,6 +573,16 @@ "countMessage": "models will be permanently deleted.", "action": "Delete All" }, + "bulkAddTags": { + "title": "Add Tags to Multiple Models", + "description": "Add tags to", + "models": "models", + "tagsToAdd": "Tags to Add", + "placeholder": "Enter tag and press Enter...", + "appendTags": "Append Tags", + "replaceTags": "Replace Tags", + "saveChanges": "Save changes" + }, "exampleAccess": { "title": "Local Example Images", "message": "No local example images found for this model. View options:", @@ -987,7 +998,14 @@ "verificationAlreadyDone": "This group has already been verified", "verificationCompleteMismatch": "Verification complete. {count} file(s) have different actual hashes.", "verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.", - "verificationFailed": "Failed to verify hashes: {message}" + "verificationFailed": "Failed to verify hashes: {message}", + "noTagsToAdd": "No tags to add", + "tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)", + "tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)", + "tagsAddFailed": "Failed to add tags to {count} model(s)", + "tagsReplaceFailed": "Failed to replace tags for {count} model(s)", + "bulkTagsAddFailed": "Failed to add tags to models", + "bulkTagsReplaceFailed": "Failed to replace tags for models" }, "search": { "atLeastOneOption": "At least one search option must be selected" diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index bf77a9b2..81f8edcd 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -49,6 +49,7 @@ class BaseModelRoutes(ABC): app.router.add_post(f'/api/{prefix}/relink-civitai', self.relink_civitai) app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview) app.router.add_post(f'/api/{prefix}/save-metadata', self.save_metadata) + app.router.add_post(f'/api/{prefix}/add-tags', self.add_tags) app.router.add_post(f'/api/{prefix}/rename', self.rename_model) app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models) app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates) @@ -272,6 +273,10 @@ class BaseModelRoutes(ABC): """Handle saving metadata updates""" return await ModelRouteUtils.handle_save_metadata(request, self.service.scanner) + async def add_tags(self, request: web.Request) -> web.Response: + """Handle adding tags to model metadata""" + return await ModelRouteUtils.handle_add_tags(request, self.service.scanner) + async def rename_model(self, request: web.Request) -> web.Response: """Handle renaming a model file and its associated files""" return await ModelRouteUtils.handle_rename_model(request, self.service.scanner) diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 3217cf2f..8f9cfe7f 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -870,11 +870,11 @@ class ModelRouteUtils: metadata = await ModelRouteUtils.load_local_metadata(metadata_path) # Compare hashes - stored_hash = metadata.get('sha256', '').lower() + stored_hash = metadata.get('sha256', '').lower(); # Set expected hash from first file if not yet set if not expected_hash: - expected_hash = stored_hash + expected_hash = stored_hash; # Check if hash matches expected hash if actual_hash != expected_hash: @@ -978,7 +978,7 @@ class ModelRouteUtils: if os.path.exists(metadata_path): metadata = await ModelRouteUtils.load_local_metadata(metadata_path) hash_value = metadata.get('sha256') - + logger.info(f"hash_value: {hash_value}, metadata_path: {metadata_path}, metadata: {metadata}") # Rename all files renamed_files = [] new_metadata_path = None @@ -1093,3 +1093,63 @@ class ModelRouteUtils: except Exception as e: logger.error(f"Error saving metadata: {e}", exc_info=True) return web.Response(text=str(e), status=500) + + @staticmethod + async def handle_add_tags(request: web.Request, scanner) -> web.Response: + """Handle adding tags to model metadata + + Args: + request: The aiohttp request + scanner: The model scanner instance + + Returns: + web.Response: The HTTP response + """ + try: + data = await request.json() + file_path = data.get('file_path') + new_tags = data.get('tags', []) + + if not file_path: + return web.Response(text='File path is required', status=400) + + if not isinstance(new_tags, list): + return web.Response(text='Tags must be a list', status=400) + + # Get metadata file path + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + + # Load existing metadata + metadata = await ModelRouteUtils.load_local_metadata(metadata_path) + + # Get existing tags (case insensitive) + existing_tags = metadata.get('tags', []) + existing_tags_lower = [tag.lower() for tag in existing_tags] + + # Add new tags that don't already exist (case insensitive check) + tags_added = [] + for tag in new_tags: + if isinstance(tag, str) and tag.strip(): + tag_stripped = tag.strip() + if tag_stripped.lower() not in existing_tags_lower: + existing_tags.append(tag_stripped) + existing_tags_lower.append(tag_stripped.lower()) + tags_added.append(tag_stripped) + + # Update metadata with combined tags + metadata['tags'] = existing_tags + + # Save updated metadata + await MetadataManager.save_metadata(file_path, metadata) + + # Update cache + await scanner.update_single_model_cache(file_path, file_path, metadata) + + return web.json_response({ + 'success': True, + 'tags': existing_tags + }) + + except Exception as e: + logger.error(f"Error adding tags: {e}", exc_info=True) + return web.Response(text=str(e), status=500) diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index 71172e6d..2b1d542d 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -37,6 +37,10 @@ body.modal-open { overflow-x: hidden; /* 防止水平滚动条 */ } +.modal-content-large { + min-height: 480px; +} + /* 当 modal 打开时锁定 body */ body.modal-open { overflow: hidden !important; /* 覆盖 base.css 中的 scroll */ diff --git a/static/css/components/shared/edit-metadata.css b/static/css/components/shared/edit-metadata.css index cf14997b..67838642 100644 --- a/static/css/components/shared/edit-metadata.css +++ b/static/css/components/shared/edit-metadata.css @@ -80,6 +80,7 @@ align-items: flex-start; margin-bottom: var(--space-2); width: 100%; + min-height: 30px; /* Ensure some height even if empty to prevent layout shifts */ } /* Individual Item */ @@ -153,17 +154,42 @@ } .metadata-save-btn, -.save-tags-btn { +.save-tags-btn, +.append-tags-btn, +.replace-tags-btn { background: var(--lora-accent) !important; color: white !important; border-color: var(--lora-accent) !important; } .metadata-save-btn:hover, -.save-tags-btn:hover { +.save-tags-btn:hover, +.append-tags-btn:hover, +.replace-tags-btn:hover { opacity: 0.9; } +/* Specific styling for bulk tag action buttons */ +.bulk-append-tags-btn { + background: var(--lora-accent) !important; + color: white !important; + border-color: var(--lora-accent) !important; +} + +.bulk-replace-tags-btn { + background: var(--lora-warning, #f59e0b) !important; + color: white !important; + border-color: var(--lora-warning, #f59e0b) !important; +} + +.bulk-append-tags-btn:hover { + opacity: 0.9; +} + +.bulk-replace-tags-btn:hover { + background: var(--lora-warning-dark, #d97706) !important; +} + /* Add Form */ .metadata-add-form { display: flex; diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 73638e20..44174119 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -63,6 +63,9 @@ export function getApiEndpoints(modelType) { // Bulk operations bulkDelete: `/api/${modelType}/bulk-delete`, + + // Tag operations + addTags: `/api/${modelType}/add-tags`, // Move operations (now common for all model types that support move) moveModel: `/api/${modelType}/move_model`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 46e98a08..485b4447 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -306,6 +306,34 @@ export class BaseModelApiClient { } } + async addTags(filePath, data) { + try { + const response = await fetch(this.apiConfig.endpoints.addTags, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to add tags'); + } + + const result = await response.json(); + + if (result.success && result.tags) { + state.virtualScroller.updateSingleItem(filePath, { tags: result.tags }); + } + + return result; + } catch (error) { + console.error('Error adding tags:', error); + throw error; + } + } + async refreshModels(fullRebuild = false) { try { state.loadingManager.showSimpleLoading( diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 052163c7..3b4116f2 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -464,10 +464,18 @@ export class BulkManager { } // Setup save button - const saveBtn = document.querySelector('.bulk-save-tags-btn'); - if (saveBtn) { - saveBtn.addEventListener('click', () => { - this.saveBulkTags(); + const appendBtn = document.querySelector('.bulk-append-tags-btn'); + const replaceBtn = document.querySelector('.bulk-replace-tags-btn'); + + if (appendBtn) { + appendBtn.addEventListener('click', () => { + this.saveBulkTags('append'); + }); + } + + if (replaceBtn) { + replaceBtn.addEventListener('click', () => { + this.saveBulkTags('replace'); }); } } @@ -557,7 +565,7 @@ export class BulkManager { tagsContainer.appendChild(newTag); } - async saveBulkTags() { + async saveBulkTags(mode = 'append') { const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item'); const tags = Array.from(tagElements).map(tag => tag.dataset.tag); @@ -577,13 +585,17 @@ export class BulkManager { let successCount = 0; let failCount = 0; - // Add tags to each selected model + // Add or replace tags for each selected model based on mode for (const filePath of filePaths) { try { - await apiClient.addTags(filePath, { tags: tags }); + if (mode === 'replace') { + await apiClient.saveModelMetadata(filePath, { tags: tags }); + } else { + await apiClient.addTags(filePath, { tags: tags }); + } successCount++; } catch (error) { - console.error(`Failed to add tags to ${filePath}:`, error); + console.error(`Failed to ${mode} tags for ${filePath}:`, error); failCount++; } } @@ -592,7 +604,8 @@ export class BulkManager { if (successCount > 0) { const currentConfig = MODEL_CONFIG[state.currentPageType]; - showToast('toast.models.tagsAddedSuccessfully', { + const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully'; + showToast(toastKey, { count: successCount, tagCount: tags.length, type: currentConfig.displayName.toLowerCase() @@ -600,12 +613,14 @@ export class BulkManager { } if (failCount > 0) { - showToast('toast.models.tagsAddFailed', { count: failCount }, 'warning'); + const toastKey = mode === 'replace' ? 'toast.models.tagsReplaceFailed' : 'toast.models.tagsAddFailed'; + showToast(toastKey, { count: failCount }, 'warning'); } } catch (error) { - console.error('Error during bulk tag addition:', error); - showToast('toast.models.bulkTagsAddFailed', {}, 'error'); + console.error('Error during bulk tag operation:', error); + const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed'; + showToast(toastKey, {}, 'error'); } } @@ -623,9 +638,14 @@ export class BulkManager { } // Remove event listeners (they will be re-added when modal opens again) - const saveBtn = document.querySelector('.bulk-save-tags-btn'); - if (saveBtn) { - saveBtn.replaceWith(saveBtn.cloneNode(true)); + const appendBtn = document.querySelector('.bulk-append-tags-btn'); + if (appendBtn) { + appendBtn.replaceWith(appendBtn.cloneNode(true)); + } + + const replaceBtn = document.querySelector('.bulk-replace-tags-btn'); + if (replaceBtn) { + replaceBtn.replaceWith(replaceBtn.cloneNode(true)); } } } diff --git a/templates/components/modals/bulk_add_tags_modal.html b/templates/components/modals/bulk_add_tags_modal.html index 7538ec89..0ec7dcaf 100644 --- a/templates/components/modals/bulk_add_tags_modal.html +++ b/templates/components/modals/bulk_add_tags_modal.html @@ -1,10 +1,8 @@