diff --git a/locales/de.json b/locales/de.json index db6a6e82..e3cce8dd 100644 --- a/locales/de.json +++ b/locales/de.json @@ -139,6 +139,13 @@ "missingPath": "Bitte legen Sie einen Speicherort fest, bevor Sie Beispielbilder herunterladen.", "unavailable": "Beispielbild-Downloads sind noch nicht verfügbar. Versuchen Sie es erneut, nachdem die Seite vollständig geladen ist." }, + "checkModelUpdates": { + "label": "Auf Updates prüfen", + "loading": "Prüfe auf {type}-Updates...", + "success": "{count} Update(s) für {type} gefunden", + "none": "Alle {type} sind auf dem neuesten Stand", + "error": "Fehler beim Prüfen auf {type}-Updates: {message}" + }, "cleanupExampleImages": { "label": "Beispielbild-Ordner bereinigen", "success": "{count} Ordner wurden in den Papierkorb verschoben", diff --git a/locales/en.json b/locales/en.json index e43296d0..285e013e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -139,6 +139,13 @@ "missingPath": "Set a download location before downloading example images.", "unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading." }, + "checkModelUpdates": { + "label": "Check for updates", + "loading": "Checking for {type} updates...", + "success": "Found {count} update(s) for {type}s", + "none": "All {type}s are up to date", + "error": "Failed to check for {type} updates: {message}" + }, "cleanupExampleImages": { "label": "Clean up example image folders", "success": "Moved {count} folder(s) to the deleted folder", diff --git a/locales/es.json b/locales/es.json index fd1dc6c4..8e6a3adf 100644 --- a/locales/es.json +++ b/locales/es.json @@ -139,6 +139,13 @@ "missingPath": "Establece una ubicación de descarga antes de descargar imágenes de ejemplo.", "unavailable": "Las descargas de imágenes de ejemplo aún no están disponibles. Intenta de nuevo después de que la página termine de cargar." }, + "checkModelUpdates": { + "label": "Buscar actualizaciones", + "loading": "Buscando actualizaciones de {type}...", + "success": "Se encontraron {count} actualización(es) para {type}", + "none": "Todos los {type} están actualizados", + "error": "Error al buscar actualizaciones de {type}: {message}" + }, "cleanupExampleImages": { "label": "Limpiar carpetas de imágenes de ejemplo", "success": "Se movieron {count} carpeta(s) a la carpeta de eliminados", diff --git a/locales/fr.json b/locales/fr.json index 71890fcb..233fa383 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -139,6 +139,13 @@ "missingPath": "Définissez un emplacement de téléchargement avant de télécharger les images d'exemple.", "unavailable": "Le téléchargement des images d'exemple n'est pas encore disponible. Réessayez après le chargement complet de la page." }, + "checkModelUpdates": { + "label": "Vérifier les mises à jour", + "loading": "Recherche de mises à jour pour {type}...", + "success": "{count} mise(s) à jour trouvée(s) pour {type}", + "none": "Tous les {type} sont à jour", + "error": "Échec de la vérification des mises à jour pour {type} : {message}" + }, "cleanupExampleImages": { "label": "Supprimer les dossiers d'exemples orphelins", "success": "{count} dossier(s) déplacé(s) vers le dossier supprimé", diff --git a/locales/he.json b/locales/he.json index 4c5308f0..219c421f 100644 --- a/locales/he.json +++ b/locales/he.json @@ -139,6 +139,13 @@ "missingPath": "הגדר מיקום הורדה לפני הורדת תמונות דוגמה.", "unavailable": "הורדות תמונות דוגמה אינן זמינות עדיין. נסה שוב לאחר שהדף מסיים להיטען." }, + "checkModelUpdates": { + "label": "בדוק עדכונים", + "loading": "בודק עדכונים עבור {type}...", + "success": "נמצאו {count} עדכונים עבור {type}", + "none": "כל ה-{type} מעודכנים", + "error": "נכשל בבדיקת העדכונים עבור {type}: {message}" + }, "cleanupExampleImages": { "label": "נקה תיקיות תמונות דוגמה", "success": "הועברו {count} תיקיות לתיקיית המחוקים", diff --git a/locales/ja.json b/locales/ja.json index 136c21e4..2b324aba 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -139,6 +139,13 @@ "missingPath": "例画像をダウンロードする前にダウンロード場所を設定してください。", "unavailable": "例画像のダウンロードはまだ利用できません。ページの読み込みが完了してから再度お試しください。" }, + "checkModelUpdates": { + "label": "アップデートを確認", + "loading": "{type} のアップデートを確認中…", + "success": "{type} のアップデートが {count} 件見つかりました", + "none": "すべての {type} は最新です", + "error": "{type} のアップデート確認に失敗しました: {message}" + }, "cleanupExampleImages": { "label": "例画像フォルダをクリーンアップ", "success": "{count} 個のフォルダを削除フォルダに移動しました", diff --git a/locales/ko.json b/locales/ko.json index 14903ef9..d06ea8e2 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -139,6 +139,13 @@ "missingPath": "예시 이미지를 다운로드하기 전에 다운로드 위치를 설정하세요.", "unavailable": "예시 이미지 다운로드는 아직 사용할 수 없습니다. 페이지 로딩이 완료된 후 다시 시도하세요." }, + "checkModelUpdates": { + "label": "업데이트 확인", + "loading": "{type} 업데이트를 확인 중...", + "success": "{type} 업데이트 {count}개를 찾았습니다", + "none": "모든 {type}가 최신 상태입니다", + "error": "{type} 업데이트 확인 실패: {message}" + }, "cleanupExampleImages": { "label": "예시 이미지 폴더 정리", "success": "{count}개의 폴더가 삭제 폴더로 이동되었습니다", diff --git a/locales/ru.json b/locales/ru.json index b72c91b1..23f15bc9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -139,6 +139,13 @@ "missingPath": "Укажите место загрузки перед загрузкой примеров изображений.", "unavailable": "Загрузка примеров изображений пока недоступна. Попробуйте снова после полной загрузки страницы." }, + "checkModelUpdates": { + "label": "Проверить обновления", + "loading": "Проверка обновлений для {type}...", + "success": "Найдено {count} обновлений для {type}", + "none": "Все {type} актуальны", + "error": "Не удалось проверить обновления для {type}: {message}" + }, "cleanupExampleImages": { "label": "Очистить папки с примерами изображений", "success": "Перемещено {count} папок в папку удалённых", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index be002614..53106e7e 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -139,6 +139,13 @@ "missingPath": "请先设置下载位置后再下载示例图片。", "unavailable": "示例图片下载当前不可用。请在页面加载完成后重试。" }, + "checkModelUpdates": { + "label": "检查更新", + "loading": "正在检查 {type} 更新...", + "success": "找到 {count} 条 {type} 更新", + "none": "所有 {type} 均已是最新版本", + "error": "检查 {type} 更新失败:{message}" + }, "cleanupExampleImages": { "label": "清理示例图片文件夹", "success": "已将 {count} 个文件夹移动到已删除文件夹", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 0ce1a382..2ce3d5f4 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -139,6 +139,13 @@ "missingPath": "請先設定下載位置再下載範例圖片。", "unavailable": "範例圖片下載目前尚不可用。請在頁面載入完成後再試一次。" }, + "checkModelUpdates": { + "label": "檢查更新", + "loading": "正在檢查 {type} 更新...", + "success": "找到 {count} 個 {type} 更新", + "none": "所有 {type} 都是最新版本", + "error": "檢查 {type} 更新失敗:{message}" + }, "cleanupExampleImages": { "label": "清理範例圖片資料夾", "success": "已將 {count} 個資料夾移至已刪除資料夾", diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index aa168413..911b792d 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -76,6 +76,7 @@ export function getApiEndpoints(modelType) { fetchAllCivitai: `/api/lm/${modelType}/fetch-all-civitai`, relinkCivitai: `/api/lm/${modelType}/relink-civitai`, civitaiVersions: `/api/lm/${modelType}/civitai/versions`, + refreshUpdates: `/api/lm/${modelType}/updates/refresh`, // Preview management replacePreview: `/api/lm/${modelType}/replace-preview`, diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index deeacff9..a075c21f 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -1,11 +1,15 @@ import { BaseContextMenu } from './BaseContextMenu.js'; import { showToast } from '../../utils/uiHelpers.js'; import { state } from '../../state/index.js'; +import { translate } from '../../utils/i18nHelpers.js'; +import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; +import { resetAndReload } from '../../api/modelApiFactory.js'; export class GlobalContextMenu extends BaseContextMenu { constructor() { super('globalContextMenu'); this._cleanupInProgress = false; + this._updateCheckInProgress = false; } showMenu(x, y, origin = null) { @@ -25,6 +29,11 @@ export class GlobalContextMenu extends BaseContextMenu { console.error('Failed to trigger example images download:', error); }); break; + case 'check-model-updates': + this.checkModelUpdates(menuItem).catch((error) => { + console.error('Failed to check model updates:', error); + }); + break; default: console.warn(`Unhandled global context menu action: ${action}`); break; @@ -101,4 +110,74 @@ export class GlobalContextMenu extends BaseContextMenu { menuItem?.classList.remove('disabled'); } } + + async checkModelUpdates(menuItem) { + if (this._updateCheckInProgress) { + return; + } + + const modelType = getCurrentModelType(); + const apiConfig = getCompleteApiConfig(modelType); + + if (!apiConfig?.endpoints?.refreshUpdates) { + console.warn('Refresh updates endpoint not configured for model type:', modelType); + return; + } + + this._updateCheckInProgress = true; + menuItem?.classList.add('disabled'); + + const displayName = apiConfig.config?.displayName ?? 'Model'; + const loadingMessage = translate( + 'globalContextMenu.checkModelUpdates.loading', + { type: displayName }, + `Checking for ${displayName} updates...` + ); + + state.loadingManager?.showSimpleLoading?.(loadingMessage); + + try { + const response = await fetch(apiConfig.endpoints.refreshUpdates, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: false }) + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok || payload.success !== true) { + const errorMessage = payload?.error || response.statusText || 'Unknown error'; + throw new Error(errorMessage); + } + + const records = Array.isArray(payload.records) ? payload.records : []; + + if (records.length > 0) { + showToast('globalContextMenu.checkModelUpdates.success', { count: records.length, type: displayName }, 'success'); + } else { + showToast('globalContextMenu.checkModelUpdates.none', { type: displayName }, 'info'); + } + + await resetAndReload(false); + } catch (error) { + console.error('Error checking model updates:', error); + showToast( + 'globalContextMenu.checkModelUpdates.error', + { message: error?.message ?? 'Unknown error', type: displayName }, + 'error' + ); + } finally { + state.loadingManager?.hide?.(); + if (typeof state.loadingManager?.restoreProgressBar === 'function') { + state.loadingManager.restoreProgressBar(); + } + menuItem?.classList.remove('disabled'); + this._updateCheckInProgress = false; + } + } } diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index d633ca97..49a192fb 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -87,6 +87,9 @@
+ diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index 7dd18ad5..b6a79875 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -1,6 +1,7 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; const showToastMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); const copyToClipboardMock = vi.fn(); const getNSFWLevelNameMock = vi.fn((level) => { if (level >= 16) return 'XXX'; @@ -27,6 +28,7 @@ const loadingManagerStub = { showSimpleLoading: vi.fn(), hide: vi.fn(), show: vi.fn(), + restoreProgressBar: vi.fn(), }; const stateStub = { @@ -40,6 +42,11 @@ const downloadExampleImagesApiMock = vi.fn(); const replaceModelPreviewMock = vi.fn(); const refreshSingleModelMetadataMock = vi.fn(); const resetAndReloadMock = vi.fn(); +const getCompleteApiConfigMock = vi.fn(() => ({ + config: { displayName: 'LoRA' }, + endpoints: { refreshUpdates: '/api/lm/loras/updates/refresh' }, +})); +const getCurrentModelTypeMock = vi.fn(() => 'loras'); const getModelApiClientMock = vi.fn(() => ({ saveModelMetadata: saveModelMetadataMock, @@ -75,6 +82,16 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ resetAndReload: resetAndReloadMock, })); +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + getCompleteApiConfig: getCompleteApiConfigMock, + getCurrentModelType: getCurrentModelTypeMock, + MODEL_TYPES: { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings', + }, +})); + vi.mock('../../../static/js/state/index.js', () => ({ state: stateStub, })); @@ -92,6 +109,10 @@ vi.mock('../../../static/js/api/recipeApi.js', () => ({ updateRecipeMetadata: updateRecipeMetadataMock, })); +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + async function flushAsyncTasks() { await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -106,6 +127,13 @@ describe('Interaction-level regression coverage', () => { saveModelMetadataMock.mockResolvedValue(undefined); downloadExampleImagesApiMock.mockResolvedValue(undefined); updateRecipeMetadataMock.mockResolvedValue({ success: true }); + resetAndReloadMock.mockResolvedValue(undefined); + getCompleteApiConfigMock.mockReturnValue({ + config: { displayName: 'LoRA' }, + endpoints: { refreshUpdates: '/api/lm/loras/updates/refresh' }, + }); + getCurrentModelTypeMock.mockReturnValue('loras'); + translateMock.mockImplementation((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); global.modalManager = modalManagerMock; }); @@ -275,6 +303,7 @@ describe('Interaction-level regression coverage', () => { `; @@ -297,10 +326,15 @@ describe('Interaction-level regression coverage', () => { expect(downloadItem.classList.contains('disabled')).toBe(false); expect(document.getElementById('globalContextMenu').style.display).toBe('none'); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ success: true, moved_total: 2 }), - }); + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, moved_total: 2 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, records: [{ id: 1 }] }), + }); menu.showMenu(240, 320); const cleanupItem = document.querySelector('[data-action="cleanup-example-images-folders"]'); @@ -315,5 +349,30 @@ describe('Interaction-level regression coverage', () => { await flushAsyncTasks(); expect(cleanupItem.classList.contains('disabled')).toBe(false); expect(menu._cleanupInProgress).toBe(false); + + menu.showMenu(360, 420); + const checkUpdatesItem = document.querySelector('[data-action="check-model-updates"]'); + checkUpdatesItem.dispatchEvent(new Event('click', { bubbles: true })); + expect(checkUpdatesItem.classList.contains('disabled')).toBe(true); + + expect(global.fetch).toHaveBeenLastCalledWith('/api/lm/loras/updates/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: false }), + }); + + const updateResponse = await global.fetch.mock.results[1].value; + await updateResponse.json(); + await flushAsyncTasks(); + + expect(showToastMock).toHaveBeenCalledWith( + 'globalContextMenu.checkModelUpdates.success', + { count: 1, type: 'LoRA' }, + 'success' + ); + expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Checking for LoRA updates...'); + expect(loadingManagerStub.hide).toHaveBeenCalled(); + expect(resetAndReloadMock).toHaveBeenCalledWith(false); + expect(checkUpdatesItem.classList.contains('disabled')).toBe(false); }); });