diff --git a/web/css/linker-main.css b/web/css/linker-main.css index 3603c3a..0ad715a 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -559,7 +559,7 @@ .ml-missing-list-head, #model-linker-modal .ml-missing-list-row { display: grid; - grid-template-columns: 22px minmax(170px, var(--ml-missing-model-col, 280px)) minmax(66px, var(--ml-missing-type-col, 112px)) minmax(150px, 1fr) 58px 106px; + grid-template-columns: 38px 22px minmax(170px, var(--ml-missing-model-col, 280px)) minmax(66px, var(--ml-missing-type-col, 112px)) minmax(150px, 1fr) 58px 106px; align-items: center; gap: 8px; } @@ -603,6 +603,30 @@ background: linear-gradient(90deg, rgba(114,211,138,0.12) 0%, rgba(114,211,138,0.055) 42%, rgba(114,211,138,0.018) 100%); box-shadow: inset 3px 0 0 rgba(114,211,138,0.92), inset 0 0 0 1px rgba(114,211,138,0.12); } +.ml-missing-list-row.is-batch-selected { + background: rgba(78,161,255,0.055); +} +.ml-missing-list-row.is-selected.is-batch-selected { + background: linear-gradient(90deg, rgba(114,211,138,0.13) 0%, rgba(78,161,255,0.065) 58%, rgba(78,161,255,0.025) 100%); +} +.ml-missing-row-select { + display: flex; + justify-content: center; + align-items: center; +} +.ml-missing-head-select { + display: flex; + justify-content: center; + align-items: center; +} +.ml-missing-select-all-check, +.ml-missing-row-check { + width: 14px; + height: 14px; + margin: 0; + accent-color: #72d38a; + cursor: pointer; +} .ml-missing-row-index { display: block; width: 100%; @@ -893,9 +917,14 @@ display: none; } #model-linker-modal .ml-missing-list-row { - grid-template-columns: 18px minmax(0, 1fr); + grid-template-columns: 24px 18px minmax(0, 1fr); gap: 7px; } + .ml-missing-row-select { + grid-row: 1 / span 4; + align-self: start; + margin-top: 1px; + } .ml-missing-row-index { grid-row: 1 / span 4; align-self: start; @@ -907,7 +936,7 @@ .ml-missing-row-best, .ml-missing-row-match, .ml-missing-row-sources { - grid-column: 2; + grid-column: 3; } } @@ -2344,6 +2373,83 @@ line-height: 1; backdrop-filter: none; } +.ml-footer-menu-wrap { + position: relative; + display: inline-flex; +} +.ml-footer-menu-button { + justify-content: center; +} +.ml-footer-menu-button::after { + content: "\25BE"; + margin-left: 7px; + font-size: 10px; + opacity: 0.68; +} +.ml-footer-menu { + display: none; + position: absolute; + right: 0; + bottom: calc(100% + 8px); + min-width: 212px; + max-height: min(420px, calc(100vh - 140px)); + overflow-y: auto; + padding: 5px; + border: 1px solid rgba(255,255,255,0.105); + border-radius: 8px; + background: rgba(31,31,35,0.98); + box-shadow: 0 18px 38px rgba(0,0,0,0.34); + z-index: var(--ml-z-dropdown, 10020); +} +.ml-footer-menu.is-open { + display: grid; + gap: 2px; +} +.ml-footer-menu-item { + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ml-text); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +#model-linker-modal .ml-footer-menu .ml-footer-menu-item { + width: 100%; + min-height: 30px; + margin: 0; + padding: 0 10px; + appearance: none; + -webkit-appearance: none; + border: 0 !important; + border-radius: 6px; + background: transparent !important; + box-shadow: none !important; + color: var(--ml-text); + font: inherit; + font-size: 12px; + line-height: 1.25; + text-align: left; + justify-content: flex-start; +} +.ml-footer-menu-item:hover, +.ml-footer-menu-item:focus-visible { + outline: none; + background: rgba(255,255,255,0.07); +} +#model-linker-modal .ml-footer-menu .ml-footer-menu-item:hover, +#model-linker-modal .ml-footer-menu .ml-footer-menu-item:focus-visible { + outline: none; + background: rgba(255,255,255,0.07) !important; +} +.ml-footer-menu-divider { + height: 1px; + margin: 4px 2px; + background: rgba(255,255,255,0.08); +} .ml-btn.ml-footer-btn, .ml-btn.ml-btn-primary.ml-footer-btn, .ml-btn.ml-btn-download.ml-footer-btn { diff --git a/web/linker.js b/web/linker.js index 3c4fd14..2b986fd 100644 --- a/web/linker.js +++ b/web/linker.js @@ -33,6 +33,10 @@ class LinkerManagerDialog extends ComfyDialog { this.cachedAnalysisData = null; this.cachedWorkflowSignature = null; this.selectedMissingModelKey = null; + this.batchSelectedMissingKeys = new Set(); + this.lastBatchSelectedMissingKey = null; + this.activeFooterMenu = null; + this.batchSearchRunning = false; this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); this.activeTabStorageKey = 'model_linker_active_tab'; this.activeTab = this.restoreActiveTab(); // Default tab @@ -124,6 +128,8 @@ class LinkerManagerDialog extends ComfyDialog { // Add click listener to hide context menu when clicking outside this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e); document.addEventListener('click', this.boundHandleContextMenuClick); + this.boundHandleFooterMenuClick = (e) => this.handleFooterMenuOutsideClick(e); + document.addEventListener('click', this.boundHandleFooterMenuClick); window.addEventListener('resize', this._boundHandleViewportResize); } @@ -2569,6 +2575,211 @@ class LinkerManagerDialog extends ComfyDialog { this.close(); } } + + handleFooterMenuOutsideClick(e) { + if (!this.activeFooterMenu) return; + if (e.target?.closest?.('.ml-footer-menu-wrap')) return; + this.closeFooterMenus(); + } + + closeFooterMenus() { + this.activeFooterMenu = null; + this.footerMenus?.forEach?.((menu, name) => { + menu.classList.remove('is-open'); + const button = this.footerMenuButtons?.get?.(name); + button?.setAttribute('aria-expanded', 'false'); + }); + } + + setMissingFooterControlsVisible(visible) { + const display = visible ? 'inline-flex' : 'none'; + [ + this.selectMenuWrap, + this.searchMenuWrap, + this.queueExactButton, + this.applyPendingBtn, + this.autoResolveButton, + this.downloadMenuWrap + ].forEach(element => { + if (element) element.style.display = display; + }); + if (!visible) { + this.closeFooterMenus(); + } + } + + toggleFooterMenu(name) { + const menu = this.footerMenus?.get?.(name); + const button = this.footerMenuButtons?.get?.(name); + if (!menu || !button) return; + + const shouldOpen = this.activeFooterMenu !== name; + this.closeFooterMenus(); + if (shouldOpen) { + this.activeFooterMenu = name; + menu.classList.add('is-open'); + button.setAttribute('aria-expanded', 'true'); + } + } + + syncBatchSelectionForMissingModels(missingModels = this.missingModels || []) { + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + + const validKeys = new Set(missingModels.map(missing => this.getMissingModelKey(missing))); + for (const key of Array.from(this.batchSelectedMissingKeys)) { + if (!validKeys.has(key)) { + this.batchSelectedMissingKeys.delete(key); + } + } + if (this.lastBatchSelectedMissingKey && !validKeys.has(this.lastBatchSelectedMissingKey)) { + this.lastBatchSelectedMissingKey = null; + } + } + + getSelectedMissingModels() { + const selectedKeys = this.batchSelectedMissingKeys || new Set(); + return (this.missingModels || []).filter(missing => selectedKeys.has(this.getMissingModelKey(missing))); + } + + getMissingWithExactLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.getBestLocalMatch(missing, 100)); + } + + getMissingWithoutExactLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestLocalMatch(missing, 100)); + } + + getMissingWithPartialLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => { + const bestMatch = this.getBestLocalMatch(missing, 70); + return bestMatch && Number(bestMatch.confidence || 0) < 100; + }); + } + + getMissingWithoutLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestLocalMatch(missing, 70)); + } + + getMissingWithDownloadSources(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.getBestDownloadSourceForMissing(missing)); + } + + getMissingWithoutDownloadSources(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestDownloadSourceForMissing(missing)); + } + + getSearchedMissingModels(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.hasRenderableSearchState(this.getSearchState(missing))); + } + + getUnsearchedMissingModels(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing))); + } + + selectBatchMissingModels(mode) { + const missingModels = this.missingModels || []; + if (!missingModels.length) { + this.showNotification('No missing models to select.', 'info'); + return; + } + + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + + if (mode === 'none') { + this.batchSelectedMissingKeys.clear(); + } else if (mode === 'invert') { + const nextSelection = new Set(); + for (const missing of missingModels) { + const key = this.getMissingModelKey(missing); + if (!this.batchSelectedMissingKeys.has(key)) { + nextSelection.add(key); + } + } + this.batchSelectedMissingKeys = nextSelection; + } else { + let models = missingModels; + if (mode === 'exact') { + models = this.getMissingWithExactLocalMatches(missingModels); + } else if (mode === 'no_exact') { + models = this.getMissingWithoutExactLocalMatches(missingModels); + } else if (mode === 'partial') { + models = this.getMissingWithPartialLocalMatches(missingModels); + } else if (mode === 'no_local') { + models = this.getMissingWithoutLocalMatches(missingModels); + } else if (mode === 'downloadable') { + models = this.getMissingWithDownloadSources(missingModels); + } else if (mode === 'no_download') { + models = this.getMissingWithoutDownloadSources(missingModels); + } else if (mode === 'searched') { + models = this.getSearchedMissingModels(missingModels); + } else if (mode === 'unsearched') { + models = this.getUnsearchedMissingModels(missingModels); + } + this.batchSelectedMissingKeys = new Set(models.map(missing => this.getMissingModelKey(missing))); + } + + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + this.closeFooterMenus(); + } + + refreshBatchSelectionUi() { + const selectedKeys = this.batchSelectedMissingKeys || new Set(); + this.contentElement?.querySelectorAll?.('.ml-missing-list-row')?.forEach(row => { + const key = row.getAttribute('data-missing-key'); + const selected = selectedKeys.has(key); + row.classList.toggle('is-batch-selected', selected); + const checkbox = row.querySelector('.ml-missing-row-check'); + if (checkbox) { + checkbox.checked = selected; + } + }); + this.updateBatchSelectAllCheckbox(); + } + + updateBatchSelectAllCheckbox() { + const checkbox = this.contentElement?.querySelector?.('.ml-missing-select-all-check'); + if (!checkbox) return; + + const totalCount = (this.missingModels || []).length; + const selectedCount = this.getSelectedMissingModels().length; + checkbox.checked = totalCount > 0 && selectedCount === totalCount; + checkbox.indeterminate = selectedCount > 0 && selectedCount < totalCount; + checkbox.disabled = totalCount === 0; + } + + setBatchSelectionForKey(key, selected) { + if (!key) return; + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + if (selected) { + this.batchSelectedMissingKeys.add(key); + } else { + this.batchSelectedMissingKeys.delete(key); + } + } + + applyBatchSelectionRange(sortedMissingModels, fromKey, toKey, selected) { + const models = sortedMissingModels || this.missingModels || []; + const fromIndex = models.findIndex(missing => this.getMissingModelKey(missing) === fromKey); + const toIndex = models.findIndex(missing => this.getMissingModelKey(missing) === toKey); + + if (fromIndex < 0 || toIndex < 0) { + this.setBatchSelectionForKey(toKey, selected); + return; + } + + const start = Math.min(fromIndex, toIndex); + const end = Math.max(fromIndex, toIndex); + for (let index = start; index <= end; index += 1) { + this.setBatchSelectionForKey(this.getMissingModelKey(models[index]), selected); + } + } createHeader() { // Create tabs @@ -4242,25 +4453,19 @@ class LinkerManagerDialog extends ComfyDialog { if (this.contentElement) { this.contentElement.style.overflowY = 'auto'; } - this.downloadAllButton.style.display = 'inline-flex'; - this.autoResolveButton.style.display = 'inline-flex'; - this.applyPendingBtn.style.display = 'inline-flex'; + this.setMissingFooterControlsVisible(true); this.loadWorkflowData(); } else if (this.activeTab === 'loaded') { if (this.contentElement) { this.contentElement.style.overflowY = 'auto'; } - this.downloadAllButton.style.display = 'none'; - this.autoResolveButton.style.display = 'none'; - this.applyPendingBtn.style.display = 'none'; + this.setMissingFooterControlsVisible(false); this.loadLoadedModels(); } else { if (this.contentElement) { this.contentElement.style.overflowY = 'hidden'; } - this.downloadAllButton.style.display = 'none'; - this.autoResolveButton.style.display = 'none'; - this.applyPendingBtn.style.display = 'none'; + this.setMissingFooterControlsVisible(false); this.displayOptions(); } } @@ -5038,9 +5243,118 @@ class LinkerManagerDialog extends ComfyDialog { this.setTooltip(this.applyPendingBtn, tooltip); this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty)); this.applyPendingBtn.classList.toggle('ml-btn-is-disabled', isEmpty); + this.updateBatchFooterButtons(); + } + + updateBatchFooterButtons() { + const missingModels = this.missingModels || []; + const selectedCount = this.getSelectedMissingModels().length; + const totalCount = missingModels.length; + const pendingCount = this.pendingResolutions?.length || 0; + const activeCount = Object.keys(this.activeDownloads || {}).length; + const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length; + const downloadableAll = this.getMissingWithDownloadSources(missingModels).length; + + if (this.selectMenuButton) { + this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`; + this.selectMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0); + } + if (this.searchMenuButton) { + this.searchMenuButton.textContent = this.batchSearchRunning ? 'Searching...' : 'Search'; + this.searchMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0 || this.batchSearchRunning); + } + if (this.downloadMenuButton) { + const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download'; + this.downloadMenuButton.textContent = label; + this.downloadMenuButton.classList.toggle('ml-btn-danger', activeCount > 0); + this.downloadMenuButton.classList.toggle('ml-btn-download', activeCount === 0); + this.downloadMenuButton.classList.toggle('ml-btn-is-disabled', activeCount === 0 && downloadableAll === 0); + this.setTooltip( + this.downloadMenuButton, + activeCount > 0 + ? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` + : `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.` + ); + } + if (this.applyPendingBtn) { + this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`; + } + } + + createFooterMenu(name, label, items = [], buttonClass = 'ml-btn-secondary') { + const button = $el(`button.ml-btn.${buttonClass}.ml-footer-btn.ml-footer-menu-button`, { + type: 'button', + 'aria-haspopup': 'menu', + 'aria-expanded': 'false', + onclick: (event) => { + event.stopPropagation(); + if (button.classList.contains('ml-btn-is-disabled')) { + if (name === 'search' && this.batchSearchRunning) { + this.showNotification('Batch search is already running.', 'info'); + } else if (name === 'download') { + this.showNotification('No missing models have downloadable sources yet.', 'info'); + } else { + this.showNotification('No missing models available.', 'info'); + } + return; + } + this.toggleFooterMenu(name); + } + }, [$el("span", { textContent: label })]); + + const menu = $el("div.ml-footer-menu", { role: 'menu' }, items.map(item => ( + item === 'divider' + ? $el("div.ml-footer-menu-divider", {}) + : $el("button.ml-footer-menu-item", { + type: 'button', + role: 'menuitem', + onclick: (event) => { + event.stopPropagation(); + item.action(); + } + }, [$el("span", { textContent: item.label })]) + ))); + + this.footerMenuButtons.set(name, button); + this.footerMenus.set(name, menu); + + return $el("div.ml-footer-menu-wrap", {}, [button, menu]); } createFooter() { + this.footerMenus = new Map(); + this.footerMenuButtons = new Map(); + + const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [ + { label: 'Select All', action: () => this.selectBatchMissingModels('all') }, + { label: 'Select None', action: () => this.selectBatchMissingModels('none') }, + { label: 'Invert Selection', action: () => this.selectBatchMissingModels('invert') }, + 'divider', + { label: 'Select Exact Local', action: () => this.selectBatchMissingModels('exact') }, + { label: 'Select No Exact Local', action: () => this.selectBatchMissingModels('no_exact') }, + { label: 'Select Partial Local', action: () => this.selectBatchMissingModels('partial') }, + { label: 'Select No Local Match', action: () => this.selectBatchMissingModels('no_local') }, + 'divider', + { label: 'Select With Download Source', action: () => this.selectBatchMissingModels('downloadable') }, + { label: 'Select Without Download Source', action: () => this.selectBatchMissingModels('no_download') }, + 'divider', + { label: 'Select Searched', action: () => this.selectBatchMissingModels('searched') }, + { label: 'Select Unsearched', action: () => this.selectBatchMissingModels('unsearched') } + ]); + this.selectMenuButton = this.footerMenuButtons.get('select'); + this.selectMenuWrap = selectMenu; + + const searchMenu = this.createFooterMenu('search', 'Search', [ + { label: 'Search Selected', action: () => this.searchMissingBatch('selected', 'all') }, + { label: 'Search All Missing', action: () => this.searchMissingBatch('all', 'all') }, + { label: 'Search Unsearched', action: () => this.searchMissingBatch('unsearched', 'all') }, + 'divider', + { label: 'Selected: CivitAI', action: () => this.searchMissingBatch('selected', 'civitai') }, + { label: 'Selected: HuggingFace', action: () => this.searchMissingBatch('selected', 'huggingface') } + ]); + this.searchMenuButton = this.footerMenuButtons.get('search'); + this.searchMenuWrap = searchMenu; + // Store reference to download all button so we can update its text this.downloadAllButton = $el("button.ml-btn.ml-btn-download.ml-footer-btn", { "data-tooltip": "Download every missing model that has a known download source.", @@ -5060,6 +5374,12 @@ class LinkerManagerDialog extends ComfyDialog { $el("span.ml-btn-icon", { textContent: "🔗" }), $el("span", { textContent: " Auto-Link 100%" }) ]); + this.queueExactButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { + type: 'button', + onclick: () => this.queueExactLocalMatchesBatch('selected') + }, [ + $el("span", { textContent: "Queue Exact" }) + ]); // Apply pending resolutions button this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary.ml-footer-btn", { @@ -5076,17 +5396,34 @@ class LinkerManagerDialog extends ComfyDialog { this.applyPendingBtn.classList.add('ml-btn-is-disabled'); this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); this.autoResolveButton.setAttribute('aria-label', 'Auto-link all 100 percent local matches'); + this.queueExactButton.setAttribute('aria-label', 'Queue exact local matches'); this.applyPendingBtn.setAttribute('aria-label', 'Apply selected model links'); this.applyPendingBtn.setAttribute('aria-disabled', 'true'); this.setTooltip(this.downloadAllButton, 'Download every missing model that has a known download source.'); this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.'); + this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.'); this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.'); + + const downloadMenu = this.createFooterMenu('download', 'Download', [ + { label: 'Download Selected', action: () => this.downloadMissingBatch('selected') }, + { label: 'Download All With Sources', action: () => this.downloadMissingBatch('all') }, + 'divider', + { label: 'Cancel Downloads', action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } + ], 'ml-btn-download'); + this.downloadMenuButton = this.footerMenuButtons.get('download'); + this.downloadMenuWrap = downloadMenu; + this.downloadAllButton = this.downloadMenuButton; + this.downloadAllButton.setAttribute('aria-label', 'Download missing models'); + this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.'); return $el("div.ml-footer", {}, [ - this.autoResolveButton, + selectMenu, + searchMenu, + this.queueExactButton, this.applyPendingBtn, - this.downloadAllButton + this.autoResolveButton, + downloadMenu ]); } @@ -5147,6 +5484,10 @@ class LinkerManagerDialog extends ComfyDialog { */ updateDownloadAllButtonState() { if (!this.downloadAllButton) return; + if (this.downloadMenuButton && this.downloadAllButton === this.downloadMenuButton) { + this.updateBatchFooterButtons(); + return; + } const activeCount = Object.keys(this.activeDownloads).length; if (activeCount > 0) { @@ -5837,6 +6178,9 @@ class LinkerManagerDialog extends ComfyDialog {
+ + + # Missing Model Type @@ -5850,6 +6194,7 @@ class LinkerManagerDialog extends ComfyDialog { missingModels.forEach((missing, index) => { const key = this.getMissingModelKey(missing); const isSelected = key === selectedKey; + const isBatchSelected = this.batchSelectedMissingKeys?.has(key); const filename = this.getMissingFilename(missing); const formattedFilename = this.formatFilename(filename, 46); const bestMatch = this.getBestLocalMatch(missing, 70); @@ -5867,8 +6212,11 @@ class LinkerManagerDialog extends ComfyDialog { html += `
+ + + ${index + 1} ${this.escapeHtml(formattedFilename.display)} @@ -5908,7 +6256,53 @@ class LinkerManagerDialog extends ComfyDialog { this.displayMissingModels(container, data); }; + const selectAllCheckbox = container.querySelector('.ml-missing-select-all-check'); + if (selectAllCheckbox) { + this.updateBatchSelectAllCheckbox(); + selectAllCheckbox.addEventListener('click', (event) => { + event.stopPropagation(); + }); + selectAllCheckbox.addEventListener('change', () => { + const shouldSelectAll = selectAllCheckbox.checked; + this.batchSelectedMissingKeys = shouldSelectAll + ? new Set((sortedMissingModels || []).map(missing => this.getMissingModelKey(missing))) + : new Set(); + this.lastBatchSelectedMissingKey = null; + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + }); + } + container.querySelectorAll('.ml-missing-list-row').forEach(row => { + const checkbox = row.querySelector('.ml-missing-row-check'); + if (checkbox) { + checkbox.addEventListener('click', (event) => { + event.stopPropagation(); + checkbox.dataset.shiftClick = event.shiftKey ? '1' : '0'; + }); + checkbox.addEventListener('change', (event) => { + const key = row.dataset.missingKey; + if (!key) return; + const selected = checkbox.checked; + const isShiftRange = event.shiftKey || checkbox.dataset.shiftClick === '1'; + + if (isShiftRange && this.lastBatchSelectedMissingKey) { + this.applyBatchSelectionRange( + sortedMissingModels, + this.lastBatchSelectedMissingKey, + key, + selected + ); + } else { + this.setBatchSelectionForKey(key, selected); + } + + this.lastBatchSelectedMissingKey = key; + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + }); + } + row.addEventListener('click', (event) => { const clickedLocate = event.target instanceof Element && event.target.closest('.ml-missing-row-locate'); if (clickedLocate) return; @@ -6205,6 +6599,7 @@ class LinkerManagerDialog extends ComfyDialog { const missingModels = data.missing_models || []; const totalMissing = data.total_missing || 0; this.missingModels = missingModels; + this.syncBatchSelectionForMissingModels(missingModels); // Check if there are active downloads const activeCount = Object.keys(this.activeDownloads).length; @@ -6215,13 +6610,14 @@ class LinkerManagerDialog extends ComfyDialog { ); // Show/hide Auto-Link button based on whether 100% matches exist + this.setMissingFooterControlsVisible(totalMissing > 0 || activeCount > 0); if (this.autoResolveButton) { this.autoResolveButton.style.display = hasAny100Match ? 'inline-flex' : 'none'; } // Hide download all button if no missing models if (this.downloadAllButton) { - this.downloadAllButton.style.display = totalMissing > 0 ? 'inline-flex' : 'none'; + this.downloadAllButton.style.display = (totalMissing > 0 || activeCount > 0) ? 'inline-flex' : 'none'; } if (totalMissing === 0 && activeCount === 0) { @@ -6291,6 +6687,7 @@ class LinkerManagerDialog extends ComfyDialog { ); this.wireMissingModelsBrowser(container, data, sortedMissingModels); this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data); + this.updateBatchFooterButtons(); } renderMissingModel(missing, missingIndex = 0) { @@ -6739,6 +7136,191 @@ class LinkerManagerDialog extends ComfyDialog { } } + getBestDownloadSourceForMissing(missing) { + if (!missing) return null; + + if (missing.download_source?.url) { + return { + ...missing.download_source, + directory: missing.download_source.directory || missing.download_source.category || missing.category || 'checkpoints' + }; + } + + const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); + const results = state?.results || {}; + const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || ''; + const first = (value) => Array.isArray(value) ? value[0] : value; + const candidates = [ + { + source: 'popular', + result: first(results.popular), + urlKey: 'url', + filenameKey: 'filename', + categoryKey: 'directory' + }, + { + source: 'model_list', + result: first(results.model_list), + urlKey: 'url', + filenameKey: 'filename', + categoryKey: 'directory' + }, + { + source: 'huggingface', + result: first(results.huggingface), + urlKey: 'url', + filenameKey: 'filename' + }, + { + source: 'civarchive', + result: first(results.civarchive), + urlKey: 'download_url', + filenameKey: 'filename' + }, + { + source: 'lora_manager_archive', + result: first(results.lora_manager_archive), + urlKey: 'download_url', + filenameKey: 'filename' + }, + { + source: 'civitai', + result: first(results.civitai), + urlKey: 'download_url', + filenameKey: 'filename' + } + ]; + + for (const candidate of candidates) { + const result = candidate.result; + const url = result?.[candidate.urlKey]; + if (!url) continue; + + return { + source: candidate.source, + url, + filename: result[candidate.filenameKey] || missing.civitai_info?.expected_filename || filename, + directory: result[candidate.categoryKey] || result.directory || result.category || missing.category || 'checkpoints', + model_url: result.url || result.model_url || this.getModelCardUrl(url), + name: result.name || result.repo_id || result.repo || result.filename || filename, + size: result.size, + type: result.type || missing.category + }; + } + + return null; + } + + getBatchSearchTargets(mode) { + const missingModels = this.missingModels || []; + if (mode === 'selected') { + return this.getSelectedMissingModels(); + } + if (mode === 'unsearched') { + return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing))); + } + return missingModels; + } + + async searchMissingBatch(mode = 'selected', source = 'all') { + if (this.batchSearchRunning) { + this.showNotification('Batch search is already running.', 'info'); + return; + } + + const targets = this.getBatchSearchTargets(mode); + if (!targets.length) { + this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to search.', 'info'); + this.closeFooterMenus(); + return; + } + + this.batchSearchRunning = true; + this.updateBatchFooterButtons(); + this.closeFooterMenus(); + this.showNotification(`Searching ${targets.length} missing model${targets.length > 1 ? 's' : ''}...`, 'info'); + + let completed = 0; + let failed = 0; + try { + for (const missing of targets) { + const state = this.getSearchState(missing); + state.selectedSource = source || 'all'; + try { + await this.searchOnline(missing); + } catch (error) { + failed += 1; + console.error('Model Linker: batch search item failed:', error); + } + completed += 1; + this.updateBatchFooterButtons(); + } + } finally { + this.batchSearchRunning = false; + this.persistSearchStateForActiveWorkflow(); + this.updateBatchFooterButtons(); + } + + const suffix = failed ? `, ${failed} failed` : ''; + this.showNotification(`Finished search for ${completed} model${completed === 1 ? '' : 's'}${suffix}.`, failed ? 'error' : 'success'); + } + + async downloadMissingBatch(mode = 'selected') { + const targets = mode === 'selected' + ? this.getSelectedMissingModels() + : (this.missingModels || []); + + if (!targets.length) { + this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to download.', 'info'); + this.closeFooterMenus(); + return; + } + + const toDownload = []; + for (const missing of targets) { + if (this.getBestLocalMatch(missing, 100)) continue; + const source = this.getBestDownloadSourceForMissing(missing); + if (!source?.url) continue; + missing.download_source = source; + toDownload.push(missing); + } + + if (!toDownload.length) { + this.showNotification('No selected models have downloadable sources.', 'info'); + this.closeFooterMenus(); + return; + } + + this.closeFooterMenus(); + this.showNotification(`Starting ${toDownload.length} download${toDownload.length > 1 ? 's' : ''}...`, 'info'); + for (const missing of toDownload) { + await this.downloadModel(missing); + } + this.updateDownloadAllButtonState(); + this.updateBatchFooterButtons(); + } + + queueExactLocalMatchesBatch(mode = 'selected') { + const selected = this.getSelectedMissingModels(); + const targets = mode === 'selected' && selected.length ? selected : (this.missingModels || []); + let queued = 0; + + for (const missing of targets) { + const match = this.getBestLocalMatch(missing, 100); + if (!match?.model) continue; + this.queueResolution(missing, match.model); + queued += 1; + } + + this.closeFooterMenus(); + if (!queued) { + this.showNotification('No exact local matches found to queue.', 'info'); + return; + } + this.showNotification(`Queued ${queued} exact local match${queued === 1 ? '' : 'es'}.`, 'success'); + this.updateBatchFooterButtons(); + } + /** * Auto-resolve a model after download completes * Reloads the workflow analysis and resolves if the downloaded model is found