diff --git a/locales/de.json b/locales/de.json
index 064b7306..394ebd6f 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -194,7 +194,7 @@
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {
"default": "Standard",
- "medium": "Mittel",
+ "medium": "Mittel",
"compact": "Kompakt"
},
"displayDensityHelp": "Wählen Sie, wie viele Karten pro Zeile angezeigt werden sollen:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Flache Struktur",
"byBaseModel": "Nach Basis-Modell",
- "byAuthor": "Nach Autor",
+ "byAuthor": "Nach Autor",
"byFirstTag": "Nach erstem Tag",
"baseModelFirstTag": "Basis-Modell + Erster Tag",
"baseModelAuthor": "Basis-Modell + Autor",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Benutzerdefinierte Vorlage eingeben (z.B. {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
- "checkpoint": "Checkpoint",
+ "checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Basis-Modell-Pfad-Zuordnungen",
@@ -319,6 +319,7 @@
"selected": "{count} ausgewählt",
"selectedSuffix": "ausgewählt",
"viewSelected": "Klicken Sie, um ausgewählte Elemente anzuzeigen",
+ "addTags": "Tags hinzufügen",
"sendToWorkflow": "An Workflow senden",
"copyAll": "Alle kopieren",
"refreshAll": "Alle aktualisieren",
@@ -572,6 +573,16 @@
"countMessage": "Modelle werden dauerhaft gelöscht.",
"action": "Alle löschen"
},
+ "bulkAddTags": {
+ "title": "Tags zu mehreren Modellen hinzufügen",
+ "description": "Tags hinzufügen zu",
+ "models": "Modelle",
+ "tagsToAdd": "Hinzugefügte Tags",
+ "placeholder": "Tag eingeben und Enter drücken...",
+ "appendTags": "Tags anhängen",
+ "replaceTags": "Tags ersetzen",
+ "saveChanges": "Änderungen speichern"
+ },
"exampleAccess": {
"title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "Diese Gruppe wurde bereits verifiziert",
"verificationCompleteMismatch": "Verifikation abgeschlossen. {count} Datei(en) haben unterschiedliche tatsächliche Hashes.",
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
- "verificationFailed": "Fehler beim Verifizieren der Hashes: {message}"
+ "verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
+ "noTagsToAdd": "Keine Tags zum Hinzufügen",
+ "tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt",
+ "tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
+ "tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
+ "tagsReplaceFailed": "Fehler beim Ersetzen von Tags für {count} Modell(e)",
+ "bulkTagsAddFailed": "Fehler beim Hinzufügen von Tags zu Modellen",
+ "bulkTagsReplaceFailed": "Fehler beim Ersetzen von Tags für Modelle"
},
"search": {
"atLeastOneOption": "Mindestens eine Suchoption muss ausgewählt werden"
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/locales/es.json b/locales/es.json
index db682c59..b34b5ee0 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -194,7 +194,7 @@
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {
"default": "Predeterminado",
- "medium": "Medio",
+ "medium": "Medio",
"compact": "Compacto"
},
"displayDensityHelp": "Elige cuántas tarjetas mostrar por fila:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Estructura plana",
"byBaseModel": "Por modelo base",
- "byAuthor": "Por autor",
+ "byAuthor": "Por autor",
"byFirstTag": "Por primera etiqueta",
"baseModelFirstTag": "Modelo base + primera etiqueta",
"baseModelAuthor": "Modelo base + autor",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Introduce plantilla personalizada (ej., {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
- "checkpoint": "Checkpoint",
+ "checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Mapeos de rutas de modelo base",
@@ -319,6 +319,7 @@
"selected": "{count} seleccionados",
"selectedSuffix": "seleccionados",
"viewSelected": "Clic para ver elementos seleccionados",
+ "addTags": "Añadir etiquetas",
"sendToWorkflow": "Enviar al flujo de trabajo",
"copyAll": "Copiar todo",
"refreshAll": "Actualizar todo",
@@ -572,6 +573,16 @@
"countMessage": "modelos serán eliminados permanentemente.",
"action": "Eliminar todo"
},
+ "bulkAddTags": {
+ "title": "Añadir etiquetas a múltiples modelos",
+ "description": "Añadir etiquetas a",
+ "models": "modelos",
+ "tagsToAdd": "Etiquetas a añadir",
+ "placeholder": "Introduce una etiqueta y presiona Enter...",
+ "appendTags": "Añadir etiquetas",
+ "replaceTags": "Reemplazar etiquetas",
+ "saveChanges": "Guardar cambios"
+ },
"exampleAccess": {
"title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "Este grupo ya ha sido verificado",
"verificationCompleteMismatch": "Verificación completa. {count} archivo(s) tienen hashes reales diferentes.",
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
- "verificationFailed": "Error al verificar hashes: {message}"
+ "verificationFailed": "Error al verificar hashes: {message}",
+ "noTagsToAdd": "No hay etiquetas para añadir",
+ "tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)",
+ "tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
+ "tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
+ "tagsReplaceFailed": "Error al reemplazar etiquetas para {count} modelo(s)",
+ "bulkTagsAddFailed": "Error al añadir etiquetas a los modelos",
+ "bulkTagsReplaceFailed": "Error al reemplazar etiquetas para los modelos"
},
"search": {
"atLeastOneOption": "Al menos una opción de búsqueda debe estar seleccionada"
diff --git a/locales/fr.json b/locales/fr.json
index 5739e7cf..8c0a6d94 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -194,7 +194,7 @@
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {
"default": "Par défaut",
- "medium": "Moyen",
+ "medium": "Moyen",
"compact": "Compact"
},
"displayDensityHelp": "Choisissez combien de cartes afficher par ligne :",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Structure plate",
"byBaseModel": "Par modèle de base",
- "byAuthor": "Par auteur",
+ "byAuthor": "Par auteur",
"byFirstTag": "Par premier tag",
"baseModelFirstTag": "Modèle de base + Premier tag",
"baseModelAuthor": "Modèle de base + Auteur",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Entrez un modèle personnalisé (ex: {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
- "checkpoint": "Checkpoint",
+ "checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Mappages de chemin de modèle de base",
@@ -319,6 +319,7 @@
"selected": "{count} sélectionné(s)",
"selectedSuffix": "sélectionné(s)",
"viewSelected": "Cliquez pour voir les éléments sélectionnés",
+ "addTags": "Ajouter des tags",
"sendToWorkflow": "Envoyer vers le workflow",
"copyAll": "Tout copier",
"refreshAll": "Tout actualiser",
@@ -572,6 +573,16 @@
"countMessage": "modèles seront définitivement supprimés.",
"action": "Tout supprimer"
},
+ "bulkAddTags": {
+ "title": "Ajouter des tags à plusieurs modèles",
+ "description": "Ajouter des tags à",
+ "models": "modèles",
+ "tagsToAdd": "Tags à ajouter",
+ "placeholder": "Entrez un tag et appuyez sur Entrée...",
+ "appendTags": "Ajouter les tags",
+ "replaceTags": "Remplacer les tags",
+ "saveChanges": "Enregistrer les modifications"
+ },
"exampleAccess": {
"title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "Ce groupe a déjà été vérifié",
"verificationCompleteMismatch": "Vérification terminée. {count} fichier(s) ont des hash différents.",
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
- "verificationFailed": "Échec de la vérification des hash : {message}"
+ "verificationFailed": "Échec de la vérification des hash : {message}",
+ "noTagsToAdd": "Aucun tag à ajouter",
+ "tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)",
+ "tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)",
+ "tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)",
+ "tagsReplaceFailed": "Échec du remplacement des tags pour {count} modèle(s)",
+ "bulkTagsAddFailed": "Échec de l'ajout des tags aux modèles",
+ "bulkTagsReplaceFailed": "Échec du remplacement des tags pour les modèles"
},
"search": {
"atLeastOneOption": "Au moins une option de recherche doit être sélectionnée"
diff --git a/locales/ja.json b/locales/ja.json
index d1fc6bd5..285af83b 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -319,6 +319,7 @@
"selected": "{count} 選択中",
"selectedSuffix": "選択中",
"viewSelected": "選択したアイテムを表示するにはクリック",
+ "addTags": "タグを追加",
"sendToWorkflow": "ワークフローに送信",
"copyAll": "すべてコピー",
"refreshAll": "すべて更新",
@@ -572,6 +573,16 @@
"countMessage": "モデルが完全に削除されます。",
"action": "すべて削除"
},
+ "bulkAddTags": {
+ "title": "複数モデルにタグを追加",
+ "description": "タグを追加するモデル:",
+ "models": "モデル",
+ "tagsToAdd": "追加するタグ",
+ "placeholder": "タグを入力してEnterを押してください...",
+ "appendTags": "タグを追加",
+ "replaceTags": "タグを置換",
+ "saveChanges": "変更を保存"
+ },
"exampleAccess": {
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "このグループは既に検証済みです",
"verificationCompleteMismatch": "検証完了。{count} ファイルの実際のハッシュが異なります。",
"verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。",
- "verificationFailed": "ハッシュの検証に失敗しました:{message}"
+ "verificationFailed": "ハッシュの検証に失敗しました:{message}",
+ "noTagsToAdd": "追加するタグがありません",
+ "tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました",
+ "tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました",
+ "tagsAddFailed": "{count} モデルへのタグ追加に失敗しました",
+ "tagsReplaceFailed": "{count} モデルのタグ置換に失敗しました",
+ "bulkTagsAddFailed": "モデルへのタグ追加に失敗しました",
+ "bulkTagsReplaceFailed": "モデルのタグ置換に失敗しました"
},
"search": {
"atLeastOneOption": "少なくとも1つの検索オプションを選択する必要があります"
diff --git a/locales/ko.json b/locales/ko.json
index 5d1b5447..09c892af 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -194,7 +194,7 @@
"displayDensity": "표시 밀도",
"displayDensityOptions": {
"default": "기본",
- "medium": "중간",
+ "medium": "중간",
"compact": "조밀"
},
"displayDensityHelp": "한 줄에 표시할 카드 수를 선택하세요:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "플랫 구조",
"byBaseModel": "베이스 모델별",
- "byAuthor": "제작자별",
+ "byAuthor": "제작자별",
"byFirstTag": "첫 번째 태그별",
"baseModelFirstTag": "베이스 모델 + 첫 번째 태그",
"baseModelAuthor": "베이스 모델 + 제작자",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "사용자 정의 템플릿 입력 (예: {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
- "checkpoint": "Checkpoint",
+ "checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "베이스 모델 경로 매핑",
@@ -319,6 +319,7 @@
"selected": "{count}개 선택됨",
"selectedSuffix": "개 선택됨",
"viewSelected": "선택된 항목 보기",
+ "addTags": "태그 추가",
"sendToWorkflow": "워크플로로 전송",
"copyAll": "모두 복사",
"refreshAll": "모두 새로고침",
@@ -572,6 +573,16 @@
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
"action": "모두 삭제"
},
+ "bulkAddTags": {
+ "title": "여러 모델에 태그 추가",
+ "description": "다음에 태그를 추가합니다:",
+ "models": "모델",
+ "tagsToAdd": "추가할 태그",
+ "placeholder": "태그를 입력하고 Enter를 누르세요...",
+ "appendTags": "태그 추가",
+ "replaceTags": "태그 교체",
+ "saveChanges": "변경사항 저장"
+ },
"exampleAccess": {
"title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "이 그룹은 이미 검증되었습니다",
"verificationCompleteMismatch": "검증 완료. {count}개 파일의 실제 해시가 다릅니다.",
"verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.",
- "verificationFailed": "해시 검증 실패: {message}"
+ "verificationFailed": "해시 검증 실패: {message}",
+ "noTagsToAdd": "추가할 태그가 없습니다",
+ "tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다",
+ "tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다",
+ "tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다",
+ "tagsReplaceFailed": "{count}개의 모델의 태그 교체에 실패했습니다",
+ "bulkTagsAddFailed": "모델에 태그 추가에 실패했습니다",
+ "bulkTagsReplaceFailed": "모델의 태그 교체에 실패했습니다"
},
"search": {
"atLeastOneOption": "최소 하나의 검색 옵션을 선택해야 합니다"
diff --git a/locales/ru.json b/locales/ru.json
index d3d13607..3fc92156 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -194,7 +194,7 @@
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
"default": "По умолчанию",
- "medium": "Средняя",
+ "medium": "Средняя",
"compact": "Компактная"
},
"displayDensityHelp": "Выберите количество карточек для отображения в ряду:",
@@ -231,7 +231,7 @@
"templateOptions": {
"flatStructure": "Плоская структура",
"byBaseModel": "По базовой модели",
- "byAuthor": "По автору",
+ "byAuthor": "По автору",
"byFirstTag": "По первому тегу",
"baseModelFirstTag": "Базовая модель + Первый тег",
"baseModelAuthor": "Базовая модель + Автор",
@@ -241,7 +241,7 @@
"customTemplatePlaceholder": "Введите пользовательский шаблон (например, {base_model}/{author}/{first_tag})",
"modelTypes": {
"lora": "LoRA",
- "checkpoint": "Checkpoint",
+ "checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"baseModelPathMappings": "Сопоставления путей базовых моделей",
@@ -319,6 +319,7 @@
"selected": "Выбрано {count}",
"selectedSuffix": "выбрано",
"viewSelected": "Нажмите для просмотра выбранных элементов",
+ "addTags": "Добавить теги",
"sendToWorkflow": "Отправить в Workflow",
"copyAll": "Копировать все",
"refreshAll": "Обновить все",
@@ -572,6 +573,16 @@
"countMessage": "моделей будут удалены навсегда.",
"action": "Удалить все"
},
+ "bulkAddTags": {
+ "title": "Добавить теги к нескольким моделям",
+ "description": "Добавить теги к",
+ "models": "моделям",
+ "tagsToAdd": "Теги для добавления",
+ "placeholder": "Введите тег и нажмите Enter...",
+ "appendTags": "Добавить теги",
+ "replaceTags": "Заменить теги",
+ "saveChanges": "Сохранить изменения"
+ },
"exampleAccess": {
"title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "Эта группа уже была проверена",
"verificationCompleteMismatch": "Проверка завершена. {count} файл(ов) имеют разные фактические хеши.",
"verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.",
- "verificationFailed": "Не удалось проверить хеши: {message}"
+ "verificationFailed": "Не удалось проверить хеши: {message}",
+ "noTagsToAdd": "Нет тегов для добавления",
+ "tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)",
+ "tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)",
+ "tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)",
+ "tagsReplaceFailed": "Не удалось заменить теги для {count} модель(ей)",
+ "bulkTagsAddFailed": "Не удалось добавить теги к моделям",
+ "bulkTagsReplaceFailed": "Не удалось заменить теги для моделей"
},
"search": {
"atLeastOneOption": "Должен быть выбран хотя бы один вариант поиска"
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index c843ed9d..c9182de6 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -319,6 +319,7 @@
"selected": "已选中 {count} 项",
"selectedSuffix": "已选中",
"viewSelected": "点击查看已选项目",
+ "addTags": "批量添加标签",
"sendToWorkflow": "发送到工作流",
"copyAll": "全部复制",
"refreshAll": "全部刷新",
@@ -572,6 +573,16 @@
"countMessage": "模型将被永久删除。",
"action": "全部删除"
},
+ "bulkAddTags": {
+ "title": "批量添加标签",
+ "description": "为多个模型添加标签",
+ "models": "个模型",
+ "tagsToAdd": "要添加的标签",
+ "placeholder": "输入标签并按回车...",
+ "appendTags": "追加标签",
+ "replaceTags": "替换标签",
+ "saveChanges": "保存更改"
+ },
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "此组已验证过",
"verificationCompleteMismatch": "验证完成。{count} 个文件实际哈希不同。",
"verificationCompleteSuccess": "验证完成。所有文件均为重复项。",
- "verificationFailed": "验证哈希失败:{message}"
+ "verificationFailed": "验证哈希失败:{message}",
+ "noTagsToAdd": "没有可添加的标签",
+ "tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签",
+ "tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签",
+ "tagsAddFailed": "为 {count} 个模型添加标签失败",
+ "tagsReplaceFailed": "为 {count} 个模型替换标签失败",
+ "bulkTagsAddFailed": "批量添加标签失败",
+ "bulkTagsReplaceFailed": "批量替换标签失败"
},
"search": {
"atLeastOneOption": "至少选择一个搜索选项"
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index c04f39ae..5b815135 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -319,6 +319,7 @@
"selected": "已選擇 {count} 項",
"selectedSuffix": "已選擇",
"viewSelected": "點擊檢視已選項目",
+ "addTags": "新增標籤",
"sendToWorkflow": "傳送到工作流",
"copyAll": "全部複製",
"refreshAll": "全部刷新",
@@ -572,6 +573,16 @@
"countMessage": "模型將被永久刪除。",
"action": "全部刪除"
},
+ "bulkAddTags": {
+ "title": "新增標籤到多個模型",
+ "description": "新增標籤到",
+ "models": "個模型",
+ "tagsToAdd": "要新增的標籤",
+ "placeholder": "輸入標籤並按 Enter...",
+ "appendTags": "附加標籤",
+ "replaceTags": "取代標籤",
+ "saveChanges": "儲存變更"
+ },
"exampleAccess": {
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
@@ -987,7 +998,14 @@
"verificationAlreadyDone": "此群組已驗證過",
"verificationCompleteMismatch": "驗證完成。{count} 個檔案的實際雜湊不同。",
"verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。",
- "verificationFailed": "驗證雜湊失敗:{message}"
+ "verificationFailed": "驗證雜湊失敗:{message}",
+ "noTagsToAdd": "沒有可新增的標籤",
+ "tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}",
+ "tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤",
+ "tagsAddFailed": "新增標籤到 {count} 個模型失敗",
+ "tagsReplaceFailed": "取代 {count} 個模型的標籤失敗",
+ "bulkTagsAddFailed": "批量新增標籤到模型失敗",
+ "bulkTagsReplaceFailed": "批量取代模型標籤失敗"
},
"search": {
"atLeastOneOption": "至少需選擇一個搜尋選項"
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/scripts/sync_translation_keys.py b/scripts/sync_translation_keys.py
new file mode 100644
index 00000000..4244bde0
--- /dev/null
+++ b/scripts/sync_translation_keys.py
@@ -0,0 +1,293 @@
+#!/usr/bin/env python3
+"""
+Translation Key Synchronization Script
+
+This script synchronizes new translation keys from en.json to all other locale files
+while maintaining exact formatting consistency to pass test_i18n.py validation.
+
+Features:
+- Preserves exact line-by-line formatting
+- Maintains proper indentation and structure
+- Adds missing keys with placeholder translations
+- Handles nested objects correctly
+- Ensures all locale files have identical structure
+
+Usage:
+ python scripts/sync_translation_keys.py [--dry-run] [--verbose]
+"""
+
+import os
+import sys
+import json
+import re
+import argparse
+from typing import Dict, List, Set, Tuple, Any, Optional
+from collections import OrderedDict
+
+# Add the parent directory to the path so we can import modules if needed
+sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+
+class TranslationKeySynchronizer:
+ """Synchronizes translation keys across locale files while maintaining formatting."""
+
+ def __init__(self, locales_dir: str, verbose: bool = False):
+ self.locales_dir = locales_dir
+ self.verbose = verbose
+ self.reference_locale = 'en'
+ self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
+
+ def log(self, message: str, level: str = 'INFO'):
+ """Log a message if verbose mode is enabled."""
+ if self.verbose or level == 'ERROR':
+ print(f"[{level}] {message}")
+
+ def load_json_preserve_order(self, file_path: str) -> Tuple[Dict[str, Any], List[str]]:
+ """
+ Load a JSON file preserving the exact order and formatting.
+ Returns both the parsed data and the original lines.
+ """
+ with open(file_path, 'r', encoding='utf-8') as f:
+ lines = f.readlines()
+ content = ''.join(lines)
+
+ # Parse JSON while preserving order
+ data = json.loads(content, object_pairs_hook=OrderedDict)
+ return data, lines
+
+ def get_all_leaf_keys(self, data: Any, prefix: str = '') -> Dict[str, Any]:
+ """
+ Extract all leaf keys (non-object values) with their full paths.
+ Returns a dictionary mapping full key paths to their values.
+ """
+ keys = {}
+
+ if isinstance(data, (dict, OrderedDict)):
+ for key, value in data.items():
+ full_key = f"{prefix}.{key}" if prefix else key
+
+ if isinstance(value, (dict, OrderedDict)):
+ # Recursively get nested keys
+ keys.update(self.get_all_leaf_keys(value, full_key))
+ else:
+ # Leaf node - actual translatable value
+ keys[full_key] = value
+
+ return keys
+
+ def merge_json_structures(self, reference_data: Dict[str, Any], target_data: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Merge the reference JSON structure with existing target translations.
+ This creates a new structure that matches the reference exactly but preserves
+ existing translations where available.
+ """
+ def merge_recursive(ref_obj, target_obj):
+ if isinstance(ref_obj, (dict, OrderedDict)):
+ result = OrderedDict()
+ for key, ref_value in ref_obj.items():
+ if key in target_obj and isinstance(target_obj[key], type(ref_value)):
+ # Key exists in target with same type
+ if isinstance(ref_value, (dict, OrderedDict)):
+ # Recursively merge nested objects
+ result[key] = merge_recursive(ref_value, target_obj[key])
+ else:
+ # Use existing translation
+ result[key] = target_obj[key]
+ else:
+ # Key missing in target or type mismatch
+ if isinstance(ref_value, (dict, OrderedDict)):
+ # Recursively handle nested objects
+ result[key] = merge_recursive(ref_value, {})
+ else:
+ # Create placeholder translation
+ result[key] = f"[TODO: Translate] {ref_value}"
+ return result
+ else:
+ # For non-dict values, use reference (this shouldn't happen at root level)
+ return ref_obj
+
+ return merge_recursive(reference_data, target_data)
+
+ def format_json_like_reference(self, data: Dict[str, Any], reference_lines: List[str]) -> List[str]:
+ """
+ Format the merged JSON data to match the reference file's formatting exactly.
+ """
+ # Use json.dumps with proper formatting to match the reference style
+ formatted_json = json.dumps(data, indent=4, ensure_ascii=False, separators=(',', ': '))
+
+ # Split into lines and ensure consistent line endings
+ formatted_lines = [line + '\n' for line in formatted_json.split('\n')]
+
+ # Make sure the last line doesn't have extra newlines
+ if formatted_lines and formatted_lines[-1].strip() == '':
+ formatted_lines = formatted_lines[:-1]
+
+ # Ensure the last line ends with just a newline
+ if formatted_lines and not formatted_lines[-1].endswith('\n'):
+ formatted_lines[-1] += '\n'
+
+ return formatted_lines
+
+ def synchronize_locale_simple(self, locale: str, reference_data: Dict[str, Any],
+ reference_lines: List[str], dry_run: bool = False) -> bool:
+ """
+ Synchronize a locale file using JSON structure merging.
+ """
+ locale_file = os.path.join(self.locales_dir, f'{locale}.json')
+
+ if not os.path.exists(locale_file):
+ self.log(f"Locale file {locale_file} does not exist!", 'ERROR')
+ return False
+
+ try:
+ target_data, _ = self.load_json_preserve_order(locale_file)
+ except Exception as e:
+ self.log(f"Error loading {locale_file}: {e}", 'ERROR')
+ return False
+
+ # Get keys to check for missing ones
+ ref_keys = self.get_all_leaf_keys(reference_data)
+ target_keys = self.get_all_leaf_keys(target_data)
+ missing_keys = set(ref_keys.keys()) - set(target_keys.keys())
+
+ if not missing_keys:
+ self.log(f"Locale {locale} is already up to date")
+ return False
+
+ self.log(f"Found {len(missing_keys)} missing keys in {locale}:")
+ for key in sorted(missing_keys):
+ self.log(f" - {key}")
+
+ if dry_run:
+ self.log(f"DRY RUN: Would update {locale} with {len(missing_keys)} new keys")
+ return True
+
+ # Merge the structures
+ try:
+ merged_data = self.merge_json_structures(reference_data, target_data)
+
+ # Format to match reference style
+ new_lines = self.format_json_like_reference(merged_data, reference_lines)
+
+ # Validate that the result is valid JSON
+ reconstructed_content = ''.join(new_lines)
+ json.loads(reconstructed_content) # This will raise an exception if invalid
+
+ # Write the updated file
+ with open(locale_file, 'w', encoding='utf-8') as f:
+ f.writelines(new_lines)
+
+ self.log(f"Successfully updated {locale} with {len(missing_keys)} new keys")
+ return True
+
+ except json.JSONDecodeError as e:
+ self.log(f"Generated invalid JSON for {locale}: {e}", 'ERROR')
+ return False
+ except Exception as e:
+ self.log(f"Error updating {locale_file}: {e}", 'ERROR')
+ return False
+
+ def synchronize_all(self, dry_run: bool = False) -> bool:
+ """
+ Synchronize all locale files with the reference.
+ Returns True if all operations were successful.
+ """
+ # Load reference file
+ reference_file = os.path.join(self.locales_dir, f'{self.reference_locale}.json')
+
+ if not os.path.exists(reference_file):
+ self.log(f"Reference file {reference_file} does not exist!", 'ERROR')
+ return False
+
+ try:
+ reference_data, reference_lines = self.load_json_preserve_order(reference_file)
+ reference_keys = self.get_all_leaf_keys(reference_data)
+ except Exception as e:
+ self.log(f"Error loading reference file: {e}", 'ERROR')
+ return False
+
+ self.log(f"Loaded reference file with {len(reference_keys)} keys")
+
+ success = True
+ changes_made = False
+
+ # Synchronize each target locale
+ for locale in self.target_locales:
+ try:
+ if self.synchronize_locale_simple(locale, reference_data, reference_lines, dry_run):
+ changes_made = True
+ except Exception as e:
+ self.log(f"Error synchronizing {locale}: {e}", 'ERROR')
+ success = False
+
+ if changes_made:
+ self.log("Synchronization completed with changes")
+ else:
+ self.log("All locale files are already up to date")
+
+ return success
+
+def main():
+ """Main entry point for the script."""
+ parser = argparse.ArgumentParser(
+ description='Synchronize translation keys from en.json to all other locale files'
+ )
+ parser.add_argument(
+ '--dry-run',
+ action='store_true',
+ help='Show what would be changed without making actual changes'
+ )
+ parser.add_argument(
+ '--verbose', '-v',
+ action='store_true',
+ help='Enable verbose output'
+ )
+ parser.add_argument(
+ '--locales-dir',
+ default=None,
+ help='Path to locales directory (default: auto-detect from script location)'
+ )
+
+ args = parser.parse_args()
+
+ # Determine locales directory
+ if args.locales_dir:
+ locales_dir = args.locales_dir
+ else:
+ # Auto-detect based on script location
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ locales_dir = os.path.join(os.path.dirname(script_dir), 'locales')
+
+ if not os.path.exists(locales_dir):
+ print(f"ERROR: Locales directory not found: {locales_dir}")
+ sys.exit(1)
+
+ print(f"Translation Key Synchronization")
+ print(f"Locales directory: {locales_dir}")
+ print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE UPDATE'}")
+ print("-" * 50)
+
+ # Create synchronizer and run
+ synchronizer = TranslationKeySynchronizer(locales_dir, args.verbose)
+
+ try:
+ success = synchronizer.synchronize_all(args.dry_run)
+
+ if success:
+ print("\n✅ Synchronization completed successfully!")
+ if not args.dry_run:
+ print("💡 Run 'python test_i18n.py' to verify formatting consistency")
+ else:
+ print("\n❌ Synchronization completed with errors!")
+ sys.exit(1)
+
+ except KeyboardInterrupt:
+ print("\n⚠️ Operation cancelled by user")
+ sys.exit(1)
+ except Exception as e:
+ print(f"\n❌ Unexpected error: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+if __name__ == '__main__':
+ main()
diff --git a/test_i18n.py b/scripts/test_i18n.py
similarity index 100%
rename from test_i18n.py
rename to scripts/test_i18n.py
diff --git a/static/css/components/menu.css b/static/css/components/menu.css
index 566f71db..481bb213 100644
--- a/static/css/components/menu.css
+++ b/static/css/components/menu.css
@@ -176,11 +176,6 @@
background: linear-gradient(45deg, #4a90e2, #357abd);
}
-/* Remove old node-color-indicator styles */
-.node-color-indicator {
- display: none;
-}
-
.send-all-item {
border-top: 1px solid var(--border-color);
font-weight: 500;
@@ -217,4 +212,22 @@
font-size: 12px;
color: var(--text-muted);
font-style: italic;
+}
+
+/* Bulk Context Menu Header */
+.bulk-context-header {
+ padding: 10px 12px;
+ background: var(--lora-accent);
+ color: var(--lora-text);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 500;
+ font-size: 14px;
+ border-radius: var(--border-radius-xs) var(--border-radius-xs) 0 0;
+}
+
+.bulk-context-header i {
+ width: 16px;
+ text-align: center;
}
\ No newline at end of file
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/checkpoints.js b/static/js/checkpoints.js
index 907e92a6..a26099d3 100644
--- a/static/js/checkpoints.js
+++ b/static/js/checkpoints.js
@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
-import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
@@ -30,10 +29,7 @@ class CheckpointsPageManager {
}
async initialize() {
- // Initialize context menu
- new CheckpointContextMenu();
-
- // Initialize common page features
+ // Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Checkpoints Manager initialized');
@@ -48,4 +44,4 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize checkpoints page
const checkpointsPage = new CheckpointsPageManager();
await checkpointsPage.initialize();
-});
+});
\ No newline at end of file
diff --git a/static/js/components/ContextMenu/BaseContextMenu.js b/static/js/components/ContextMenu/BaseContextMenu.js
index e2f9edfc..8ec2d9eb 100644
--- a/static/js/components/ContextMenu/BaseContextMenu.js
+++ b/static/js/components/ContextMenu/BaseContextMenu.js
@@ -15,17 +15,6 @@ export class BaseContextMenu {
init() {
// Hide menu on regular clicks
document.addEventListener('click', () => this.hideMenu());
-
- // Show menu on right-click on cards
- document.addEventListener('contextmenu', (e) => {
- const card = e.target.closest(this.cardSelector);
- if (!card) {
- this.hideMenu();
- return;
- }
- e.preventDefault();
- this.showMenu(e.clientX, e.clientY, card);
- });
// Handle menu item clicks
this.menu.addEventListener('click', (e) => {
diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js
new file mode 100644
index 00000000..70030764
--- /dev/null
+++ b/static/js/components/ContextMenu/BulkContextMenu.js
@@ -0,0 +1,96 @@
+import { BaseContextMenu } from './BaseContextMenu.js';
+import { state } from '../../state/index.js';
+import { bulkManager } from '../../managers/BulkManager.js';
+import { updateElementText } from '../../utils/i18nHelpers.js';
+
+export class BulkContextMenu extends BaseContextMenu {
+ constructor() {
+ super('bulkContextMenu', '.model-card.selected');
+ this.setupBulkMenuItems();
+ }
+
+ setupBulkMenuItems() {
+ if (!this.menu) return;
+
+ // Update menu items visibility based on current model type
+ this.updateMenuItemsForModelType();
+
+ // Update selected count in header
+ this.updateSelectedCountHeader();
+ }
+
+ updateMenuItemsForModelType() {
+ const currentModelType = state.currentPageType;
+ const config = bulkManager.actionConfig[currentModelType];
+
+ if (!config) return;
+
+ // Update button visibility based on model type
+ const addTagsItem = this.menu.querySelector('[data-action="add-tags"]');
+ const sendToWorkflowItem = this.menu.querySelector('[data-action="send-to-workflow"]');
+ const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
+ const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]');
+ const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
+ const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
+
+ if (sendToWorkflowItem) {
+ sendToWorkflowItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
+ }
+ if (copyAllItem) {
+ copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
+ }
+ if (refreshAllItem) {
+ refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
+ }
+ if (moveAllItem) {
+ moveAllItem.style.display = config.moveAll ? 'flex' : 'none';
+ }
+ if (deleteAllItem) {
+ deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none';
+ }
+ if (addTagsItem) {
+ addTagsItem.style.display = config.addTags ? 'flex' : 'none';
+ }
+ }
+
+ updateSelectedCountHeader() {
+ const headerElement = this.menu.querySelector('.bulk-context-header');
+ if (headerElement) {
+ updateElementText(headerElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
+ }
+ }
+
+ showMenu(x, y, card) {
+ this.updateMenuItemsForModelType();
+ this.updateSelectedCountHeader();
+ super.showMenu(x, y, card);
+ }
+
+ handleMenuAction(action, menuItem) {
+ switch (action) {
+ case 'add-tags':
+ bulkManager.showBulkAddTagsModal();
+ break;
+ case 'send-to-workflow':
+ bulkManager.sendAllModelsToWorkflow();
+ break;
+ case 'copy-all':
+ bulkManager.copyAllModelsSyntax();
+ break;
+ case 'refresh-all':
+ bulkManager.refreshAllMetadata();
+ break;
+ case 'move-all':
+ window.moveManager.showMoveModal('bulk');
+ break;
+ case 'delete-all':
+ bulkManager.showBulkDeleteModal();
+ break;
+ case 'clear':
+ bulkManager.clearSelection();
+ break;
+ default:
+ console.warn(`Unknown bulk action: ${action}`);
+ }
+ }
+}
diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js
index af539a53..306777ae 100644
--- a/static/js/components/ContextMenu/index.js
+++ b/static/js/components/ContextMenu/index.js
@@ -2,4 +2,56 @@ export { LoraContextMenu } from './LoraContextMenu.js';
export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
-export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
\ No newline at end of file
+export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
+
+import { LoraContextMenu } from './LoraContextMenu.js';
+import { RecipeContextMenu } from './RecipeContextMenu.js';
+import { CheckpointContextMenu } from './CheckpointContextMenu.js';
+import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
+import { state } from '../../state/index.js';
+
+// Factory method to create page-specific context menu instances
+export function createPageContextMenu(pageType) {
+ switch (pageType) {
+ case 'loras':
+ return new LoraContextMenu();
+ case 'recipes':
+ return new RecipeContextMenu();
+ case 'checkpoints':
+ return new CheckpointContextMenu();
+ case 'embeddings':
+ return new EmbeddingContextMenu();
+ default:
+ return null;
+ }
+}
+
+// Initialize context menu coordination for pages that support it
+export function initializeContextMenuCoordination(pageContextMenu, bulkContextMenu) {
+ // Centralized context menu event handler
+ document.addEventListener('contextmenu', (e) => {
+ const card = e.target.closest('.model-card');
+ if (!card) {
+ // Hide all menus if not right-clicking on a card
+ pageContextMenu?.hideMenu();
+ bulkContextMenu?.hideMenu();
+ return;
+ }
+
+ e.preventDefault();
+
+ // Hide all menus first
+ pageContextMenu?.hideMenu();
+ bulkContextMenu?.hideMenu();
+
+ // Determine which menu to show based on bulk mode and selection state
+ if (state.bulkMode && card.classList.contains('selected')) {
+ // Show bulk menu for selected cards in bulk mode
+ bulkContextMenu?.showMenu(e.clientX, e.clientY, card);
+ } else if (!state.bulkMode) {
+ // Show regular menu when not in bulk mode
+ pageContextMenu?.showMenu(e.clientX, e.clientY, card);
+ }
+ // Don't show any menu for unselected cards in bulk mode
+ });
+}
\ No newline at end of file
diff --git a/static/js/components/shared/ModelTags.js b/static/js/components/shared/ModelTags.js
index 87abe0ce..08ae6900 100644
--- a/static/js/components/shared/ModelTags.js
+++ b/static/js/components/shared/ModelTags.js
@@ -4,14 +4,7 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
-import { translate } from '../../utils/i18nHelpers.js';
-
-// Preset tag suggestions
-const PRESET_TAGS = [
- 'character', 'style', 'concept', 'clothing',
- 'poses', 'background', 'vehicle', 'buildings',
- 'objects', 'animal'
-];
+import { PRESET_TAGS } from '../../utils/constants.js';
// Create a named function so we can remove it later
let saveTagsHandler = null;
@@ -139,7 +132,7 @@ export function setupTagEditMode() {
// ...existing helper functions...
/**
- * Save tags - 支持LoRA和Checkpoint
+ * Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
diff --git a/static/js/core.js b/static/js/core.js
index 1a250aca..eb7d8dba 100644
--- a/static/js/core.js
+++ b/static/js/core.js
@@ -15,11 +15,15 @@ import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js';
+import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
+import { createPageContextMenu, initializeContextMenuCoordination } from './components/ContextMenu/index.js';
// Core application class
export class AppCore {
constructor() {
this.initialized = false;
+ this.pageContextMenu = null;
+ this.bulkContextMenu = null;
}
// Initialize core functionality
@@ -55,6 +59,10 @@ export class AppCore {
// Initialize the bulk manager
bulkManager.initialize();
+ // Initialize bulk context menu
+ const bulkContextMenu = new BulkContextMenu();
+ bulkManager.setBulkContextMenu(bulkContextMenu);
+
// Initialize the example images manager
exampleImagesManager.initialize();
// Initialize the help manager
@@ -88,13 +96,27 @@ export class AppCore {
initializePageFeatures() {
const pageType = this.getPageType();
- // Initialize virtual scroll for pages that need it
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
+ this.initializeContextMenus(pageType);
initializeInfiniteScroll(pageType);
}
return this;
}
+
+ // Initialize context menus for the current page
+ initializeContextMenus(pageType) {
+ // Create page-specific context menu
+ this.pageContextMenu = createPageContextMenu(pageType);
+
+ // Get bulk context menu from bulkManager
+ this.bulkContextMenu = bulkManager.bulkContextMenu;
+
+ // Initialize context menu coordination
+ if (this.pageContextMenu || this.bulkContextMenu) {
+ initializeContextMenuCoordination(this.pageContextMenu, this.bulkContextMenu);
+ }
+ }
}
document.addEventListener('DOMContentLoaded', () => {
diff --git a/static/js/embeddings.js b/static/js/embeddings.js
index c2276ce8..1352471c 100644
--- a/static/js/embeddings.js
+++ b/static/js/embeddings.js
@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
-import { EmbeddingContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
@@ -30,10 +29,7 @@ class EmbeddingsPageManager {
}
async initialize() {
- // Initialize context menu
- new EmbeddingContextMenu();
-
- // Initialize common page features
+ // Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Embeddings Manager initialized');
diff --git a/static/js/loras.js b/static/js/loras.js
index d8820cac..6566235f 100644
--- a/static/js/loras.js
+++ b/static/js/loras.js
@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
-import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
@@ -37,13 +36,10 @@ class LoraPageManager {
}
async initialize() {
- // Initialize page-specific components
- new LoraContextMenu();
-
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
- // Initialize common page features (virtual scroll)
+ // Initialize common page features (including context menus and virtual scroll)
appCore.initializePageFeatures();
}
}
diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js
index 5dc2bc97..7ecdadcf 100644
--- a/static/js/managers/BulkManager.js
+++ b/static/js/managers/BulkManager.js
@@ -2,22 +2,20 @@ import { state, getCurrentPageState } from '../state/index.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
-import { moveManager } from './MoveManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
-import { updateElementText } from '../utils/i18nHelpers.js';
+import { PRESET_TAGS } from '../utils/constants.js';
export class BulkManager {
constructor() {
this.bulkBtn = document.getElementById('bulkOperationsBtn');
- this.bulkPanel = document.getElementById('bulkOperationsPanel');
- this.isStripVisible = false;
-
- this.stripMaxThumbnails = 50;
+ // Remove bulk panel references since we're using context menu now
+ this.bulkContextMenu = null; // Will be set by core initialization
// Model type specific action configurations
this.actionConfig = {
[MODEL_TYPES.LORA]: {
+ addTags: true,
sendToWorkflow: true,
copyAll: true,
refreshAll: true,
@@ -25,6 +23,7 @@ export class BulkManager {
deleteAll: true
},
[MODEL_TYPES.EMBEDDING]: {
+ addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
@@ -32,6 +31,7 @@ export class BulkManager {
deleteAll: true
},
[MODEL_TYPES.CHECKPOINT]: {
+ addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
@@ -46,41 +46,13 @@ export class BulkManager {
this.setupGlobalKeyboardListeners();
}
+ setBulkContextMenu(bulkContextMenu) {
+ this.bulkContextMenu = bulkContextMenu;
+ }
+
setupEventListeners() {
- // Bulk operations button listeners
- const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
- const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
- const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
- const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
- const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
- const clearBtn = this.bulkPanel?.querySelector('[data-action="clear"]');
-
- if (sendToWorkflowBtn) {
- sendToWorkflowBtn.addEventListener('click', () => this.sendAllModelsToWorkflow());
- }
- if (copyAllBtn) {
- copyAllBtn.addEventListener('click', () => this.copyAllModelsSyntax());
- }
- if (refreshAllBtn) {
- refreshAllBtn.addEventListener('click', () => this.refreshAllMetadata());
- }
- if (moveAllBtn) {
- moveAllBtn.addEventListener('click', () => {
- moveManager.showMoveModal('bulk');
- });
- }
- if (deleteAllBtn) {
- deleteAllBtn.addEventListener('click', () => this.showBulkDeleteModal());
- }
- if (clearBtn) {
- clearBtn.addEventListener('click', () => this.clearSelection());
- }
-
- // Selected count click listener
- const selectedCount = document.getElementById('selectedCount');
- if (selectedCount) {
- selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
- }
+ // Only setup bulk mode toggle button listener now
+ // Context menu actions are handled by BulkContextMenu
}
setupGlobalKeyboardListeners() {
@@ -115,60 +87,15 @@ export class BulkManager {
this.bulkBtn.classList.toggle('active', state.bulkMode);
- if (state.bulkMode) {
- this.bulkPanel.classList.remove('hidden');
- this.updateActionButtonsVisibility();
- setTimeout(() => {
- this.bulkPanel.classList.add('visible');
- }, 10);
- } else {
- this.bulkPanel.classList.remove('visible');
- setTimeout(() => {
- this.bulkPanel.classList.add('hidden');
- }, 400);
- this.hideThumbnailStrip();
- }
-
updateCardsForBulkMode(state.bulkMode);
if (!state.bulkMode) {
this.clearSelection();
- // TODO:
- document.querySelectorAll('.model-card').forEach(card => {
- const actions = card.querySelectorAll('.card-actions, .card-button');
- actions.forEach(action => action.style.display = 'flex');
- });
- }
- }
-
- updateActionButtonsVisibility() {
- const currentModelType = state.currentPageType;
- const config = this.actionConfig[currentModelType];
-
- if (!config) return;
-
- // Update button visibility based on model type
- const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
- const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
- const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
- const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
- const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
-
- if (sendToWorkflowBtn) {
- sendToWorkflowBtn.style.display = config.sendToWorkflow ? 'block' : 'none';
- }
- if (copyAllBtn) {
- copyAllBtn.style.display = config.copyAll ? 'block' : 'none';
- }
- if (refreshAllBtn) {
- refreshAllBtn.style.display = config.refreshAll ? 'block' : 'none';
- }
- if (moveAllBtn) {
- moveAllBtn.style.display = config.moveAll ? 'block' : 'none';
- }
- if (deleteAllBtn) {
- deleteAllBtn.style.display = config.deleteAll ? 'block' : 'none';
+ // Hide context menu when exiting bulk mode
+ if (this.bulkContextMenu) {
+ this.bulkContextMenu.hideMenu();
+ }
}
}
@@ -177,27 +104,10 @@ export class BulkManager {
card.classList.remove('selected');
});
state.selectedModels.clear();
- this.updateSelectedCount();
- this.hideThumbnailStrip();
- }
-
- updateSelectedCount() {
- const countElement = document.getElementById('selectedCount');
- if (countElement) {
- // Use i18nHelpers.js to update the count text
- updateElementText(countElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
-
- const existingCaret = countElement.querySelector('.dropdown-caret');
- if (existingCaret) {
- existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
- existingCaret.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
- } else {
- const caretIcon = document.createElement('i');
- caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
- caretIcon.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
- countElement.appendChild(caretIcon);
- }
+ // Update context menu header if visible
+ if (this.bulkContextMenu) {
+ this.bulkContextMenu.updateSelectedCountHeader();
}
}
@@ -222,10 +132,9 @@ export class BulkManager {
});
}
- this.updateSelectedCount();
-
- if (this.isStripVisible) {
- this.updateThumbnailStrip();
+ // Update context menu header if visible
+ if (this.bulkContextMenu) {
+ this.bulkContextMenu.updateSelectedCountHeader();
}
}
@@ -277,8 +186,6 @@ export class BulkManager {
card.classList.remove('selected');
}
});
-
- this.updateSelectedCount();
}
async copyAllModelsSyntax() {
@@ -413,115 +320,6 @@ export class BulkManager {
showToast('toast.models.deleteFailedGeneral', {}, 'error');
}
}
-
- toggleThumbnailStrip() {
- if (state.selectedModels.size === 0) return;
-
- const existing = document.querySelector('.selected-thumbnails-strip');
- if (existing) {
- this.hideThumbnailStrip();
- } else {
- this.showThumbnailStrip();
- }
- }
-
- showThumbnailStrip() {
- const strip = document.createElement('div');
- strip.className = 'selected-thumbnails-strip';
-
- const thumbnailContainer = document.createElement('div');
- thumbnailContainer.className = 'thumbnails-container';
- strip.appendChild(thumbnailContainer);
-
- this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
-
- this.updateThumbnailStrip();
-
- this.isStripVisible = true;
- this.updateSelectedCount();
-
- setTimeout(() => strip.classList.add('visible'), 10);
- }
-
- hideThumbnailStrip() {
- const strip = document.querySelector('.selected-thumbnails-strip');
- if (strip && this.isStripVisible) {
- strip.classList.remove('visible');
-
- this.isStripVisible = false;
-
- const countElement = document.getElementById('selectedCount');
- if (countElement) {
- const caret = countElement.querySelector('.dropdown-caret');
- if (caret) {
- caret.className = 'fas fa-caret-up dropdown-caret';
- }
- }
-
- setTimeout(() => {
- if (strip.parentNode) {
- strip.parentNode.removeChild(strip);
- }
- }, 300);
- }
- }
-
- updateThumbnailStrip() {
- const container = document.querySelector('.thumbnails-container');
- if (!container) return;
-
- container.innerHTML = '';
-
- const selectedModels = Array.from(state.selectedModels);
-
- if (selectedModels.length > this.stripMaxThumbnails) {
- const counter = document.createElement('div');
- counter.className = 'strip-counter';
- counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedModels.length} selected`;
- container.appendChild(counter);
- }
-
- const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails);
- const metadataCache = this.getMetadataCache();
-
- thumbnailsToShow.forEach(filepath => {
- const metadata = metadataCache.get(filepath);
- if (!metadata) return;
-
- const thumbnail = document.createElement('div');
- thumbnail.className = 'selected-thumbnail';
- thumbnail.dataset.filepath = filepath;
-
- if (metadata.isVideo) {
- thumbnail.innerHTML = `
-
- ${metadata.modelName}
-
- `;
- } else {
- thumbnail.innerHTML = `
-
- ${metadata.modelName}
-
- `;
- }
-
- thumbnail.addEventListener('click', (e) => {
- if (!e.target.closest('.thumbnail-remove')) {
- this.deselectItem(filepath);
- }
- });
-
- thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
- e.stopPropagation();
- this.deselectItem(filepath);
- });
-
- container.appendChild(thumbnail);
- });
- }
deselectItem(filepath) {
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
@@ -530,13 +328,6 @@ export class BulkManager {
}
state.selectedModels.delete(filepath);
-
- this.updateSelectedCount();
- this.updateThumbnailStrip();
-
- if (state.selectedModels.size === 0) {
- this.hideThumbnailStrip();
- }
}
selectAllVisibleModels() {
@@ -619,6 +410,330 @@ export class BulkManager {
showToast('toast.models.refreshMetadataFailed', {}, 'error');
}
}
+
+ showBulkAddTagsModal() {
+ if (state.selectedModels.size === 0) {
+ showToast('toast.models.noModelsSelected', {}, 'warning');
+ return;
+ }
+
+ const countElement = document.getElementById('bulkAddTagsCount');
+ if (countElement) {
+ countElement.textContent = state.selectedModels.size;
+ }
+
+ // Clear any existing tags in the modal
+ const tagsContainer = document.getElementById('bulkTagsItems');
+ if (tagsContainer) {
+ tagsContainer.innerHTML = '';
+ }
+
+ modalManager.showModal('bulkAddTagsModal', null, null, () => {
+ // Cleanup when modal is closed
+ this.cleanupBulkAddTagsModal();
+ });
+
+ // Initialize the bulk tags editing interface
+ this.initializeBulkTagsInterface();
+ }
+
+ initializeBulkTagsInterface() {
+ // Setup tag input behavior
+ const tagInput = document.querySelector('.bulk-metadata-input');
+ if (tagInput) {
+ tagInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ this.addBulkTag(e.target.value.trim());
+ e.target.value = '';
+ // Update dropdown to show added indicator
+ this.updateBulkSuggestionsDropdown();
+ }
+ });
+ }
+
+ // Create suggestions dropdown
+ const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
+ if (tagForm) {
+ const suggestionsDropdown = this.createBulkSuggestionsDropdown(PRESET_TAGS);
+ tagForm.appendChild(suggestionsDropdown);
+ }
+
+ // Setup save button
+ 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');
+ });
+ }
+ }
+
+ createBulkSuggestionsDropdown(presetTags) {
+ const dropdown = document.createElement('div');
+ dropdown.className = 'metadata-suggestions-dropdown';
+
+ const header = document.createElement('div');
+ header.className = 'metadata-suggestions-header';
+ header.innerHTML = `
+ Suggested Tags
+ Click to add
+ `;
+ dropdown.appendChild(header);
+
+ const container = document.createElement('div');
+ container.className = 'metadata-suggestions-container';
+
+ presetTags.forEach(tag => {
+ // Check if tag is already added
+ const existingTags = this.getBulkExistingTags();
+ const isAdded = existingTags.includes(tag);
+
+ const item = document.createElement('div');
+ item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
+ item.title = tag;
+ item.innerHTML = `
+
+ ${isAdded ? '' : ''}
+ `;
+
+ if (!isAdded) {
+ item.addEventListener('click', () => {
+ this.addBulkTag(tag);
+ const input = document.querySelector('.bulk-metadata-input');
+ if (input) {
+ input.value = tag;
+ input.focus();
+ }
+ // Update dropdown to show added indicator
+ this.updateBulkSuggestionsDropdown();
+ });
+ }
+
+ container.appendChild(item);
+ });
+
+ dropdown.appendChild(container);
+ return dropdown;
+ }
+
+ addBulkTag(tag) {
+ tag = tag.trim().toLowerCase();
+ if (!tag) return;
+
+ const tagsContainer = document.getElementById('bulkTagsItems');
+ if (!tagsContainer) return;
+
+ // Validation: Check length
+ if (tag.length > 30) {
+ showToast('modelTags.validation.maxLength', {}, 'error');
+ return;
+ }
+
+ // Validation: Check total number
+ const currentTags = tagsContainer.querySelectorAll('.metadata-item');
+ if (currentTags.length >= 30) {
+ showToast('modelTags.validation.maxCount', {}, 'error');
+ return;
+ }
+
+ // Validation: Check for duplicates
+ const existingTags = Array.from(currentTags).map(tagEl => tagEl.dataset.tag);
+ if (existingTags.includes(tag)) {
+ showToast('modelTags.validation.duplicate', {}, 'error');
+ return;
+ }
+
+ // Create new tag
+ const newTag = document.createElement('div');
+ newTag.className = 'metadata-item';
+ newTag.dataset.tag = tag;
+ newTag.innerHTML = `
+
+
+ `;
+
+ // Add delete button event listener
+ const deleteBtn = newTag.querySelector('.metadata-delete-btn');
+ deleteBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ newTag.remove();
+ // Update dropdown to show/hide added indicator
+ this.updateBulkSuggestionsDropdown();
+ });
+
+ tagsContainer.appendChild(newTag);
+ }
+
+ /**
+ * Get existing tags in the bulk tags container
+ * @returns {Array} Array of existing tag strings
+ */
+ getBulkExistingTags() {
+ const tagsContainer = document.getElementById('bulkTagsItems');
+ if (!tagsContainer) return [];
+
+ const currentTags = tagsContainer.querySelectorAll('.metadata-item');
+ return Array.from(currentTags).map(tag => tag.dataset.tag);
+ }
+
+ /**
+ * Update status of items in the bulk suggestions dropdown
+ */
+ updateBulkSuggestionsDropdown() {
+ const dropdown = document.querySelector('.metadata-suggestions-dropdown');
+ if (!dropdown) return;
+
+ // Get all current tags
+ const existingTags = this.getBulkExistingTags();
+
+ // Update status of each item in dropdown
+ dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
+ const tagText = item.querySelector('.metadata-suggestion-text').textContent;
+ const isAdded = existingTags.includes(tagText);
+
+ if (isAdded) {
+ item.classList.add('already-added');
+
+ // Add indicator if it doesn't exist
+ let indicator = item.querySelector('.added-indicator');
+ if (!indicator) {
+ indicator = document.createElement('span');
+ indicator.className = 'added-indicator';
+ indicator.innerHTML = '';
+ item.appendChild(indicator);
+ }
+
+ // Remove click event
+ item.onclick = null;
+ item.removeEventListener('click', item._clickHandler);
+ } else {
+ // Re-enable items that are no longer in the list
+ item.classList.remove('already-added');
+
+ // Remove indicator if it exists
+ const indicator = item.querySelector('.added-indicator');
+ if (indicator) indicator.remove();
+
+ // Restore click event if not already set
+ if (!item._clickHandler) {
+ item._clickHandler = () => {
+ this.addBulkTag(tagText);
+ const input = document.querySelector('.bulk-metadata-input');
+ if (input) {
+ input.value = tagText;
+ input.focus();
+ }
+ // Update dropdown to show added indicator
+ this.updateBulkSuggestionsDropdown();
+ };
+ item.addEventListener('click', item._clickHandler);
+ }
+ }
+ });
+ }
+
+ async saveBulkTags(mode = 'append') {
+ const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
+ const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
+
+ if (tags.length === 0) {
+ showToast('toast.models.noTagsToAdd', {}, 'warning');
+ return;
+ }
+
+ if (state.selectedModels.size === 0) {
+ showToast('toast.models.noModelsSelected', {}, 'warning');
+ return;
+ }
+
+ try {
+ const apiClient = getModelApiClient();
+ const filePaths = Array.from(state.selectedModels);
+ let successCount = 0;
+ let failCount = 0;
+
+ // Add or replace tags for each selected model based on mode
+ for (const filePath of filePaths) {
+ try {
+ if (mode === 'replace') {
+ await apiClient.saveModelMetadata(filePath, { tags: tags });
+ } else {
+ await apiClient.addTags(filePath, { tags: tags });
+ }
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to ${mode} tags for ${filePath}:`, error);
+ failCount++;
+ }
+ }
+
+ modalManager.closeModal('bulkAddTagsModal');
+
+ if (successCount > 0) {
+ const currentConfig = MODEL_CONFIG[state.currentPageType];
+ const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
+ showToast(toastKey, {
+ count: successCount,
+ tagCount: tags.length,
+ type: currentConfig.displayName.toLowerCase()
+ }, 'success');
+ }
+
+ if (failCount > 0) {
+ const toastKey = mode === 'replace' ? 'toast.models.tagsReplaceFailed' : 'toast.models.tagsAddFailed';
+ showToast(toastKey, { count: failCount }, 'warning');
+ }
+
+ } catch (error) {
+ console.error('Error during bulk tag operation:', error);
+ const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed';
+ showToast(toastKey, {}, 'error');
+ }
+ }
+
+ cleanupBulkAddTagsModal() {
+ // Clear tags container
+ const tagsContainer = document.getElementById('bulkTagsItems');
+ if (tagsContainer) {
+ tagsContainer.innerHTML = '';
+ }
+
+ // Clear input
+ const input = document.querySelector('.bulk-metadata-input');
+ if (input) {
+ input.value = '';
+ }
+
+ // Remove event listeners (they will be re-added when modal opens again)
+ 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));
+ }
+
+ // Remove the suggestions dropdown
+ const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
+ if (tagForm) {
+ const dropdown = tagForm.querySelector('.metadata-suggestions-dropdown');
+ if (dropdown) {
+ dropdown.remove();
+ }
+ }
+ }
}
export const bulkManager = new BulkManager();
diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js
index 9f56c269..8313c226 100644
--- a/static/js/managers/ModalManager.js
+++ b/static/js/managers/ModalManager.js
@@ -234,6 +234,19 @@ export class ModalManager {
});
}
+ // Add bulkAddTagsModal registration
+ const bulkAddTagsModal = document.getElementById('bulkAddTagsModal');
+ if (bulkAddTagsModal) {
+ this.registerModal('bulkAddTagsModal', {
+ element: bulkAddTagsModal,
+ onClose: () => {
+ this.getModal('bulkAddTagsModal').element.style.display = 'none';
+ document.body.classList.remove('modal-open');
+ },
+ closeOnOutsideClick: true
+ });
+ }
+
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}
diff --git a/static/js/state/index.js b/static/js/state/index.js
index f905eb09..65d5619f 100644
--- a/static/js/state/index.js
+++ b/static/js/state/index.js
@@ -71,6 +71,8 @@ export const state = {
pageSize: 20,
showFavoritesOnly: false,
duplicatesMode: false,
+ bulkMode: false,
+ selectedModels: new Set(),
},
[MODEL_TYPES.CHECKPOINT]: {
diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js
index 9f440aa5..196de1fc 100644
--- a/static/js/utils/constants.js
+++ b/static/js/utils/constants.js
@@ -163,3 +163,10 @@ export const NODE_TYPE_ICONS = {
// Default ComfyUI node color when bgcolor is null
export const DEFAULT_NODE_COLOR = "#353535";
+
+// Preset tag suggestions
+export const PRESET_TAGS = [
+ 'character', 'style', 'concept', 'clothing',
+ 'poses', 'background', 'vehicle', 'buildings',
+ 'objects', 'animal'
+];
diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html
index ff488290..92e7a5b2 100644
--- a/templates/components/context_menu.html
+++ b/templates/components/context_menu.html
@@ -44,6 +44,37 @@
+