mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-08 10:32:13 -03:00
Refreshing had no way to say "scan only this drive": a user with three external
drives had to spin all of them up for every refresh, and switching a drive off
made the next refresh treat its whole library as deleted (rows pruned from the
memory cache and the SQLite cache, preview_url stripped on the next scroll).
Backend (py/services/model_scanner.py, py/config.py):
* ReconcileScope(roots, folder) + _reconcile_cache(scope=...): files inside the
scope reconcile normally, everything outside is neither re-read nor removed.
The folder half is plumbing for the sidebar entry in the next change.
* Path-level pruning guard: cached entries under a path this walk could not
read are kept and reported instead of removed. Sources: a configured root that
is not reachable (drive switched off while LM runs), a directory os.walk
failed to enter (permissions / I/O error / Windows junction to an offline
drive), and a known first-level symlink whose target is gone
(Config.iter_path_mappings()).
* The recorded folder list is unioned instead of replaced whenever the scan did
not verify every root, so a scoped scan cannot empty the sidebar.
* _reconcile_cache returns a summary (added / removed / repaired /
scanned_roots / skipped_roots / unavailable_paths / kept_unreachable),
exposed as ModelScanner.last_reconcile_summary, returned by
BaseModelService.scan_models() and broadcast in the completed WS payload.
* _root_display_labels(): set-aware labels ("G: loras", "usb/loras") grown
leftwards with real parent segments until unique, shared by the walk-progress
line and the roots API.
* GET /scan accepts repeated `roots` (400 for unknown roots, 400 combined with
full_rebuild=true); GET /roots gains root_details (label / reachable / cached
count) while `roots` stays a plain path list for existing callers.
* serve_preview: a 404 no longer clears the cached preview_url when the file's
own directory is unreachable - browsing the grid with a drive off used to
strip preview references from the persistent cache.
Frontend:
* Refresh ▾ gains a "Scan one folder" section listing the page's roots with
their cached counts; offline roots stay clickable and explain themselves; rows
are wired by delegation (new static/js/components/controls/ScanScopeMenu.js).
* A scoped scan reports "Scanned <root>: N new, M removed"; a scan that kept
entries reports "<N> models kept: <paths> not reachable".
* registerAPI() now injects the two cross-page passthroughs (fetchModelRoots and
an argument-forwarding refreshModels) so a page facade cannot drop them: the
first version rendered an empty menu and would have run a full refresh.
* createToastElement whitelists toast types, so a wrong `type` argument degrades
to the info style instead of rendering an unstyled box.
Verified in a sandbox instance with three roots: a scoped scan walks only the
requested root (progress roots=0/1, 240 files); a full refresh with one root
offline reports kept_unreachable=60 and leaves all 420 models cached; /roots
reports the offline root with its cached count. 3699 passed, 7 skipped;
frontend 1488 passed (148 files); vue widgets 96 passed.
1178 lines
42 KiB
JavaScript
1178 lines
42 KiB
JavaScript
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
|
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
|
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
|
import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
|
|
import { eventManager } from '../../utils/EventManager.js';
|
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
|
import { sidebarManager } from '../SidebarManager.js';
|
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
|
|
import { renderScanScopeMenu, resolveScanScopeTarget } from './ScanScopeMenu.js';
|
|
|
|
/**
|
|
* PageControls class - Unified control management for model pages
|
|
*/
|
|
export class PageControls {
|
|
constructor(pageType) {
|
|
// Set the current page type in state
|
|
setCurrentPageType(pageType);
|
|
|
|
// Store the page type
|
|
this.pageType = pageType;
|
|
|
|
// Get the current page state
|
|
this.pageState = getCurrentPageState();
|
|
|
|
// Initialize state based on page type
|
|
this.initializeState();
|
|
|
|
// Store API methods
|
|
this.api = null;
|
|
|
|
// Use global sidebar manager
|
|
this.sidebarManager = sidebarManager;
|
|
|
|
this._updateCheckInProgress = false;
|
|
|
|
// Initialize event listeners
|
|
this.initEventListeners();
|
|
|
|
// Initialize update availability filter button state
|
|
this.initUpdateAvailableFilter();
|
|
|
|
// Initialize favorites filter button state
|
|
this.initFavoritesFilter();
|
|
|
|
this.initExcludedViewControls();
|
|
this.syncExcludedViewState();
|
|
|
|
console.log(`PageControls initialized for ${pageType} page`);
|
|
window.pageControls = this;
|
|
}
|
|
|
|
/**
|
|
* Initialize state based on page type
|
|
*/
|
|
initializeState() {
|
|
// Set default values
|
|
this.pageState.pageSize = 100;
|
|
this.pageState.isLoading = false;
|
|
this.pageState.hasMore = true;
|
|
|
|
// Set default sort based on page type
|
|
this.pageState.sortBy = this.pageType === 'loras' ? 'name:asc' : 'name:asc';
|
|
|
|
// Load sort preference
|
|
this.loadSortPreference();
|
|
|
|
if (!this.pageState.viewMode) {
|
|
this.pageState.viewMode = 'active';
|
|
}
|
|
if (!this.pageState.excludedViewState) {
|
|
this.pageState.excludedViewState = {
|
|
sortBy: 'name:asc',
|
|
search: '',
|
|
};
|
|
}
|
|
if (!this.pageState.filters?.search) {
|
|
this.pageState.filters.search = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register API methods for the page
|
|
* @param {Object} api - API methods for the page
|
|
*/
|
|
registerAPI(api) {
|
|
this.api = {
|
|
...api,
|
|
// These two are identical for every page and must stay in sync with
|
|
// the scan API: the refresh scope menu reads the root list, and a
|
|
// scoped refresh has to pass `roots` through. They are defined here
|
|
// rather than in each page facade so a page cannot silently drop
|
|
// them (which is exactly how the scope menu ended up empty).
|
|
fetchModelRoots: async () => getModelApiClient().fetchModelRoots(),
|
|
// Forward the exact arguments: an unscoped refresh must keep calling
|
|
// the client with a single argument.
|
|
refreshModels: async (...args) => getModelApiClient().refreshModels(...args),
|
|
};
|
|
console.log(`API methods registered for ${this.pageType} page`);
|
|
|
|
// Initialize sidebar manager after API is registered
|
|
this.initSidebarManager();
|
|
}
|
|
|
|
/**
|
|
* Initialize sidebar manager
|
|
*/
|
|
async initSidebarManager() {
|
|
try {
|
|
this.sidebarManager.setHostPageControls(this);
|
|
await this.sidebarManager.initialize(this);
|
|
} catch (error) {
|
|
console.error('Failed to initialize SidebarManager:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize event listeners for controls
|
|
*/
|
|
initEventListeners() {
|
|
// Sort select handler
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (sortSelect) {
|
|
initSortDropdown(sortSelect);
|
|
applySortToSelect(this.pageState.sortBy);
|
|
sortSelect.addEventListener('change', async (e) => {
|
|
let value = e.target.value;
|
|
if (value.startsWith('random')) {
|
|
// Every pick of Random reshuffles the list: generate a
|
|
// fresh seed so the backend keeps a stable order across
|
|
// paginated requests.
|
|
value = randomizeSortValue();
|
|
}
|
|
this.pageState.sortBy = value;
|
|
this.saveSortPreference(value);
|
|
// Reset the seeded Random option when switching away from
|
|
// Random, or re-apply the fresh seed when picking it again.
|
|
applySortToSelect(value);
|
|
await this.resetAndReload();
|
|
});
|
|
}
|
|
|
|
// Refresh button handler
|
|
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => this.refreshModels(false)); // Regular refresh (incremental)
|
|
}
|
|
|
|
// Initialize dropdown functionality
|
|
this.initDropdowns();
|
|
|
|
// Clear custom filter handler
|
|
const clearFilterBtn = document.querySelector('.clear-filter');
|
|
if (clearFilterBtn) {
|
|
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
|
|
}
|
|
|
|
// Check for View Local Versions filter
|
|
this.checkVlmFilter();
|
|
|
|
// Page-specific event listeners
|
|
this.initPageSpecificListeners();
|
|
|
|
// Keyboard shortcuts for the actions toolbar (R / F / D)
|
|
this.registerKeyboardShortcuts();
|
|
}
|
|
|
|
/**
|
|
* Register keyboard shortcuts for the actions toolbar buttons
|
|
* (R = refresh, F = fetch metadata, D = download)
|
|
*/
|
|
registerKeyboardShortcuts() {
|
|
eventManager.addHandler('keydown', 'pageControls-actions', (e) => {
|
|
return this.handleActionShortcut(e);
|
|
}, {
|
|
priority: 90,
|
|
skipWhenModalOpen: true
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle a keydown event for the actions toolbar shortcuts
|
|
* @param {KeyboardEvent} e
|
|
* @returns {boolean} True when the event was handled and propagation should stop
|
|
*/
|
|
handleActionShortcut(e) {
|
|
// Plain letters only — leave modified combos (Ctrl/Cmd/Alt) alone
|
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
return false;
|
|
}
|
|
|
|
// Don't hijack keys while typing in a text entry context
|
|
if (isTypingContext(e.target)) {
|
|
return false;
|
|
}
|
|
|
|
const actionByKey = {
|
|
r: 'refresh',
|
|
f: 'fetch',
|
|
d: 'download'
|
|
};
|
|
const action = actionByKey[e.key.toLowerCase()];
|
|
if (!action) {
|
|
return false;
|
|
}
|
|
|
|
// The button may not exist on this page (e.g. recipes has no
|
|
// fetch/download) — let other handlers run in that case
|
|
const button = document.querySelector(`[data-action="${action}"]`);
|
|
if (!button) {
|
|
return false;
|
|
}
|
|
|
|
e.preventDefault();
|
|
// Native disabled buttons ignore .click(), so an in-progress
|
|
// refresh is safe
|
|
button.click();
|
|
return true;
|
|
}
|
|
|
|
initExcludedViewControls() {
|
|
const backButton = document.getElementById('excludedViewBackBtn');
|
|
if (backButton) {
|
|
backButton.addEventListener('click', async () => {
|
|
await this.exitExcludedView();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize dropdown functionality
|
|
*/
|
|
initDropdowns() {
|
|
// Handle dropdown toggles
|
|
const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
|
|
dropdownToggles.forEach(toggle => {
|
|
toggle.addEventListener('click', (e) => {
|
|
e.stopPropagation(); // Prevent triggering parent button
|
|
const dropdownGroup = toggle.closest('.dropdown-group');
|
|
|
|
// Close all other open dropdowns first
|
|
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
|
if (group !== dropdownGroup) {
|
|
group.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Toggle current dropdown
|
|
const isOpen = dropdownGroup.classList.toggle('active');
|
|
if (isOpen) {
|
|
this.loadScanScopeMenu();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Handle full rebuild option
|
|
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
|
if (fullRebuildOption) {
|
|
fullRebuildOption.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.refreshModels(true);
|
|
// Close the dropdown
|
|
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
// Per-root scan rows are rendered dynamically, so they are wired by
|
|
// delegation instead of a direct querySelector per item.
|
|
const scopeMenu = document.getElementById('refreshScopeMenu');
|
|
if (scopeMenu) {
|
|
scopeMenu.addEventListener('click', (e) => {
|
|
const item = e.target.closest('[data-action="scan-root"]');
|
|
if (!item) {
|
|
return;
|
|
}
|
|
e.stopPropagation();
|
|
this.handleScanRootSelection(item);
|
|
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
const checkUpdatesOption = document.getElementById('checkUpdatesMenuItem');
|
|
if (checkUpdatesOption) {
|
|
checkUpdatesOption.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await this.handleCheckModelUpdates(e.currentTarget);
|
|
});
|
|
}
|
|
|
|
// Close dropdowns when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.dropdown-group')) {
|
|
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
|
group.classList.remove('active');
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render the per-root scan entries of the refresh dropdown.
|
|
*
|
|
* Roots come from the backend (label, model count, reachability) so the menu
|
|
* always names a root exactly like the scan progress line does. The list is
|
|
* cached until the next scan finishes.
|
|
*/
|
|
async loadScanScopeMenu() {
|
|
const menu = document.getElementById('refreshScopeMenu');
|
|
if (!menu || typeof this.api?.fetchModelRoots !== 'function') {
|
|
return;
|
|
}
|
|
|
|
if (this._scanScopeDetails) {
|
|
this.renderScanScopeMenu(this._scanScopeDetails);
|
|
return;
|
|
}
|
|
if (this._scanScopeLoading) {
|
|
return;
|
|
}
|
|
|
|
this._scanScopeLoading = true;
|
|
try {
|
|
const payload = await this.api.fetchModelRoots();
|
|
const details = Array.isArray(payload?.root_details) ? payload.root_details : [];
|
|
this._scanScopeDetails = details;
|
|
renderScanScopeMenu(menu, details);
|
|
} catch (error) {
|
|
console.error('Error loading model roots:', error);
|
|
menu.innerHTML = '';
|
|
} finally {
|
|
this._scanScopeLoading = false;
|
|
}
|
|
}
|
|
|
|
handleScanRootSelection(item) {
|
|
const { rootPath, label, offline } = resolveScanScopeTarget(item);
|
|
if (!rootPath) {
|
|
return;
|
|
}
|
|
if (offline) {
|
|
// showToast(key, params, type): the sentence must NOT be passed here,
|
|
// it used to land in the `type` slot and produced an unstyled toast.
|
|
showToast('toast.api.scanRootUnreachable', { scope: label }, 'info');
|
|
return;
|
|
}
|
|
this.refreshModels(false, { roots: [rootPath] });
|
|
}
|
|
|
|
async handleCheckModelUpdates(menuItem) {
|
|
if (this._updateCheckInProgress) {
|
|
return;
|
|
}
|
|
|
|
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
|
const dropdownToggle = document.getElementById('updateFilterMenuToggle');
|
|
const dropdownGroup = menuItem?.closest('.dropdown-group');
|
|
const iconElement = updateFilterBtn?.querySelector('i');
|
|
|
|
const setLoadingState = (isLoading) => {
|
|
if (updateFilterBtn) {
|
|
updateFilterBtn.disabled = isLoading;
|
|
updateFilterBtn.classList.toggle('loading', isLoading);
|
|
updateFilterBtn.setAttribute('aria-busy', isLoading ? 'true' : 'false');
|
|
|
|
if (iconElement) {
|
|
if (isLoading) {
|
|
if (!iconElement.dataset.originalClass) {
|
|
iconElement.dataset.originalClass = iconElement.className;
|
|
}
|
|
iconElement.className = 'fas fa-spinner fa-spin';
|
|
} else {
|
|
const originalClass = iconElement.dataset.originalClass;
|
|
if (originalClass) {
|
|
iconElement.className = originalClass;
|
|
delete iconElement.dataset.originalClass;
|
|
} else {
|
|
iconElement.classList.remove('fa-spinner', 'fa-spin');
|
|
if (!iconElement.classList.contains('fa-exclamation-circle')) {
|
|
iconElement.classList.add('fa-exclamation-circle');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (dropdownToggle) {
|
|
dropdownToggle.disabled = isLoading;
|
|
dropdownToggle.classList.toggle('loading', isLoading);
|
|
}
|
|
|
|
if (menuItem) {
|
|
menuItem.classList.toggle('disabled', isLoading);
|
|
if (isLoading) {
|
|
menuItem.setAttribute('aria-disabled', 'true');
|
|
} else {
|
|
menuItem.removeAttribute('aria-disabled');
|
|
}
|
|
}
|
|
};
|
|
|
|
this._updateCheckInProgress = true;
|
|
setLoadingState(true);
|
|
|
|
const handleComplete = () => {
|
|
this._updateCheckInProgress = false;
|
|
setLoadingState(false);
|
|
};
|
|
|
|
try {
|
|
await performModelUpdateCheck({
|
|
onComplete: handleComplete,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to check model updates:', error);
|
|
} finally {
|
|
if (this._updateCheckInProgress) {
|
|
this._updateCheckInProgress = false;
|
|
setLoadingState(false);
|
|
}
|
|
dropdownGroup?.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize page-specific event listeners
|
|
*/
|
|
initPageSpecificListeners() {
|
|
// Fetch from Civitai button - available for both loras and checkpoints
|
|
const fetchButton = document.querySelector('[data-action="fetch"]');
|
|
if (fetchButton) {
|
|
fetchButton.addEventListener('click', () => this.fetchFromCivitai());
|
|
}
|
|
|
|
const downloadButton = document.querySelector('[data-action="download"]');
|
|
if (downloadButton) {
|
|
downloadButton.addEventListener('click', () => this.showDownloadModal());
|
|
}
|
|
|
|
// Find duplicates button - available for both loras and checkpoints
|
|
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
|
|
if (duplicatesButton) {
|
|
duplicatesButton.addEventListener('click', () => this.findDuplicates());
|
|
}
|
|
|
|
const bulkButton = document.querySelector('[data-action="bulk"]');
|
|
if (bulkButton) {
|
|
bulkButton.addEventListener('click', () => this.toggleBulkMode());
|
|
}
|
|
|
|
// Favorites filter button handler
|
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
|
if (favoriteFilterBtn) {
|
|
favoriteFilterBtn.addEventListener('click', () => this.toggleFavoritesOnly());
|
|
}
|
|
|
|
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
|
if (updateFilterBtn) {
|
|
updateFilterBtn.addEventListener('click', () => this.toggleUpdateAvailableOnly());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load sort preference from storage
|
|
*/
|
|
loadSortPreference() {
|
|
// Use separate keys for grouped vs non-grouped sort so each mode
|
|
// remembers its own preference independently
|
|
const key = state.global.settings.group_by_model
|
|
? `${this.pageType}_sort_grouped`
|
|
: `${this.pageType}_sort`;
|
|
const savedSort = getStorageItem(key);
|
|
if (savedSort) {
|
|
// Handle legacy format conversion
|
|
const convertedSort = this.convertLegacySortFormat(savedSort);
|
|
this.pageState.sortBy = convertedSort;
|
|
applySortToSelect(convertedSort);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert legacy sort format to new format
|
|
* @param {string} sortValue - The sort value to convert
|
|
* @returns {string} - Converted sort value
|
|
*/
|
|
convertLegacySortFormat(sortValue) {
|
|
// Convert old format to new format with direction
|
|
switch (sortValue) {
|
|
case 'name':
|
|
return 'name:asc';
|
|
case 'date':
|
|
return 'date:desc'; // Newest first is more intuitive default
|
|
case 'size':
|
|
return 'size:desc'; // Largest first is more intuitive default
|
|
default:
|
|
// If it's already in new format or unknown, return as is
|
|
return sortValue.includes(':') ? sortValue : 'name:asc';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save sort preference to storage
|
|
* @param {string} sortValue - The sort value to save
|
|
*/
|
|
saveSortPreference(sortValue) {
|
|
if (this.pageState.viewMode === 'excluded') {
|
|
this.pageState.excludedViewState = {
|
|
...(this.pageState.excludedViewState || {}),
|
|
sortBy: sortValue,
|
|
};
|
|
return;
|
|
}
|
|
// Separate storage for grouped vs non-grouped sort
|
|
const key = state.global.settings.group_by_model
|
|
? `${this.pageType}_sort_grouped`
|
|
: `${this.pageType}_sort`;
|
|
setStorageItem(key, sortValue);
|
|
}
|
|
|
|
/**
|
|
* Open model page on Civitai
|
|
* @param {string} modelName - Name of the model
|
|
*/
|
|
openCivitai(modelName) {
|
|
// Get card selector based on page type
|
|
const cardSelector = this.pageType === 'loras'
|
|
? `.model-card[data-name="${modelName}"]`
|
|
: `.checkpoint-card[data-name="${modelName}"]`;
|
|
|
|
const card = document.querySelector(cardSelector);
|
|
if (!card) return;
|
|
|
|
const metaData = JSON.parse(card.dataset.meta);
|
|
const civitaiId = metaData.modelId;
|
|
const versionId = metaData.id;
|
|
|
|
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
|
}
|
|
|
|
/**
|
|
* Reset and reload the models list
|
|
*/
|
|
async resetAndReload(updateFolders = false) {
|
|
if (!this.api) {
|
|
console.error('API methods not registered');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.api.resetAndReload(updateFolders);
|
|
|
|
// Refresh sidebar after reload if folders were updated
|
|
if (updateFolders && this.sidebarManager) {
|
|
await this.sidebarManager.refresh();
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error reloading ${this.pageType}:`, error);
|
|
showToast('toast.controls.reloadFailed', { pageType: this.pageType, message: error.message }, 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh models list
|
|
* @param {boolean} fullRebuild - Whether to perform a full rebuild
|
|
*/
|
|
async refreshModels(fullRebuild = false, { roots = null } = {}) {
|
|
if (!this.api) {
|
|
console.error('API methods not registered');
|
|
return;
|
|
}
|
|
|
|
// Root labels / reachability may have changed with the scan, and the
|
|
// model counts definitely did.
|
|
this._scanScopeDetails = null;
|
|
const scopedRoots = Array.isArray(roots) ? roots.filter(Boolean) : [];
|
|
|
|
try {
|
|
if (scopedRoots.length) {
|
|
await this.api.refreshModels(fullRebuild, { roots: scopedRoots });
|
|
} else {
|
|
await this.api.refreshModels(fullRebuild);
|
|
}
|
|
|
|
// Refresh sidebar after rebuild
|
|
if (this.sidebarManager) {
|
|
await this.sidebarManager.refresh();
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
|
showToast('toast.controls.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', pageType: this.pageType, message: error.message }, 'error');
|
|
}
|
|
|
|
if (window.modelDuplicatesManager) {
|
|
// Update duplicates badge after refresh
|
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch metadata from Civitai (available for both LoRAs and Checkpoints)
|
|
*/
|
|
async fetchFromCivitai() {
|
|
if (!this.api) {
|
|
console.error('API methods not registered');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.api.fetchFromCivitai();
|
|
} catch (error) {
|
|
console.error('Error fetching metadata:', error);
|
|
showToast('toast.controls.fetchMetadataFailed', { message: error.message }, 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show download modal
|
|
*/
|
|
showDownloadModal() {
|
|
this.api.showDownloadModal();
|
|
}
|
|
|
|
/**
|
|
* Toggle bulk mode
|
|
*/
|
|
toggleBulkMode() {
|
|
this.api.toggleBulkMode();
|
|
}
|
|
|
|
/**
|
|
* Clear custom filter
|
|
*/
|
|
/**
|
|
* Dynamically add the VLM sort option (version_id:desc) to the sort dropdown.
|
|
* It is not a permanent option — only present while VLM is active.
|
|
*/
|
|
_addVlmSortOption() {
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (!sortSelect) return;
|
|
// Only add if not already present
|
|
if (sortSelect.querySelector('option[value="version_id:desc"]')) return;
|
|
const opt = document.createElement('option');
|
|
opt.value = 'version_id:desc';
|
|
opt.textContent = this._t('loras.controls.sort.versionIdDesc', 'Newest version first');
|
|
sortSelect.appendChild(opt);
|
|
}
|
|
|
|
/**
|
|
* Remove the VLM sort option from the sort dropdown.
|
|
*/
|
|
_removeVlmSortOption() {
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (!sortSelect) return;
|
|
const opt = sortSelect.querySelector('option[value="version_id:desc"]');
|
|
if (opt) opt.remove();
|
|
}
|
|
|
|
/**
|
|
* Look up a translation key via the global i18n helper, falling back to
|
|
* a plain-text default when the key is missing or i18n is unavailable.
|
|
*/
|
|
_t(key, fallback) {
|
|
if (typeof window.i18n?.t === 'function') {
|
|
return window.i18n.t(key, { defaultValue: fallback });
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* Restore the sort dropdown state after VLM is cleared.
|
|
* Shared by PageControls.clearCustomFilter() and subclass overrides.
|
|
*/
|
|
_restoreSortAfterVlm() {
|
|
const prevSort = getSessionItem('vlm_prev_sort');
|
|
removeSessionItem('vlm_prev_sort');
|
|
const restoredSort = prevSort || 'name:asc';
|
|
this.pageState.sortBy = restoredSort;
|
|
this.saveSortPreference(restoredSort);
|
|
this._removeVlmSortOption();
|
|
applySortToSelect(restoredSort);
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (sortSelect) {
|
|
sortSelect.disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger View Local Versions without page reload
|
|
* Sets sessionStorage and reloads data via the API.
|
|
*/
|
|
triggerVlmView(modelId, modelName, baseModel, pageType) {
|
|
const targetPageType = pageType || this.pageType;
|
|
setSessionItem('vlm_model_id', String(modelId));
|
|
setSessionItem('vlm_model_name', modelName || String(modelId));
|
|
setSessionItem('vlm_page_type', targetPageType);
|
|
if (baseModel) {
|
|
setSessionItem('vlm_base_model', baseModel);
|
|
} else {
|
|
removeSessionItem('vlm_base_model');
|
|
}
|
|
// Save current sort preference so it can be restored when VLM is cleared
|
|
setSessionItem('vlm_prev_sort', this.pageState.sortBy);
|
|
// Inject the temporary sort option and force version_id:desc
|
|
this._addVlmSortOption();
|
|
this.pageState.sortBy = 'version_id:desc';
|
|
this.saveSortPreference('version_id:desc');
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (sortSelect) {
|
|
sortSelect.value = 'version_id:desc';
|
|
sortSelect.disabled = true;
|
|
}
|
|
// Reload data via API (no page reload)
|
|
this.resetAndReload(true).then(() => {
|
|
// Show the VLM indicator after data loads
|
|
this.checkVlmFilter();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called when group_by_model is toggled.
|
|
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
|
|
* so each mode remembers its own sort preference independently.
|
|
*/
|
|
onGroupByModelToggled(isEnabled) {
|
|
const groupedKey = `${this.pageType}_sort_grouped`;
|
|
|
|
if (isEnabled) {
|
|
// Entering group mode: restore last-used grouped sort, if any
|
|
const savedGroupedSort = getStorageItem(groupedKey);
|
|
if (savedGroupedSort) {
|
|
this.pageState.sortBy = savedGroupedSort;
|
|
applySortToSelect(savedGroupedSort);
|
|
}
|
|
} else {
|
|
// Leaving group mode: persist current sort for next time, restore non-group sort
|
|
setStorageItem(groupedKey, this.pageState.sortBy);
|
|
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
|
if (savedNormalSort) {
|
|
this.pageState.sortBy = savedNormalSort;
|
|
applySortToSelect(savedNormalSort);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
|
|
*/
|
|
checkVlmFilter() {
|
|
const vlmModelId = getSessionItem('vlm_model_id');
|
|
const vlmPageType = getSessionItem('vlm_page_type');
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
|
|
// Only show VLM indicator when it belongs to the current page type
|
|
if (vlmModelId && vlmPageType !== this.pageType) {
|
|
// Stale VLM data from a different page — clean up
|
|
removeSessionItem('vlm_model_id');
|
|
removeSessionItem('vlm_model_name');
|
|
removeSessionItem('vlm_base_model');
|
|
removeSessionItem('vlm_page_type');
|
|
removeSessionItem('vlm_prev_sort');
|
|
this._removeVlmSortOption();
|
|
if (sortSelect) sortSelect.disabled = false;
|
|
return;
|
|
}
|
|
|
|
const vlmModelName = getSessionItem('vlm_model_name');
|
|
const vlmBaseModel = getSessionItem('vlm_base_model');
|
|
|
|
if (vlmModelId && vlmModelName) {
|
|
// VLM is active — inject sort option, disable dropdown, show indicator
|
|
this._addVlmSortOption();
|
|
if (sortSelect) {
|
|
sortSelect.value = 'version_id:desc';
|
|
sortSelect.disabled = true;
|
|
}
|
|
|
|
const indicator = document.getElementById('customFilterIndicator');
|
|
const filterText = indicator?.querySelector('.customFilterText');
|
|
|
|
if (indicator && filterText) {
|
|
indicator.classList.remove('hidden');
|
|
|
|
const prefix = vlmBaseModel
|
|
? 'Showing same-base versions from'
|
|
: 'Showing all versions from';
|
|
const displayText = `${prefix}: ${vlmModelName}`;
|
|
|
|
filterText.textContent = this._truncateText(displayText, 40);
|
|
filterText.setAttribute('title', displayText);
|
|
}
|
|
} else {
|
|
// No VLM — ensure sort option is removed and dropdown is enabled
|
|
this._removeVlmSortOption();
|
|
if (sortSelect) sortSelect.disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear custom filter
|
|
*/
|
|
async clearCustomFilter() {
|
|
// Check for View Local Versions filter first
|
|
const vlmModelId = getSessionItem('vlm_model_id');
|
|
if (vlmModelId) {
|
|
removeSessionItem('vlm_model_id');
|
|
removeSessionItem('vlm_model_name');
|
|
removeSessionItem('vlm_base_model');
|
|
removeSessionItem('vlm_page_type');
|
|
|
|
this._restoreSortAfterVlm();
|
|
|
|
// Hide the indicator
|
|
const indicator = document.getElementById('customFilterIndicator');
|
|
if (indicator) {
|
|
indicator.classList.add('hidden');
|
|
}
|
|
|
|
// Reload data via API (no page reload)
|
|
await this.resetAndReload(true);
|
|
return;
|
|
}
|
|
|
|
// Otherwise delegate to subclass for recipe filters
|
|
if (!this.api) {
|
|
console.error('API methods not registered');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.api.clearCustomFilter();
|
|
} catch (error) {
|
|
console.error('Error clearing custom filter:', error);
|
|
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Truncate text with ellipsis
|
|
*/
|
|
_truncateText(text, maxLength) {
|
|
if (!text) return '';
|
|
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
|
}
|
|
|
|
/**
|
|
* Initialize the favorites filter button state
|
|
*/
|
|
initFavoritesFilter() {
|
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
|
if (favoriteFilterBtn) {
|
|
// Get current state from session storage with page-specific key
|
|
const storageKey = `show_favorites_only_${this.pageType}`;
|
|
const showFavoritesOnly = getSessionItem(storageKey, false);
|
|
|
|
// Update button state
|
|
if (showFavoritesOnly) {
|
|
favoriteFilterBtn.classList.add('active');
|
|
}
|
|
|
|
// Update app state
|
|
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
|
}
|
|
|
|
this.updateActionButtonStates();
|
|
}
|
|
|
|
/**
|
|
* Initialize update availability filter button state
|
|
*/
|
|
initUpdateAvailableFilter() {
|
|
const storageKey = `show_update_available_only_${this.pageType}`;
|
|
const storedValue = getSessionItem(storageKey, false);
|
|
const showUpdatesOnly = storedValue === true || storedValue === 'true';
|
|
|
|
this.pageState.showUpdateAvailableOnly = showUpdatesOnly;
|
|
|
|
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
|
if (updateFilterBtn) {
|
|
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
|
}
|
|
|
|
this.updateActionButtonStates();
|
|
}
|
|
|
|
/**
|
|
* Toggle favorites-only filter and reload models
|
|
*/
|
|
async toggleFavoritesOnly() {
|
|
if (this.pageState.viewMode === 'excluded') {
|
|
return;
|
|
}
|
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
|
|
|
// Toggle the filter state in storage
|
|
const storageKey = `show_favorites_only_${this.pageType}`;
|
|
const currentState = this.pageState.showFavoritesOnly;
|
|
const newState = !currentState;
|
|
|
|
// Update session storage
|
|
setSessionItem(storageKey, newState);
|
|
|
|
// Update state
|
|
this.pageState.showFavoritesOnly = newState;
|
|
|
|
// Update button appearance
|
|
if (favoriteFilterBtn) {
|
|
favoriteFilterBtn.classList.toggle('active', newState);
|
|
}
|
|
|
|
// Reload models with new filter
|
|
await this.resetAndReload(true);
|
|
}
|
|
|
|
/**
|
|
* Toggle update-available-only filter and reload models
|
|
*/
|
|
async toggleUpdateAvailableOnly() {
|
|
if (this.pageState.viewMode === 'excluded') {
|
|
return;
|
|
}
|
|
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
|
const storageKey = `show_update_available_only_${this.pageType}`;
|
|
const newState = !this.pageState.showUpdateAvailableOnly;
|
|
|
|
setSessionItem(storageKey, newState);
|
|
|
|
this.pageState.showUpdateAvailableOnly = newState;
|
|
|
|
if (updateFilterBtn) {
|
|
updateFilterBtn.classList.toggle('active', newState);
|
|
}
|
|
|
|
await this.resetAndReload(true);
|
|
}
|
|
|
|
cloneFilters(filters = this.pageState.filters) {
|
|
return JSON.parse(JSON.stringify(filters || {}));
|
|
}
|
|
|
|
buildExcludedFilters(search = '') {
|
|
return {
|
|
baseModel: [],
|
|
tags: {},
|
|
license: {},
|
|
modelTypes: [],
|
|
search,
|
|
tagLogic: 'any',
|
|
};
|
|
}
|
|
|
|
applyFilterState(filters) {
|
|
this.pageState.filters = filters;
|
|
|
|
if (window.filterManager) {
|
|
window.filterManager.filters = window.filterManager.initializeFilters(filters);
|
|
window.filterManager.updateActiveFiltersCount();
|
|
if (typeof window.filterManager.updateSelections === 'function') {
|
|
window.filterManager.updateSelections();
|
|
}
|
|
window.filterManager.closeFilterPanel();
|
|
}
|
|
}
|
|
|
|
updateActionButtonStates() {
|
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
|
if (favoriteFilterBtn) {
|
|
favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly));
|
|
}
|
|
|
|
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
|
if (updateFilterBtn) {
|
|
updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly));
|
|
}
|
|
}
|
|
|
|
syncExcludedViewState() {
|
|
const isExcludedView = this.pageState.viewMode === 'excluded';
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const excludedBanner = document.getElementById('excludedViewBanner');
|
|
const filterButton = document.getElementById('filterButton');
|
|
const breadcrumbContainer = document.getElementById('breadcrumbContainer');
|
|
const duplicatesBanner = document.getElementById('duplicatesBanner');
|
|
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
|
|
const hiddenSelectors = [
|
|
'[data-action="fetch"]',
|
|
'[data-action="download"]',
|
|
'[data-action="bulk"]',
|
|
'[data-action="find-duplicates"]',
|
|
'#favoriteFilterBtn',
|
|
'.update-filter-group',
|
|
];
|
|
const customFilterIndicator = document.getElementById('customFilterIndicator');
|
|
|
|
document.body.classList.toggle('excluded-view-active', isExcludedView);
|
|
excludedBanner?.classList.toggle('hidden', !isExcludedView);
|
|
breadcrumbContainer?.classList.toggle('hidden', isExcludedView);
|
|
alphabetBarContainer?.classList.toggle('hidden', isExcludedView);
|
|
|
|
if (duplicatesBanner && isExcludedView) {
|
|
duplicatesBanner.style.display = 'none';
|
|
}
|
|
|
|
hiddenSelectors.forEach((selector) => {
|
|
document.querySelectorAll(selector).forEach((element) => {
|
|
element.classList.toggle('hidden', isExcludedView);
|
|
});
|
|
});
|
|
|
|
if (customFilterIndicator && isExcludedView) {
|
|
customFilterIndicator.classList.add('hidden');
|
|
}
|
|
|
|
if (filterButton) {
|
|
filterButton.disabled = isExcludedView;
|
|
filterButton.classList.toggle('hidden', isExcludedView);
|
|
}
|
|
|
|
const activeFiltersCount = document.getElementById('activeFiltersCount');
|
|
if (activeFiltersCount && isExcludedView) {
|
|
activeFiltersCount.style.display = 'none';
|
|
}
|
|
|
|
if (sortSelect) {
|
|
applySortToSelect(this.pageState.sortBy);
|
|
}
|
|
if (searchInput) {
|
|
searchInput.value = this.pageState.filters?.search || '';
|
|
}
|
|
|
|
this.updateActionButtonStates();
|
|
}
|
|
|
|
suspendInteractiveModes() {
|
|
const snapshot = {
|
|
bulkMode: Boolean(state.bulkMode),
|
|
duplicatesMode: Boolean(this.pageState.duplicatesMode),
|
|
};
|
|
|
|
if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) {
|
|
window.bulkManager.toggleBulkMode();
|
|
}
|
|
|
|
if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) {
|
|
window.modelDuplicatesManager.exitDuplicateMode();
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
async restoreInteractiveModes(snapshot = {}) {
|
|
if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) {
|
|
window.bulkManager.toggleBulkMode();
|
|
}
|
|
|
|
if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) {
|
|
return;
|
|
}
|
|
|
|
const duplicatesManager = window.modelDuplicatesManager;
|
|
if (!duplicatesManager) {
|
|
return;
|
|
}
|
|
|
|
if (typeof duplicatesManager.enterDuplicateMode === 'function' &&
|
|
Array.isArray(duplicatesManager.duplicateGroups) &&
|
|
duplicatesManager.duplicateGroups.length > 0) {
|
|
duplicatesManager.enterDuplicateMode();
|
|
return;
|
|
}
|
|
|
|
if (typeof duplicatesManager.findDuplicates === 'function') {
|
|
await duplicatesManager.findDuplicates();
|
|
}
|
|
}
|
|
|
|
syncCustomFilterIndicator() {
|
|
const indicator = document.getElementById('customFilterIndicator');
|
|
if (!indicator) {
|
|
return;
|
|
}
|
|
|
|
if (this.pageState.viewMode === 'excluded') {
|
|
indicator.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
if (typeof this.checkCustomFilters === 'function') {
|
|
this.checkCustomFilters();
|
|
}
|
|
}
|
|
|
|
async enterExcludedView() {
|
|
if (this.pageState.viewMode === 'excluded') {
|
|
return;
|
|
}
|
|
|
|
const interactionSnapshot = this.suspendInteractiveModes();
|
|
|
|
this.pageState.activeViewSnapshot = {
|
|
sortBy: this.pageState.sortBy,
|
|
activeFolder: this.pageState.activeFolder,
|
|
activeLetterFilter: this.pageState.activeLetterFilter ?? null,
|
|
showFavoritesOnly: this.pageState.showFavoritesOnly,
|
|
showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly,
|
|
bulkMode: interactionSnapshot.bulkMode,
|
|
duplicatesMode: interactionSnapshot.duplicatesMode,
|
|
filters: this.cloneFilters(),
|
|
};
|
|
|
|
const excludedState = this.pageState.excludedViewState || {
|
|
sortBy: 'name:asc',
|
|
search: '',
|
|
};
|
|
|
|
this.pageState.viewMode = 'excluded';
|
|
this.pageState.sortBy = excludedState.sortBy || 'name:asc';
|
|
this.pageState.currentPage = 1;
|
|
this.pageState.activeFolder = null;
|
|
this.pageState.activeLetterFilter = null;
|
|
this.pageState.showFavoritesOnly = false;
|
|
this.pageState.showUpdateAvailableOnly = false;
|
|
|
|
this.applyFilterState(this.buildExcludedFilters(excludedState.search || ''));
|
|
this.syncExcludedViewState();
|
|
await this.resetAndReload(false);
|
|
}
|
|
|
|
async exitExcludedView() {
|
|
if (this.pageState.viewMode !== 'excluded') {
|
|
return;
|
|
}
|
|
|
|
this.pageState.excludedViewState = {
|
|
...(this.pageState.excludedViewState || {}),
|
|
sortBy: this.pageState.sortBy,
|
|
search: this.pageState.filters?.search || '',
|
|
};
|
|
|
|
const snapshot = this.pageState.activeViewSnapshot || {};
|
|
this.pageState.viewMode = 'active';
|
|
this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc');
|
|
this.pageState.currentPage = 1;
|
|
this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`);
|
|
this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null;
|
|
this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly);
|
|
this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly);
|
|
this.applyFilterState(snapshot.filters || this.buildExcludedFilters(''));
|
|
this.pageState.activeViewSnapshot = null;
|
|
|
|
this.syncExcludedViewState();
|
|
await this.resetAndReload(true);
|
|
this.syncCustomFilterIndicator();
|
|
await this.restoreInteractiveModes(snapshot);
|
|
}
|
|
|
|
/**
|
|
* Find duplicate models
|
|
*/
|
|
findDuplicates() {
|
|
if (window.modelDuplicatesManager) {
|
|
// Change to toggle functionality
|
|
window.modelDuplicatesManager.toggleDuplicateMode();
|
|
} else {
|
|
console.error('Model duplicates manager not available');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up resources
|
|
*/
|
|
destroy() {
|
|
// Note: We don't destroy the global sidebar manager, just clean it up
|
|
// The global instance will be reused for other page controls
|
|
if (this.sidebarManager && this.sidebarManager.isInitialized) {
|
|
this.sidebarManager.cleanup();
|
|
}
|
|
}
|
|
} |