diff --git a/locales/de.json b/locales/de.json index 6bf5fb63..004a98af 100644 --- a/locales/de.json +++ b/locales/de.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "Keine Ordner gefunden", "dragHint": "Elemente hierher ziehen, um Ordner zu erstellen" + }, + "folderUpdateCheck": { + "label": "Auf Updates in diesem Ordner prüfen", + "loading": "Prüfe {type}-Updates in diesem Ordner...", + "success": "{count} Update(s) für {type}s in diesem Ordner gefunden", + "none": "Alle {type}s in diesem Ordner sind aktuell", + "error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}" } }, "statistics": { diff --git a/locales/en.json b/locales/en.json index 65f09b35..b4a32469 100644 --- a/locales/en.json +++ b/locales/en.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "No folders found", "dragHint": "Drag items here to create folders" + }, + "folderUpdateCheck": { + "label": "Check for updates in this folder", + "loading": "Checking {type} updates for this folder...", + "success": "Found {count} update(s) for {type}s in this folder", + "none": "All {type}s in this folder are up to date", + "error": "Failed to check folder for {type} updates: {message}" } }, "statistics": { diff --git a/locales/es.json b/locales/es.json index ae7871e9..02118d5e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "No se encontraron carpetas", "dragHint": "Arrastra elementos aquí para crear carpetas" + }, + "folderUpdateCheck": { + "label": "Buscar actualizaciones en esta carpeta", + "loading": "Buscando actualizaciones de {type} en esta carpeta...", + "success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta", + "none": "Todos los {type}s en esta carpeta están actualizados", + "error": "Error al buscar actualizaciones de {type} en la carpeta: {message}" } }, "statistics": { diff --git a/locales/fr.json b/locales/fr.json index de7dbab8..1cc64a65 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "Aucun dossier trouvé", "dragHint": "Faites glisser des éléments ici pour créer des dossiers" + }, + "folderUpdateCheck": { + "label": "Vérifier les mises à jour dans ce dossier", + "loading": "Vérification des mises à jour {type} dans ce dossier...", + "success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier", + "none": "Tous les {type}s dans ce dossier sont à jour", + "error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}" } }, "statistics": { diff --git a/locales/he.json b/locales/he.json index f88abfdc..e974aecc 100644 --- a/locales/he.json +++ b/locales/he.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "לא נמצאו תיקיות", "dragHint": "גרור פריטים לכאן כדי ליצור תיקיות" + }, + "folderUpdateCheck": { + "label": "בדוק עדכונים בתיקייה זו", + "loading": "בודק עדכוני {type} בתיקייה זו...", + "success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו", + "none": "כל ה-{type}s בתיקייה זו מעודכנים", + "error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}" } }, "statistics": { diff --git a/locales/ja.json b/locales/ja.json index 72ab5a40..dab1065f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "フォルダが見つかりません", "dragHint": "ここへアイテムをドラッグしてフォルダを作成します" + }, + "folderUpdateCheck": { + "label": "このフォルダのアップデートを確認", + "loading": "このフォルダの{type}アップデートを確認中...", + "success": "このフォルダの{type}sに{count}件のアップデートが見つかりました", + "none": "このフォルダのすべての{type}sは最新です", + "error": "フォルダの{type}アップデート確認に失敗しました: {message}" } }, "statistics": { diff --git a/locales/ko.json b/locales/ko.json index f265f99f..4e8abe85 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "폴더를 찾을 수 없습니다", "dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다" + }, + "folderUpdateCheck": { + "label": "이 폴더의 업데이트 확인", + "loading": "이 폴더의 {type} 업데이트를 확인하는 중...", + "success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다", + "none": "이 폴더의 모든 {type}s가 최신 상태입니다", + "error": "폴더의 {type} 업데이트 확인 실패: {message}" } }, "statistics": { diff --git a/locales/ru.json b/locales/ru.json index d666060f..c9b626fd 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "Папки не найдены", "dragHint": "Перетащите элементы сюда, чтобы создать папки" + }, + "folderUpdateCheck": { + "label": "Проверить обновления в этой папке", + "loading": "Проверка обновлений {type} в этой папке...", + "success": "Найдено {count} обновление(й) для {type}s в этой папке", + "none": "Все {type}s в этой папке актуальны", + "error": "Не удалось проверить папку на наличие обновлений {type}: {message}" } }, "statistics": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 46f67846..184a6893 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "未找到文件夹", "dragHint": "拖拽项目到此处以创建文件夹" + }, + "folderUpdateCheck": { + "label": "检查此文件夹的更新", + "loading": "正在检查此文件夹中的{type}更新...", + "success": "在此文件夹中找到 {count} 个{type}更新", + "none": "此文件夹中的所有{type}都是最新版本", + "error": "检查文件夹{type}更新失败: {message}" } }, "statistics": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 4ff95aba..0baf0b28 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -963,6 +963,13 @@ "empty": { "noFolders": "未找到資料夾", "dragHint": "將項目拖到此處以建立資料夾" + }, + "folderUpdateCheck": { + "label": "檢查此資料夾的更新", + "loading": "正在檢查此資料夾中的{type}更新...", + "success": "在此資料夾中找到 {count} 個{type}更新", + "none": "此資料夾中的所有{type}都是最新版本", + "error": "檢查資料夾{type}更新失敗: {message}" } }, "statistics": { diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index d2b8a15f..3724a01f 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -1960,6 +1960,10 @@ class ModelUpdateHandler: if target_model_ids: target_model_ids = sorted(set(target_model_ids)) + folder_path: Optional[str] = payload.get("folder_path") + if folder_path is not None and not isinstance(folder_path, str): + folder_path = None + provider = await self._get_civitai_provider() if provider is None: return web.json_response( @@ -1974,6 +1978,7 @@ class ModelUpdateHandler: provider, force_refresh=force_refresh, target_model_ids=target_model_ids or None, + folder_path=folder_path, ) if self._service.scanner.is_cancelled(): return web.json_response( diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 0965e229..19237888 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -689,6 +689,7 @@ class ModelUpdateService: *, force_refresh: bool = False, target_model_ids: Optional[Sequence[int]] = None, + folder_path: Optional[str] = None, ) -> Dict[int, ModelUpdateRecord]: """Refresh update information for every model present in the cache.""" scanner.reset_cancellation() @@ -703,6 +704,7 @@ class ModelUpdateService: local_versions = await self._collect_local_versions( scanner, target_model_ids=target_filter, + folder_path=folder_path, ) total_models = len(local_versions) if total_models == 0: @@ -1276,6 +1278,7 @@ class ModelUpdateService: scanner, *, target_model_ids: Optional[Sequence[int]] = None, + folder_path: Optional[str] = None, ) -> Dict[int, List[int]]: cache = await scanner.get_cached_data() mapping: Dict[int, set[int]] = {} @@ -1288,7 +1291,19 @@ class ModelUpdateService: if not target_set: return {} + normalized_folder = None + if folder_path is not None: + normalized_folder = folder_path.replace("\\", "/").strip("/") + for item in cache.raw_data: + # Apply folder filter first (cheapest check) + if normalized_folder is not None: + if not isinstance(item, dict): + continue + item_folder = (item.get("folder") or "").replace("\\", "/").strip("/") + if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"): + continue + civitai = item.get("civitai") if isinstance(item, dict) else None if not isinstance(civitai, dict): continue diff --git a/static/css/components/sidebar.css b/static/css/components/sidebar.css index f20d567e..801cb255 100644 --- a/static/css/components/sidebar.css +++ b/static/css/components/sidebar.css @@ -745,3 +745,8 @@ .sidebar-tree-container { position: relative; } + +/* Folder context menu - positioned relative to sidebar */ +#sidebarFolderContextMenu { + z-index: var(--z-modal, 1002); +} diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index c80d0fd3..7e7563d7 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -766,6 +766,49 @@ export class BaseModelApiClient { } } + async refreshUpdatesForFolder(folderPath, { force = false } = {}) { + if (!folderPath) { + throw new Error('No folder path provided'); + } + + try { + state.loadingManager.show('Checking for updates...', 0); + state.loadingManager.showCancelButton(() => this.cancelTask()); + + const response = await fetch(this.apiConfig.endpoints.refreshUpdates, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + folder_path: folderPath, + 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) { + if (payload?.status === 'cancelled') { + showToast('toast.api.operationCancelled', {}, 'info'); + return null; + } + const message = payload?.error || response.statusText || 'Failed to refresh updates'; + throw new Error(message); + } + + return payload; + } catch (error) { + console.error('Error refreshing updates for folder:', error); + throw error; + } finally { + state.loadingManager.hide(); + } + } + async fetchCivitaiVersions(modelId, source = null) { try { let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index a4805dce..d5371650 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js'; import { state } from '../state/index.js'; import { bulkManager } from '../managers/BulkManager.js'; import { showToast } from '../utils/uiHelpers.js'; +import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { escapeHtml, escapeAttribute } from './shared/utils.js'; export class SidebarManager { @@ -41,6 +42,7 @@ export class SidebarManager { // Bind methods this.handleTreeClick = this.handleTreeClick.bind(this); + this.handleTreeContextMenu = this.handleTreeContextMenu.bind(this); this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this); this.handleDocumentClick = this.handleDocumentClick.bind(this); this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this); @@ -185,6 +187,8 @@ export class SidebarManager { } if (folderTree) { folderTree.removeEventListener('click', this.handleTreeClick); + folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu); + folderTree.removeEventListener('dragover', this.handleFolderDragOver); } if (sidebarBreadcrumbNav) { sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick); @@ -977,6 +981,7 @@ export class SidebarManager { const folderTree = document.getElementById('sidebarFolderTree'); if (folderTree) { folderTree.addEventListener('click', this.handleTreeClick); + folderTree.addEventListener('contextmenu', this.handleTreeContextMenu); } // Breadcrumb click handler @@ -1027,6 +1032,19 @@ export class SidebarManager { if (displayModeToggleBtn) { displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle); } + + // Sidebar folder context menu click handler + const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu'); + if (sidebarFolderMenu) { + sidebarFolderMenu.addEventListener('click', (e) => { + const item = e.target.closest('.context-menu-item'); + if (!item) return; + const action = item.dataset.action; + if (action) { + this.handleFolderContextMenuAction(action); + } + }); + } } handleDocumentClick(event) { @@ -1398,6 +1416,82 @@ export class SidebarManager { } } + handleTreeContextMenu(event) { + const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item'); + if (!nodeContent) return; + + event.preventDefault(); + event.stopPropagation(); + + const path = nodeContent.dataset.path; + if (path === undefined || path === null || path === '') return; + + this._showFolderContextMenu(event.clientX, event.clientY, path); + } + + _showFolderContextMenu(x, y, path) { + this._closeFolderContextMenu(); + + const menu = document.getElementById('sidebarFolderContextMenu'); + if (!menu) return; + + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + menu.style.display = 'block'; + menu.dataset.folderPath = path; + + this._folderContextOpen = true; + + // Close on next click outside + this._folderContextCloseHandler = (e) => { + if (!menu.contains(e.target)) { + this._closeFolderContextMenu(); + } + }; + setTimeout(() => { + document.addEventListener('click', this._folderContextCloseHandler); + }, 0); + } + + _closeFolderContextMenu() { + const menu = document.getElementById('sidebarFolderContextMenu'); + if (menu) { + menu.style.display = 'none'; + delete menu.dataset.folderPath; + } + if (this._folderContextCloseHandler) { + document.removeEventListener('click', this._folderContextCloseHandler); + this._folderContextCloseHandler = null; + } + this._folderContextOpen = false; + } + + handleFolderContextMenuAction(action) { + const menu = document.getElementById('sidebarFolderContextMenu'); + if (!menu) return; + + const path = menu.dataset.folderPath; + this._closeFolderContextMenu(); + + if (!path) return; + + this._performFolderAction(action, path); + } + + async _performFolderAction(action, path) { + switch (action) { + case 'check-folder-updates': + try { + await performFolderUpdateCheck(path); + } catch (error) { + console.error('Folder update check failed:', error); + } + break; + default: + console.warn('Unknown folder action:', action); + } + } + handleBreadcrumbClick(event) { const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item'); const dropdownItem = event.target.closest('.breadcrumb-dropdown-item'); diff --git a/static/js/utils/updateCheckHelpers.js b/static/js/utils/updateCheckHelpers.js index 914614fc..92ec02a6 100644 --- a/static/js/utils/updateCheckHelpers.js +++ b/static/js/utils/updateCheckHelpers.js @@ -100,6 +100,90 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) { return { status, displayName, records, error }; } +/** + * Perform a model update check scoped to a specific folder. + * @param {string} folderPath - The relative folder path to check. + * @param {Object} [options] + * @param {Function} [options.onComplete] - Callback invoked after the request settles. + * @returns {Promise<{status: string, records: Array, error: Error | null}>} + */ +export async function performFolderUpdateCheck(folderPath, { onComplete } = {}) { + const modelType = getCurrentModelType(); + const apiConfig = getCompleteApiConfig(modelType); + const apiClient = getModelApiClient(modelType); + const displayName = apiConfig?.config?.displayName ?? 'Model'; + + if (!apiConfig?.endpoints?.refreshUpdates) { + console.warn('Refresh updates endpoint not configured for model type:', modelType); + onComplete?.({ status: 'unsupported', records: [], error: null }); + return { status: 'unsupported', records: [], error: null }; + } + + const loadingMessage = translate( + 'sidebar.folderUpdateCheck.loading', + { type: displayName }, + `Checking ${displayName} updates for this folder...` + ); + + state.loadingManager?.showSimpleLoading?.(loadingMessage); + state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask()); + + let status = 'success'; + let records = []; + let error = null; + + try { + const response = await fetch(apiConfig.endpoints.refreshUpdates, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_path: folderPath, force: false }) + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok || payload.success !== true) { + if (payload?.status === 'cancelled') { + showToast('toast.api.operationCancelled', {}, 'info'); + return { status: 'cancelled', records: [], error: null }; + } + const errorMessage = payload?.error || response.statusText || 'Unknown error'; + throw new Error(errorMessage); + } + + records = Array.isArray(payload.records) ? payload.records : []; + + if (records.length > 0) { + showToast('sidebar.folderUpdateCheck.success', { count: records.length, type: displayName }, 'success'); + } else { + showToast('sidebar.folderUpdateCheck.none', { type: displayName }, 'info'); + } + + await resetAndReload(false); + } catch (err) { + status = 'error'; + error = err instanceof Error ? err : new Error(String(err)); + console.error('Error checking folder model updates:', error); + showToast( + 'sidebar.folderUpdateCheck.error', + { message: error?.message ?? 'Unknown error', type: displayName }, + 'error' + ); + } finally { + state.loadingManager?.hide?.(); + if (typeof state.loadingManager?.restoreProgressBar === 'function') { + state.loadingManager.restoreProgressBar(); + } + onComplete?.({ status, records, error }); + } + + return { status, records, error }; +} + function getTypePlural(displayName) { if (!displayName) { return 'models'; diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 28bb8a5d..d7b4a652 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -150,6 +150,13 @@ + +
+
+ {{ t('sidebar.folderUpdateCheck.label') }} +
+
+

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