feat(update): add per-folder update check via sidebar context menu (#944)

This commit is contained in:
Will Miao
2026-06-02 18:34:01 +08:00
parent 7e5e3b1ec7
commit 2e7532eecc
17 changed files with 323 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -963,6 +963,13 @@
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
},
"folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו",
"loading": "בודק עדכוני {type} בתיקייה זו...",
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
}
},
"statistics": {

View File

@@ -963,6 +963,13 @@
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
},
"folderUpdateCheck": {
"label": "このフォルダのアップデートを確認",
"loading": "このフォルダの{type}アップデートを確認中...",
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
"none": "このフォルダのすべての{type}sは最新です",
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
}
},
"statistics": {

View File

@@ -963,6 +963,13 @@
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
},
"folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인",
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
}
},
"statistics": {

View File

@@ -963,6 +963,13 @@
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
},
"folderUpdateCheck": {
"label": "Проверить обновления в этой папке",
"loading": "Проверка обновлений {type} в этой папке...",
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
"none": "Все {type}s в этой папке актуальны",
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
}
},
"statistics": {

View File

@@ -963,6 +963,13 @@
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
},
"folderUpdateCheck": {
"label": "检查此文件夹的更新",
"loading": "正在检查此文件夹中的{type}更新...",
"success": "在此文件夹中找到 {count} 个{type}更新",
"none": "此文件夹中的所有{type}都是最新版本",
"error": "检查文件夹{type}更新失败: {message}"
}
},
"statistics": {

View File

@@ -963,6 +963,13 @@
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾"
},
"folderUpdateCheck": {
"label": "檢查此資料夾的更新",
"loading": "正在檢查此資料夾中的{type}更新...",
"success": "在此資料夾中找到 {count} 個{type}更新",
"none": "此資料夾中的所有{type}都是最新版本",
"error": "檢查資料夾{type}更新失敗: {message}"
}
},
"statistics": {

View File

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

View File

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

View File

@@ -745,3 +745,8 @@
.sidebar-tree-container {
position: relative;
}
/* Folder context menu - positioned relative to sidebar */
#sidebarFolderContextMenu {
z-index: var(--z-modal, 1002);
}

View File

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

View File

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

View File

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

View File

@@ -150,6 +150,13 @@
</div>
</div>
<!-- Sidebar Folder Context Menu -->
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
</div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector">
<div class="nsfw-level-header">
<h3>{{ t('modals.contentRating.title') }}</h3>