mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
feat(tags): implement bulk tag addition and replacement functionality
This commit is contained in:
@@ -319,6 +319,7 @@
|
|||||||
"selected": "{count} selected",
|
"selected": "{count} selected",
|
||||||
"selectedSuffix": "selected",
|
"selectedSuffix": "selected",
|
||||||
"viewSelected": "Click to view selected items",
|
"viewSelected": "Click to view selected items",
|
||||||
|
"addTags": "Add Tags",
|
||||||
"sendToWorkflow": "Send to Workflow",
|
"sendToWorkflow": "Send to Workflow",
|
||||||
"copyAll": "Copy All",
|
"copyAll": "Copy All",
|
||||||
"refreshAll": "Refresh All",
|
"refreshAll": "Refresh All",
|
||||||
@@ -572,6 +573,16 @@
|
|||||||
"countMessage": "models will be permanently deleted.",
|
"countMessage": "models will be permanently deleted.",
|
||||||
"action": "Delete All"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Local Example Images",
|
"title": "Local Example Images",
|
||||||
"message": "No local example images found for this model. View options:",
|
"message": "No local example images found for this model. View options:",
|
||||||
@@ -987,7 +998,14 @@
|
|||||||
"verificationAlreadyDone": "This group has already been verified",
|
"verificationAlreadyDone": "This group has already been verified",
|
||||||
"verificationCompleteMismatch": "Verification complete. {count} file(s) have different actual hashes.",
|
"verificationCompleteMismatch": "Verification complete. {count} file(s) have different actual hashes.",
|
||||||
"verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.",
|
"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": {
|
"search": {
|
||||||
"atLeastOneOption": "At least one search option must be selected"
|
"atLeastOneOption": "At least one search option must be selected"
|
||||||
|
|||||||
@@ -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}/relink-civitai', self.relink_civitai)
|
||||||
app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview)
|
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}/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}/rename', self.rename_model)
|
||||||
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
|
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)
|
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
|
||||||
@@ -272,6 +273,10 @@ class BaseModelRoutes(ABC):
|
|||||||
"""Handle saving metadata updates"""
|
"""Handle saving metadata updates"""
|
||||||
return await ModelRouteUtils.handle_save_metadata(request, self.service.scanner)
|
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:
|
async def rename_model(self, request: web.Request) -> web.Response:
|
||||||
"""Handle renaming a model file and its associated files"""
|
"""Handle renaming a model file and its associated files"""
|
||||||
return await ModelRouteUtils.handle_rename_model(request, self.service.scanner)
|
return await ModelRouteUtils.handle_rename_model(request, self.service.scanner)
|
||||||
|
|||||||
@@ -870,11 +870,11 @@ class ModelRouteUtils:
|
|||||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||||
|
|
||||||
# Compare hashes
|
# Compare hashes
|
||||||
stored_hash = metadata.get('sha256', '').lower()
|
stored_hash = metadata.get('sha256', '').lower();
|
||||||
|
|
||||||
# Set expected hash from first file if not yet set
|
# Set expected hash from first file if not yet set
|
||||||
if not expected_hash:
|
if not expected_hash:
|
||||||
expected_hash = stored_hash
|
expected_hash = stored_hash;
|
||||||
|
|
||||||
# Check if hash matches expected hash
|
# Check if hash matches expected hash
|
||||||
if actual_hash != expected_hash:
|
if actual_hash != expected_hash:
|
||||||
@@ -978,7 +978,7 @@ class ModelRouteUtils:
|
|||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||||
hash_value = metadata.get('sha256')
|
hash_value = metadata.get('sha256')
|
||||||
|
logger.info(f"hash_value: {hash_value}, metadata_path: {metadata_path}, metadata: {metadata}")
|
||||||
# Rename all files
|
# Rename all files
|
||||||
renamed_files = []
|
renamed_files = []
|
||||||
new_metadata_path = None
|
new_metadata_path = None
|
||||||
@@ -1093,3 +1093,63 @@ class ModelRouteUtils:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving metadata: {e}", exc_info=True)
|
logger.error(f"Error saving metadata: {e}", exc_info=True)
|
||||||
return web.Response(text=str(e), status=500)
|
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)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ body.modal-open {
|
|||||||
overflow-x: hidden; /* 防止水平滚动条 */
|
overflow-x: hidden; /* 防止水平滚动条 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content-large {
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 当 modal 打开时锁定 body */
|
/* 当 modal 打开时锁定 body */
|
||||||
body.modal-open {
|
body.modal-open {
|
||||||
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */
|
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 30px; /* Ensure some height even if empty to prevent layout shifts */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Individual Item */
|
/* Individual Item */
|
||||||
@@ -153,17 +154,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metadata-save-btn,
|
.metadata-save-btn,
|
||||||
.save-tags-btn {
|
.save-tags-btn,
|
||||||
|
.append-tags-btn,
|
||||||
|
.replace-tags-btn {
|
||||||
background: var(--lora-accent) !important;
|
background: var(--lora-accent) !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
border-color: var(--lora-accent) !important;
|
border-color: var(--lora-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-save-btn:hover,
|
.metadata-save-btn:hover,
|
||||||
.save-tags-btn:hover {
|
.save-tags-btn:hover,
|
||||||
|
.append-tags-btn:hover,
|
||||||
|
.replace-tags-btn:hover {
|
||||||
opacity: 0.9;
|
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 */
|
/* Add Form */
|
||||||
.metadata-add-form {
|
.metadata-add-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export function getApiEndpoints(modelType) {
|
|||||||
|
|
||||||
// Bulk operations
|
// Bulk operations
|
||||||
bulkDelete: `/api/${modelType}/bulk-delete`,
|
bulkDelete: `/api/${modelType}/bulk-delete`,
|
||||||
|
|
||||||
|
// Tag operations
|
||||||
|
addTags: `/api/${modelType}/add-tags`,
|
||||||
|
|
||||||
// Move operations (now common for all model types that support move)
|
// Move operations (now common for all model types that support move)
|
||||||
moveModel: `/api/${modelType}/move_model`,
|
moveModel: `/api/${modelType}/move_model`,
|
||||||
|
|||||||
@@ -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) {
|
async refreshModels(fullRebuild = false) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(
|
state.loadingManager.showSimpleLoading(
|
||||||
|
|||||||
@@ -464,10 +464,18 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup save button
|
// Setup save button
|
||||||
const saveBtn = document.querySelector('.bulk-save-tags-btn');
|
const appendBtn = document.querySelector('.bulk-append-tags-btn');
|
||||||
if (saveBtn) {
|
const replaceBtn = document.querySelector('.bulk-replace-tags-btn');
|
||||||
saveBtn.addEventListener('click', () => {
|
|
||||||
this.saveBulkTags();
|
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);
|
tagsContainer.appendChild(newTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveBulkTags() {
|
async saveBulkTags(mode = 'append') {
|
||||||
const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
|
const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
|
||||||
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||||
|
|
||||||
@@ -577,13 +585,17 @@ export class BulkManager {
|
|||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 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) {
|
for (const filePath of filePaths) {
|
||||||
try {
|
try {
|
||||||
await apiClient.addTags(filePath, { tags: tags });
|
if (mode === 'replace') {
|
||||||
|
await apiClient.saveModelMetadata(filePath, { tags: tags });
|
||||||
|
} else {
|
||||||
|
await apiClient.addTags(filePath, { tags: tags });
|
||||||
|
}
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to add tags to ${filePath}:`, error);
|
console.error(`Failed to ${mode} tags for ${filePath}:`, error);
|
||||||
failCount++;
|
failCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,7 +604,8 @@ export class BulkManager {
|
|||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||||
showToast('toast.models.tagsAddedSuccessfully', {
|
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
||||||
|
showToast(toastKey, {
|
||||||
count: successCount,
|
count: successCount,
|
||||||
tagCount: tags.length,
|
tagCount: tags.length,
|
||||||
type: currentConfig.displayName.toLowerCase()
|
type: currentConfig.displayName.toLowerCase()
|
||||||
@@ -600,12 +613,14 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failCount > 0) {
|
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) {
|
} catch (error) {
|
||||||
console.error('Error during bulk tag addition:', error);
|
console.error('Error during bulk tag operation:', error);
|
||||||
showToast('toast.models.bulkTagsAddFailed', {}, '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)
|
// Remove event listeners (they will be re-added when modal opens again)
|
||||||
const saveBtn = document.querySelector('.bulk-save-tags-btn');
|
const appendBtn = document.querySelector('.bulk-append-tags-btn');
|
||||||
if (saveBtn) {
|
if (appendBtn) {
|
||||||
saveBtn.replaceWith(saveBtn.cloneNode(true));
|
appendBtn.replaceWith(appendBtn.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceBtn = document.querySelector('.bulk-replace-tags-btn');
|
||||||
|
if (replaceBtn) {
|
||||||
|
replaceBtn.replaceWith(replaceBtn.cloneNode(true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<div id="bulkAddTagsModal" class="modal" style="display: none;">
|
<div id="bulkAddTagsModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content modal-content-large">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3><i class="fas fa-tags"></i> {{ t('modals.bulkAddTags.title') }}</h3>
|
<h2>{{ t('modals.bulkAddTags.title') }}</h2>
|
||||||
<button class="modal-close" onclick="modalManager.closeModal('bulkAddTagsModal')">
|
<span class="close" onclick="modalManager.closeModal('bulkAddTagsModal')">×</span>
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="bulk-add-tags-info">
|
<div class="bulk-add-tags-info">
|
||||||
@@ -21,8 +19,11 @@
|
|||||||
<!-- Tags will be added here dynamically -->
|
<!-- Tags will be added here dynamically -->
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls">
|
<div class="metadata-edit-controls">
|
||||||
<button class="save-tags-btn bulk-save-tags-btn" title="{{ t('modals.bulkAddTags.saveChanges') }}">
|
<button class="append-tags-btn bulk-append-tags-btn" title="{{ t('modals.bulkAddTags.appendTags') }}">
|
||||||
<i class="fas fa-save"></i> {{ t('common.actions.save') }}
|
<i class="fas fa-plus"></i> {{ t('modals.bulkAddTags.appendTags') }}
|
||||||
|
</button>
|
||||||
|
<button class="replace-tags-btn bulk-replace-tags-btn" title="{{ t('modals.bulkAddTags.replaceTags') }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i> {{ t('modals.bulkAddTags.replaceTags') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-add-form">
|
<div class="metadata-add-form">
|
||||||
|
|||||||
Reference in New Issue
Block a user