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} - ${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 = ` + ${tag} + ${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 = ` + ${tag} + + `; + + // 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 @@ +
+
+ + {{ t('loras.bulkOperations.selected', {'count': 0}) }} +
+
+
+ {{ t('loras.bulkOperations.addTags') }} +
+
+ {{ t('loras.bulkOperations.sendToWorkflow') }} +
+
+ {{ t('loras.bulkOperations.copyAll') }} +
+
+ {{ t('loras.bulkOperations.refreshAll') }} +
+
+ {{ t('loras.bulkOperations.moveAll') }} +
+
+
+ {{ t('loras.bulkOperations.deleteAll') }} +
+
+
+ {{ t('loras.bulkOperations.clear') }} +
+
+

{{ t('modals.contentRating.title') }}

diff --git a/templates/components/controls.html b/templates/components/controls.html index dc1e1bf5..8c9f5af5 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -98,33 +98,4 @@
-
- - - \ No newline at end of file diff --git a/templates/components/modals.html b/templates/components/modals.html index 0b003976..14068b44 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -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' %} \ No newline at end of file +{% include 'components/modals/move_modal.html' %} +{% include 'components/modals/bulk_add_tags_modal.html' %} \ No newline at end of file diff --git a/templates/components/modals/bulk_add_tags_modal.html b/templates/components/modals/bulk_add_tags_modal.html new file mode 100644 index 00000000..0ec7dcaf --- /dev/null +++ b/templates/components/modals/bulk_add_tags_modal.html @@ -0,0 +1,38 @@ + diff --git a/templates/loras.html b/templates/loras.html index b158632f..3ece6a10 100644 --- a/templates/loras.html +++ b/templates/loras.html @@ -16,8 +16,6 @@
- - {% endblock %} {% block overlay %}