From de05b59f29fc9dd341be483097f2e36653bbb2bf Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Wed, 29 Oct 2025 07:33:58 +0800 Subject: [PATCH] test(routes): cover snake case model id payload --- locales/de.json | 7 + locales/en.json | 7 + locales/es.json | 7 + locales/fr.json | 7 + locales/he.json | 7 + locales/ja.json | 7 + locales/ko.json | 7 + locales/ru.json | 7 + locales/zh-CN.json | 7 + locales/zh-TW.json | 7 + py/routes/handlers/model_handlers.py | 16 ++ py/services/model_update_service.py | 41 +++- static/js/api/baseModelApi.js | 29 +++ .../components/ContextMenu/BulkContextMenu.js | 7 + static/js/components/shared/ModelCard.js | 6 + static/js/managers/BulkManager.js | 193 +++++++++++++++--- templates/components/context_menu.html | 3 + tests/routes/test_model_update_handler.py | 114 ++++++++++- tests/services/test_model_update_service.py | 41 ++++ 19 files changed, 484 insertions(+), 36 deletions(-) diff --git a/locales/de.json b/locales/de.json index 725339c1..40756ba5 100644 --- a/locales/de.json +++ b/locales/de.json @@ -443,6 +443,7 @@ "setContentRating": "Inhaltsbewertung für alle festlegen", "copyAll": "Alle Syntax kopieren", "refreshAll": "Alle Metadaten aktualisieren", + "checkUpdates": "Auswahl auf Updates prüfen", "moveAll": "Alle in Ordner verschieben", "autoOrganize": "Automatisch organisieren", "deleteAll": "Alle Modelle löschen", @@ -1206,6 +1207,12 @@ "bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt", "bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen", "bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden", + "bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...", + "bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar", + "bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden", + "bulkUpdatesMissing": "Ausgewählte {type}-Modelle sind nicht mit Civitai-Updates verknüpft", + "bulkUpdatesPartialMissing": "{missing} ausgewählte {type}-Modelle ohne Civitai-Verknüpfung übersprungen", + "bulkUpdatesFailed": "Updates für ausgewählte {type}-Modelle konnten nicht geprüft werden: {message}", "invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt", "filenameCannotBeEmpty": "Dateiname darf nicht leer sein", "renameFailed": "Fehler beim Umbenennen der Datei: {message}", diff --git a/locales/en.json b/locales/en.json index a22e4d57..8a0b6a53 100644 --- a/locales/en.json +++ b/locales/en.json @@ -442,6 +442,7 @@ "setContentRating": "Set Content Rating for Selected", "copyAll": "Copy Selected Syntax", "refreshAll": "Refresh Selected Metadata", + "checkUpdates": "Check Updates for Selected", "moveAll": "Move Selected to Folder", "autoOrganize": "Auto-Organize Selected", "deleteAll": "Delete Selected Models", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "Set content rating to {level} for {count} model(s)", "bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed", "bulkContentRatingFailed": "Failed to update content rating for selected models", + "bulkUpdatesChecking": "Checking selected {type}(s) for updates...", + "bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)", + "bulkUpdatesNone": "No updates found for selected {type}(s)", + "bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates", + "bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links", + "bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}", "invalidCharactersRemoved": "Invalid characters removed from filename", "filenameCannotBeEmpty": "File name cannot be empty", "renameFailed": "Failed to rename file: {message}", diff --git a/locales/es.json b/locales/es.json index 54edf26a..db0d6d3c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -442,6 +442,7 @@ "setContentRating": "Establecer clasificación de contenido para todos", "copyAll": "Copiar toda la sintaxis", "refreshAll": "Actualizar todos los metadatos", + "checkUpdates": "Comprobar actualizaciones para la selección", "moveAll": "Mover todos a carpeta", "autoOrganize": "Auto-organizar seleccionados", "deleteAll": "Eliminar todos los modelos", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)", "bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron", "bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados", + "bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...", + "bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados", + "bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados", + "bulkUpdatesMissing": "Los {type} seleccionados no están vinculados a actualizaciones de Civitai", + "bulkUpdatesPartialMissing": "Se omitieron {missing} {type} seleccionados sin enlace de Civitai", + "bulkUpdatesFailed": "Error al comprobar actualizaciones para los {type} seleccionados: {message}", "invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo", "filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío", "renameFailed": "Error al renombrar archivo: {message}", diff --git a/locales/fr.json b/locales/fr.json index 17b6e098..d930fe79 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -442,6 +442,7 @@ "setContentRating": "Définir la classification du contenu pour tous", "copyAll": "Copier toute la syntaxe", "refreshAll": "Actualiser toutes les métadonnées", + "checkUpdates": "Vérifier les mises à jour pour la sélection", "moveAll": "Déplacer tout vers un dossier", "autoOrganize": "Auto-organiser la sélection", "deleteAll": "Supprimer tous les modèles", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)", "bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)", "bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés", + "bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...", + "bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés", + "bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés", + "bulkUpdatesMissing": "Les {type} sélectionnés ne sont pas liés aux mises à jour Civitai", + "bulkUpdatesPartialMissing": "{missing} {type} sélectionnés sans lien Civitai ignorés", + "bulkUpdatesFailed": "Échec de la vérification des mises à jour pour les {type} sélectionnés : {message}", "invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier", "filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide", "renameFailed": "Échec du renommage du fichier : {message}", diff --git a/locales/he.json b/locales/he.json index 64fbcc49..b16d92e8 100644 --- a/locales/he.json +++ b/locales/he.json @@ -442,6 +442,7 @@ "setContentRating": "הגדר דירוג תוכן לכל המודלים", "copyAll": "העתק את כל התחבירים", "refreshAll": "רענן את כל המטא-דאטה", + "checkUpdates": "בדוק עדכונים לבחירה", "moveAll": "העבר הכל לתיקייה", "autoOrganize": "ארגן אוטומטית נבחרים", "deleteAll": "מחק את כל המודלים", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים", "bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו", "bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל", + "bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...", + "bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו", + "bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו", + "bulkUpdatesMissing": "ה-{type} שנבחרו אינם מקושרים לעדכוני Civitai", + "bulkUpdatesPartialMissing": "דילג על {missing} {type} שנבחרו ללא קישור Civitai", + "bulkUpdatesFailed": "בדיקת העדכונים עבור {type} שנבחרו נכשלה: {message}", "invalidCharactersRemoved": "תווים לא חוקיים הוסרו משם הקובץ", "filenameCannotBeEmpty": "שם הקובץ אינו יכול להיות ריק", "renameFailed": "שינוי שם הקובץ נכשל: {message}", diff --git a/locales/ja.json b/locales/ja.json index bb37ac04..92cb23c3 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -442,6 +442,7 @@ "setContentRating": "すべてのモデルのコンテンツレーティングを設定", "copyAll": "すべての構文をコピー", "refreshAll": "すべてのメタデータを更新", + "checkUpdates": "選択項目の更新を確認", "moveAll": "すべてをフォルダに移動", "autoOrganize": "自動整理を実行", "deleteAll": "すべてのモデルを削除", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました", "bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました", "bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした", + "bulkUpdatesChecking": "選択された{type}の更新を確認しています...", + "bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります", + "bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした", + "bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません", + "bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました", + "bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}", "invalidCharactersRemoved": "ファイル名から無効な文字が削除されました", "filenameCannotBeEmpty": "ファイル名を空にすることはできません", "renameFailed": "ファイル名の変更に失敗しました:{message}", diff --git a/locales/ko.json b/locales/ko.json index 45cb597f..2406029e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -442,6 +442,7 @@ "setContentRating": "모든 모델에 콘텐츠 등급 설정", "copyAll": "모든 문법 복사", "refreshAll": "모든 메타데이터 새로고침", + "checkUpdates": "선택 항목 업데이트 확인", "moveAll": "모두 폴더로 이동", "autoOrganize": "자동 정리 선택", "deleteAll": "모든 모델 삭제", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다", "bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다", "bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다", + "bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...", + "bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다", + "bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다", + "bulkUpdatesMissing": "선택한 {type}이 Civitai 업데이트에 연결되어 있지 않습니다", + "bulkUpdatesPartialMissing": "Civitai 링크가 없는 {missing}개의 {type}을 건너뛰었습니다", + "bulkUpdatesFailed": "선택한 {type}의 업데이트 확인에 실패했습니다: {message}", "invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다", "filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다", "renameFailed": "파일 이름 변경 실패: {message}", diff --git a/locales/ru.json b/locales/ru.json index 5ef3eca6..c1b94e25 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -442,6 +442,7 @@ "setContentRating": "Установить рейтинг контента для всех", "copyAll": "Копировать весь синтаксис", "refreshAll": "Обновить все метаданные", + "checkUpdates": "Проверить обновления для выбранных", "moveAll": "Переместить все в папку", "autoOrganize": "Автоматически организовать выбранные", "deleteAll": "Удалить все модели", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)", "bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось", "bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей", + "bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...", + "bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}", + "bulkUpdatesNone": "Обновления для выбранных {type} не найдены", + "bulkUpdatesMissing": "Выбранные {type} не привязаны к обновлениям Civitai", + "bulkUpdatesPartialMissing": "Пропущено {missing} выбранных {type} без привязки Civitai", + "bulkUpdatesFailed": "Не удалось проверить обновления для выбранных {type}: {message}", "invalidCharactersRemoved": "Недопустимые символы удалены из имени файла", "filenameCannotBeEmpty": "Имя файла не может быть пустым", "renameFailed": "Не удалось переименовать файл: {message}", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index fe3e9f41..7c914d57 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -442,6 +442,7 @@ "setContentRating": "为所选中设置内容评级", "copyAll": "复制所选中语法", "refreshAll": "刷新所选中元数据", + "checkUpdates": "检查所选更新", "moveAll": "移动所选中到文件夹", "autoOrganize": "自动整理所选模型", "deleteAll": "删除选中模型", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}", "bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败", "bulkContentRatingFailed": "未能更新所选模型的内容评级", + "bulkUpdatesChecking": "正在检查所选 {type} 的更新...", + "bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新", + "bulkUpdatesNone": "所选 {type} 未发现更新", + "bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新", + "bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}", + "bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}", "invalidCharactersRemoved": "文件名中的无效字符已移除", "filenameCannotBeEmpty": "文件名不能为空", "renameFailed": "重命名文件失败:{message}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d05aac31..9d5ab1ee 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -442,6 +442,7 @@ "setContentRating": "為全部設定內容分級", "copyAll": "複製全部語法", "refreshAll": "刷新全部 metadata", + "checkUpdates": "檢查所選更新", "moveAll": "全部移動到資料夾", "autoOrganize": "自動整理所選模型", "deleteAll": "刪除全部模型", @@ -1205,6 +1206,12 @@ "bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}", "bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗", "bulkContentRatingFailed": "無法更新所選模型的內容分級", + "bulkUpdatesChecking": "正在檢查所選 {type} 的更新...", + "bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新", + "bulkUpdatesNone": "所選 {type} 未找到更新", + "bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新", + "bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}", + "bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}", "invalidCharactersRemoved": "已移除檔名中的無效字元", "filenameCannotBeEmpty": "檔案名稱不可為空", "renameFailed": "重新命名檔案失敗:{message}", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index f95968ed..0447163c 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -1048,6 +1048,21 @@ class ModelUpdateHandler: force_refresh = self._parse_bool(request.query.get("force")) or self._parse_bool( payload.get("force") ) + + raw_model_ids = payload.get("modelIds") + if raw_model_ids is None: + raw_model_ids = payload.get("model_ids") + + target_model_ids: list[int] = [] + if isinstance(raw_model_ids, (list, tuple, set)): + for value in raw_model_ids: + normalized = self._normalize_model_id(value) + if normalized is not None: + target_model_ids.append(normalized) + + if target_model_ids: + target_model_ids = sorted(set(target_model_ids)) + provider = await self._get_civitai_provider() if provider is None: return web.json_response( @@ -1060,6 +1075,7 @@ class ModelUpdateHandler: self._service.scanner, provider, force_refresh=force_refresh, + target_model_ids=target_model_ids or None, ) except RateLimitError as exc: return web.json_response( diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 64581cda..dcf49ae6 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -210,15 +210,33 @@ class ModelUpdateService: metadata_provider, *, force_refresh: bool = False, + target_model_ids: Optional[Sequence[int]] = None, ) -> Dict[int, ModelUpdateRecord]: """Refresh update information for every model present in the cache.""" - local_versions = await self._collect_local_versions(scanner) + normalized_targets = ( + self._normalize_sequence(target_model_ids) + if target_model_ids is not None + else [] + ) + target_filter = normalized_targets or None + + local_versions = await self._collect_local_versions( + scanner, + target_model_ids=target_filter, + ) total_models = len(local_versions) if total_models == 0: - logger.info( - "No %s models found while refreshing update metadata", model_type - ) + if target_filter: + logger.info( + "No %s models matched requested ids %s while refreshing update metadata", + model_type, + target_filter, + ) + else: + logger.info( + "No %s models found while refreshing update metadata", model_type + ) return {} logger.info( @@ -616,12 +634,23 @@ class ModelUpdateService: ) return aggregated - async def _collect_local_versions(self, scanner) -> Dict[int, List[int]]: + async def _collect_local_versions( + self, + scanner, + *, + target_model_ids: Optional[Sequence[int]] = None, + ) -> Dict[int, List[int]]: cache = await scanner.get_cached_data() mapping: Dict[int, set[int]] = {} if not cache or not getattr(cache, "raw_data", None): return {} + target_set = None + if target_model_ids: + target_set = set(target_model_ids) + if not target_set: + return {} + for item in cache.raw_data: civitai = item.get("civitai") if isinstance(item, dict) else None if not isinstance(civitai, dict): @@ -630,6 +659,8 @@ class ModelUpdateService: version_id = self._normalize_int(civitai.get("id")) if model_id is None or version_id is None: continue + if target_set is not None and model_id not in target_set: + continue mapping.setdefault(model_id, set()).add(version_id) return {model_id: sorted(ids) for model_id, ids in mapping.items()} diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 8c6af107..ba278541 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -569,6 +569,35 @@ export class BaseModelApiClient { } } + async refreshUpdatesForModels(modelIds, { force = false } = {}) { + if (!Array.isArray(modelIds) || modelIds.length === 0) { + throw new Error('No model IDs provided'); + } + + const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_ids: modelIds, + force + }) + }); + + let payload = {}; + try { + payload = await response.json(); + } catch (error) { + console.warn('Unable to parse refresh updates response as JSON', error); + } + + if (!response.ok || payload?.success !== true) { + const message = payload?.error || response.statusText || 'Failed to refresh updates'; + throw new Error(message); + } + + return payload; + } + async fetchCivitaiVersions(modelId, source = null) { try { let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`; diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index 0428adbe..17ad30e7 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -33,6 +33,7 @@ export class BulkContextMenu extends BaseContextMenu { const sendToWorkflowReplaceItem = this.menu.querySelector('[data-action="send-to-workflow-replace"]'); const copyAllItem = this.menu.querySelector('[data-action="copy-all"]'); const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]'); + const checkUpdatesItem = this.menu.querySelector('[data-action="check-updates"]'); const moveAllItem = this.menu.querySelector('[data-action="move-all"]'); const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]'); const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]'); @@ -49,6 +50,9 @@ export class BulkContextMenu extends BaseContextMenu { if (refreshAllItem) { refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none'; } + if (checkUpdatesItem) { + checkUpdatesItem.style.display = config.checkUpdates ? 'flex' : 'none'; + } if (moveAllItem) { moveAllItem.style.display = config.moveAll ? 'flex' : 'none'; } @@ -105,6 +109,9 @@ export class BulkContextMenu extends BaseContextMenu { case 'refresh-all': bulkManager.refreshAllMetadata(); break; + case 'check-updates': + bulkManager.checkUpdatesForSelectedModels(); + break; case 'move-all': window.moveManager.showMoveModal('bulk'); break; diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index f53a61a2..d2c9c96a 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -436,6 +436,12 @@ export function createModelCard(model, modelType) { const hasUpdateAvailable = Boolean(model.update_available); card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false'; + const civitaiData = model.civitai || {}; + const modelId = civitaiData?.modelId ?? civitaiData?.model_id; + if (modelId !== undefined && modelId !== null && modelId !== '') { + card.dataset.modelId = modelId; + } + // LoRA specific data if (modelType === MODEL_TYPES.LORA) { card.dataset.usage_tips = model.usage_tips; diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index f333e84c..364b3ac4 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -34,6 +34,7 @@ export class BulkManager { sendToWorkflow: true, copyAll: true, refreshAll: true, + checkUpdates: true, moveAll: true, autoOrganize: true, deleteAll: true, @@ -44,6 +45,7 @@ export class BulkManager { sendToWorkflow: false, copyAll: false, refreshAll: true, + checkUpdates: true, moveAll: true, autoOrganize: true, deleteAll: true, @@ -54,6 +56,7 @@ export class BulkManager { sendToWorkflow: false, copyAll: false, refreshAll: true, + checkUpdates: true, moveAll: false, autoOrganize: true, deleteAll: true, @@ -271,14 +274,9 @@ export class BulkManager { } else { card.classList.add('selected'); state.selectedModels.add(filepath); - + // Cache the metadata for this model - const metadataCache = this.getMetadataCache(); - metadataCache.set(filepath, { - fileName: card.dataset.file_name, - usageTips: card.dataset.usage_tips, - modelName: card.dataset.name - }); + this.updateMetadataCacheFromCard(filepath, card); } // Update context menu header if visible @@ -290,7 +288,7 @@ export class BulkManager { getMetadataCache() { const currentType = state.currentPageType; const pageState = getCurrentPageState(); - + // Initialize metadata cache if it doesn't exist if (currentType === MODEL_TYPES.LORA) { if (!state.loraMetadataCache) { @@ -305,6 +303,89 @@ export class BulkManager { } } + parseModelId(value) { + if (value === undefined || value === null || value === '') { + return null; + } + + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? null : parsed; + } + + updateMetadataCacheFromCard(filepath, card) { + if (!card) { + return; + } + + const metadataCache = this.getMetadataCache(); + const existing = metadataCache.get(filepath) || {}; + const modelId = this.parseModelId(card.dataset.modelId); + + const updated = { + ...existing, + fileName: card.dataset.file_name ?? existing.fileName, + usageTips: card.dataset.usage_tips ?? existing.usageTips, + modelName: card.dataset.name ?? existing.modelName, + }; + + if (modelId !== null) { + updated.modelId = modelId; + } + + metadataCache.set(filepath, updated); + } + + escapeAttributeValue(value) { + if (value === undefined || value === null) { + return ''; + } + + return String(value) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + } + + getModelIdForFilePath(filePath) { + const metadataCache = this.getMetadataCache(); + const cached = metadataCache.get(filePath); + if (cached && typeof cached.modelId === 'number') { + return cached.modelId; + } + + const escapedPath = this.escapeAttributeValue(filePath); + const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`); + if (!card) { + return null; + } + + this.updateMetadataCacheFromCard(filePath, card); + const updated = metadataCache.get(filePath); + return updated && typeof updated.modelId === 'number' ? updated.modelId : null; + } + + collectSelectedModelIds() { + const metadataCache = this.getMetadataCache(); + const ids = []; + let missingCount = 0; + + for (const filepath of state.selectedModels) { + const cached = metadataCache.get(filepath); + let modelId = cached && typeof cached.modelId === 'number' ? cached.modelId : null; + if (modelId === null) { + modelId = this.getModelIdForFilePath(filepath); + } + + if (typeof modelId === 'number') { + ids.push(modelId); + } else { + missingCount++; + } + } + + const uniqueIds = Array.from(new Set(ids)); + return { ids: uniqueIds, missingCount }; + } + applySelectionState() { if (!state.bulkMode) return; @@ -312,13 +393,8 @@ export class BulkManager { const filepath = card.dataset.filepath; if (state.selectedModels.has(filepath)) { card.classList.add('selected'); - - const metadataCache = this.getMetadataCache(); - metadataCache.set(filepath, { - fileName: card.dataset.file_name, - usageTips: card.dataset.usage_tips, - modelName: card.dataset.name - }); + + this.updateMetadataCacheFromCard(filepath, card); } else { card.classList.remove('selected'); } @@ -477,12 +553,14 @@ export class BulkManager { state.virtualScroller.items.forEach(item => { if (item && item.file_path) { state.selectedModels.add(item.file_path); - + if (!metadataCache.has(item.file_path)) { + const modelId = this.parseModelId(item?.civitai?.modelId); metadataCache.set(item.file_path, { fileName: item.file_name, usageTips: item.usage_tips || '{}', - modelName: item.name || item.file_name + modelName: item.name || item.file_name, + ...(modelId !== null ? { modelId } : {}) }); } } @@ -521,12 +599,7 @@ export class BulkManager { if (metadata) { const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`); if (card) { - metadataCache.set(filepath, { - ...metadata, - fileName: card.dataset.file_name, - usageTips: card.dataset.usage_tips, - modelName: card.dataset.name - }); + this.updateMetadataCacheFromCard(filepath, card); } } } @@ -541,7 +614,71 @@ export class BulkManager { showToast('toast.models.refreshMetadataFailed', {}, 'error'); } } - + + async checkUpdatesForSelectedModels() { + if (state.selectedModels.size === 0) { + showToast('toast.models.noModelsSelected', {}, 'warning'); + return; + } + + const currentType = state.currentPageType; + const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA]; + const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase(); + + const { ids: modelIds, missingCount } = this.collectSelectedModelIds(); + + if (modelIds.length === 0) { + showToast('toast.models.bulkUpdatesMissing', { type: typeLabel }, 'warning'); + return; + } + + if (missingCount > 0) { + showToast('toast.models.bulkUpdatesPartialMissing', { missing: missingCount, type: typeLabel }, 'info'); + } + + const apiClient = getModelApiClient(); + if (!apiClient || typeof apiClient.refreshUpdatesForModels !== 'function') { + console.warn('Model API client does not support refreshUpdatesForModels'); + showToast('toast.models.bulkUpdatesFailed', { type: typeLabel, message: 'Operation not supported' }, 'error'); + return; + } + + const loadingMessage = translate( + 'toast.models.bulkUpdatesChecking', + { count: state.selectedModels.size, type: typeLabel }, + `Checking selected ${typeLabel}(s) for updates...` + ); + state.loadingManager?.showSimpleLoading?.(loadingMessage); + + try { + const response = await apiClient.refreshUpdatesForModels(modelIds); + const records = Array.isArray(response?.records) ? response.records : []; + const updatesCount = records.length; + + if (updatesCount > 0) { + showToast('toast.models.bulkUpdatesSuccess', { count: updatesCount, type: typeLabel }, 'success'); + } else { + showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info'); + } + + await resetAndReload(false); + } catch (error) { + console.error('Error checking updates for selected models:', error); + showToast( + 'toast.models.bulkUpdatesFailed', + { type: typeLabel, message: error?.message ?? 'Unknown error' }, + 'error' + ); + } finally { + if (state.loadingManager?.hide) { + state.loadingManager.hide(); + } + if (typeof state.loadingManager?.restoreProgressBar === 'function') { + state.loadingManager.restoreProgressBar(); + } + } + } + showBulkAddTagsModal() { if (state.selectedModels.size === 0) { showToast('toast.models.noModelsSelected', {}, 'warning'); @@ -1263,15 +1400,11 @@ export class BulkManager { // Add to selection if intersecting newSelection.add(filepath); card.classList.add('selected'); - + // Cache metadata if not already cached const metadataCache = this.getMetadataCache(); if (!metadataCache.has(filepath)) { - metadataCache.set(filepath, { - fileName: card.dataset.file_name, - usageTips: card.dataset.usage_tips, - modelName: card.dataset.name - }); + this.updateMetadataCacheFromCard(filepath, card); } } else if (!this.initialSelectedModels.has(filepath)) { // Remove from selection if not intersecting and wasn't initially selected diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 49a192fb..d0f967e5 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -53,6 +53,9 @@
{{ t('loras.bulkOperations.refreshAll') }}
+
+ {{ t('loras.bulkOperations.checkUpdates') }} +
{{ t('loras.bulkOperations.copyAll') }}
diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index a378115c..79278ef7 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -28,13 +28,22 @@ class DummyUpdateService: self.records = records self.calls = [] - async def refresh_for_model_type(self, model_type, scanner, provider, *, force_refresh=False): + async def refresh_for_model_type( + self, + model_type, + scanner, + provider, + *, + force_refresh=False, + target_model_ids=None, + ): self.calls.append( { "model_type": model_type, "scanner": scanner, "provider": provider, "force_refresh": force_refresh, + "target_model_ids": target_model_ids, } ) return self.records @@ -152,3 +161,106 @@ async def test_refresh_model_updates_filters_records_without_updates(): assert call["scanner"] is service.scanner assert call["force_refresh"] is False assert call["provider"] is not None + assert call["target_model_ids"] is None + + +@pytest.mark.asyncio +async def test_refresh_model_updates_with_target_ids(): + cache = SimpleNamespace(version_index={}) + service = DummyService(cache) + + record_with_update = ModelUpdateRecord( + model_type="lora", + model_id=1, + versions=[ + ModelVersionRecord( + version_id=10, + name="v1", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + ) + ], + last_checked_at=None, + should_ignore_model=False, + ) + + update_service = DummyUpdateService({1: record_with_update}) + + async def metadata_selector(name): + assert name == "civitai_api" + return object() + + handler = ModelUpdateHandler( + service=service, + update_service=update_service, + metadata_provider_selector=metadata_selector, + logger=logging.getLogger(__name__), + ) + + class DummyRequest: + can_read_body = True + query = {} + + async def json(self): + return {"modelIds": [1, "2", None]} + + response = await handler.refresh_model_updates(DummyRequest()) + assert response.status == 200 + + call = update_service.calls[0] + assert call["target_model_ids"] == [1, 2] + + +@pytest.mark.asyncio +async def test_refresh_model_updates_accepts_snake_case_ids(): + cache = SimpleNamespace(version_index={}) + service = DummyService(cache) + + record_with_update = ModelUpdateRecord( + model_type="lora", + model_id=3, + versions=[ + ModelVersionRecord( + version_id=30, + name="v3", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + ) + ], + last_checked_at=None, + should_ignore_model=False, + ) + + update_service = DummyUpdateService({3: record_with_update}) + + async def metadata_selector(name): + assert name == "civitai_api" + return object() + + handler = ModelUpdateHandler( + service=service, + update_service=update_service, + metadata_provider_selector=metadata_selector, + logger=logging.getLogger(__name__), + ) + + class DummyRequest: + can_read_body = True + query = {} + + async def json(self): + return {"model_ids": [3, "4", "abc", None]} + + response = await handler.refresh_model_updates(DummyRequest()) + assert response.status == 200 + + call = update_service.calls[0] + assert call["target_model_ids"] == [3, 4] diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 3bdaa967..5538f3b8 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -143,6 +143,47 @@ async def test_refresh_persists_versions_and_uses_cache(tmp_path): assert provider.bulk_calls == [[1]] +@pytest.mark.asyncio +async def test_refresh_filters_to_requested_models(tmp_path): + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path), ttl_seconds=3600) + raw_data = [ + {"civitai": {"modelId": 1, "id": 11}}, + {"civitai": {"modelId": 2, "id": 21}}, + ] + scanner = DummyScanner(raw_data) + provider = DummyProvider({"modelVersions": []}) + + result = await service.refresh_for_model_type( + "lora", + scanner, + provider, + target_model_ids=[2], + ) + + assert list(result.keys()) == [2] + assert provider.bulk_calls == [[2]] + + +@pytest.mark.asyncio +async def test_refresh_returns_empty_when_targets_missing(tmp_path): + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path), ttl_seconds=3600) + raw_data = [{"civitai": {"modelId": 1, "id": 11}}] + scanner = DummyScanner(raw_data) + provider = DummyProvider({"modelVersions": []}) + + result = await service.refresh_for_model_type( + "lora", + scanner, + provider, + target_model_ids=[5], + ) + + assert result == {} + assert provider.bulk_calls == [] + + @pytest.mark.asyncio async def test_refresh_respects_ignore_flag(tmp_path): db_path = tmp_path / "updates.sqlite"