Merge pull request #399 from willmiao/bulk-menu

Bulk context menu
This commit is contained in:
pixelpaws
2025-09-05 09:31:48 +08:00
committed by GitHub
35 changed files with 1245 additions and 335 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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つの検索オプションを選択する必要があります"

View File

@@ -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": "최소 하나의 검색 옵션을 선택해야 합니다"

View File

@@ -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": "Должен быть выбран хотя бы один вариант поиска"

View File

@@ -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": "至少选择一个搜索选项"

View File

@@ -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": "至少需選擇一個搜尋選項"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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`,

View File

@@ -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(

View File

@@ -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();
});
});

View File

@@ -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) => {

View File

@@ -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}`);
}
}
}

View File

@@ -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';
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
});
}

View File

@@ -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');

View File

@@ -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', () => {

View File

@@ -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');

View File

@@ -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();
}
}

View File

@@ -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 = `
<video autoplay loop muted playsinline>
<source src="${metadata.previewUrl}" type="video/mp4">
</video>
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
`;
} else {
thumbnail.innerHTML = `
<img src="${metadata.previewUrl}" alt="${metadata.modelName}">
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
`;
}
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 = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
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 = `
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
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 = `
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
`;
// 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 = '<i class="fas fa-check"></i>';
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();

View File

@@ -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;
}

View File

@@ -71,6 +71,8 @@ export const state = {
pageSize: 20,
showFavoritesOnly: false,
duplicatesMode: false,
bulkMode: false,
selectedModels: new Set(),
},
[MODEL_TYPES.CHECKPOINT]: {

View File

@@ -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'
];

View File

@@ -44,6 +44,37 @@
</div>
</div>
<div id="bulkContextMenu" class="context-menu">
<div class="bulk-context-header">
<i class="fas fa-th-large"></i>
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
</div>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div>
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</div>
<div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-all">
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="clear">
<i class="fas fa-times"></i> <span>{{ t('loras.bulkOperations.clear') }}</span>
</div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector">
<div class="nsfw-level-header">
<h3>{{ t('modals.contentRating.title') }}</h3>

View File

@@ -98,33 +98,4 @@
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div>
<!-- Add bulk operations panel (initially hidden) -->
<div id="bulkOperationsPanel" class="bulk-operations-panel hidden">
<div class="bulk-operations-header">
<span id="selectedCount" class="selectable-count" title="{{ t('loras.bulkOperations.viewSelected') }}">
0 {{ t('loras.bulkOperations.selectedSuffix') }} <i class="fas fa-caret-down dropdown-caret"></i>
</span>
<div class="bulk-operations-actions">
<button data-action="send-to-workflow" title="{{ t('loras.bulkOperations.sendToWorkflow') }}">
<i class="fas fa-arrow-right"></i> <span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
</button>
<button data-action="copy-all" title="{{ t('loras.bulkOperations.copyAll') }}">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</button>
<button data-action="refresh-all" title="{{ t('loras.bulkOperations.refreshAll') }}">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</button>
<button data-action="move-all" title="{{ t('loras.bulkOperations.moveAll') }}">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</button>
<button data-action="delete-all" title="{{ t('loras.bulkOperations.deleteAll') }}" class="danger-btn">
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
</button>
<button data-action="clear" title="{{ t('loras.bulkOperations.clear') }}">
<i class="fas fa-times"></i> <span>{{ t('loras.bulkOperations.clear') }}</span>
</button>
</div>
</div>
</div>

View File

@@ -9,4 +9,5 @@
{% include 'components/modals/relink_civitai_modal.html' %}
{% include 'components/modals/example_access_modal.html' %}
{% include 'components/modals/download_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %}

View File

@@ -0,0 +1,38 @@
<div id="bulkAddTagsModal" class="modal" style="display: none;">
<div class="modal-content modal-content-large">
<div class="modal-header">
<h2>{{ t('modals.bulkAddTags.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkAddTagsModal')">&times;</span>
</div>
<div class="modal-body">
<div class="bulk-add-tags-info">
<p>{{ t('modals.bulkAddTags.description') }} <span id="bulkAddTagsCount">0</span> {{ t('modals.bulkAddTags.models') }}</p>
</div>
<div class="model-tags-container bulk-tags-container edit-mode">
<div class="metadata-edit-container" style="display: block;">
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>{{ t('modals.bulkAddTags.tagsToAdd') }}</label>
</div>
<div class="metadata-items" id="bulkTagsItems">
<!-- Tags will be added here dynamically -->
</div>
<div class="metadata-edit-controls">
<button class="append-tags-btn bulk-append-tags-btn" title="{{ t('modals.bulkAddTags.appendTags') }}">
<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>
</div>
<div class="metadata-add-form">
<input type="text" class="metadata-input bulk-metadata-input" placeholder="{{ t('modals.bulkAddTags.placeholder') }}">
</div>
<!-- Suggestions dropdown will be added here -->
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -16,8 +16,6 @@
<div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here -->
</div>
<!-- Bulk operations panel will be inserted here by JavaScript -->
{% endblock %}
{% block overlay %}