Add batch selection UI, footer command menus, and helper filters for missing models

This commit is contained in:
Dariusz L
2026-06-05 02:51:23 +02:00
parent c4ee8844d4
commit 4b57d07ffa
2 changed files with 704 additions and 16 deletions
+109 -3
View File
@@ -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 {
+595 -13
View File
@@ -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 {
</div>
</div>
<div class="ml-missing-list-head">
<span class="ml-missing-head-select">
<input type="checkbox" class="ml-missing-select-all-check" aria-label="Select or deselect all missing models">
</span>
<span>#</span>
<span>Missing Model</span>
<span>Type</span>
@@ -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 += `
<div role="button" tabindex="0"
class="ml-missing-list-row ${isSelected ? 'is-selected' : ''}"
class="ml-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''}"
data-missing-key="${this.escapeHtml(key)}">
<span class="ml-missing-row-select">
<input type="checkbox" class="ml-missing-row-check" data-ml-no-drag="1" aria-label="Select ${this.escapeHtml(filename)}" ${isBatchSelected ? 'checked' : ''}>
</span>
<span class="ml-missing-row-index">${index + 1}</span>
<span class="ml-missing-row-model">
<span class="ml-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(formattedFilename.display)}</span>
@@ -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