test(routes): cover snake case model id payload

This commit is contained in:
pixelpaws
2025-10-29 07:33:58 +08:00
parent 7770976513
commit de05b59f29
19 changed files with 484 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,6 +53,9 @@
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</div>
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div>

View File

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

View File

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