// ****** ComfyUI-AF-Find Nodes ******
//
// Creator: Alex Furer - Co-Creator(s): Claude AI & QWEN3 Coder & DeepSeek
//
// Description: A ComfyUI utility extension for finding nodes by ID, title, pack, or type in workflows.
//
// Repo: https://github.com/alFrame/ComfyUI-AF-Find-Nodes/
//
// Issues, praise, comment, bugs, improvements: https://github.com/alFrame/ComfyUI-AF-Find-Nodes/issues
//
// LICENSE: GNU General Public License v3.0 (GPL-3.0-or-later)
//
// Usage: https://github.com/alFrame/ComfyUI-AF-Find-Nodes/blob/main/README.md
//
// Feature Requests / Wet Dreams
// -
/*
* Copyright (C) 2025 Alex Furer
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
// Prevent double initialization
if (window.AF_Find_Nodes_Widget) {
// console.log("AF - Find Nodes already loaded, skipping initialization");
} else {
class AF_Find_Nodes_Widget {
constructor() {
this.searchPanel = null;
this.isVisible = false;
this.highlightedNode = null;
this.originalNodeColors = new Map();
this.searchHistory = [];
this.maxHistory = 10;
this.inspectorMode = false;
this.searchTimeout = null;
this.experimentalEnabled = localStorage.getItem('af-find-node-experimental') === 'true';
this.workflowPackIndex = {};
this.workflowTypeIndex = {};
this.lastWorkflowSignature = null;
this.scanCompleted = false;
this.currentTab = localStorage.getItem('af-find-node-last-tab') || 'id';
this.tabInputs = {};
this.tabHistory = {
id: JSON.parse(localStorage.getItem('af-find-node-history-id') || '[]'),
title: JSON.parse(localStorage.getItem('af-find-node-history-title') || '[]'),
pack: JSON.parse(localStorage.getItem('af-find-node-history-pack') || '[]'),
type: JSON.parse(localStorage.getItem('af-find-node-history-type') || '[]')
};
// Add workflow change monitoring WITHOUT overriding anything
// NOTE: Automatic monitoring disabled - scanning happens on-demand only
this.lastNodeCount = 0;
this.selectionInterval = null;
this.lastSelectedNodes = [];
// Updates tab state
this.updateableNodes = [];
this.selectedUpdates = new Set();
this.autoBackupEnabled = localStorage.getItem('af-find-node-auto-backup') !== 'false';
this.collapsedPacks = new Set();
this.collapsedStatsPacks = new Set(); // Track collapsed packs in Stats tab
// Keyboard shortcut - read from ComfyUI settings, fallback to default
this.keyboardShortcut = app.extensionManager?.setting.get("AF.FindNodes.keyboardShortcut") ?? "Ctrl+Shift+F";
}
setupSelectionMonitor() {
if (this.inspectorMode && !this.selectionInterval) {
this.selectionInterval = setInterval(() => {
this.checkNodeSelection();
}, 300);
} else if (!this.inspectorMode && this.selectionInterval) {
clearInterval(this.selectionInterval);
this.selectionInterval = null;
this.lastSelectedNodes = [];
}
}
checkNodeSelection() {
if (!this.inspectorMode || !app.canvas || !app.canvas.selected_nodes) return;
const currentSelected = app.canvas.selected_nodes;
if (!currentSelected || Object.keys(currentSelected).length === 0) return;
// Get the first selected node
const nodeId = Object.keys(currentSelected)[0];
const node = currentSelected[nodeId];
if (node && !this.lastSelectedNodes.includes(node.id)) {
this.AF_Find_Nodes_HandleNodeClick(node);
this.lastSelectedNodes = [node.id];
}
}
// ========== WORKFLOW CHANGE DETECTION (KISS) ==========
getCurrentWorkflowSignature() {
if (!app.graph?.nodes) return null;
const nodeIds = app.graph.nodes.map(n => n.id).sort();
return nodeIds.join('_') + '_' + app.graph.nodes.length;
}
isWorkflowChanged() {
const currentSig = this.getCurrentWorkflowSignature();
return currentSig !== this.lastWorkflowSignature;
}
invalidateScanCache() {
this.workflowPackIndex = {};
this.workflowTypeIndex = {};
this.lastWorkflowSignature = null;
this.scanCompleted = false;
}
// DEPRECATED: Automatic workflow monitoring disabled
// Scanning now happens on-demand only when tabs are opened
// This prevents cross-tab contamination issues
startWorkflowMonitor() {
// No longer starts automatic monitoring
return;
/* DISABLED CODE - kept for reference
if (this.workflowMonitorInterval) return;
this.workflowMonitorInterval = setInterval(() => {
if (!app.graph || !app.graph.nodes) return;
const currentCount = app.graph.nodes.length;
// Check if workflow changed significantly (more than just selection changes)
if (Math.abs(currentCount - this.lastNodeCount) > 0) {
this.lastNodeCount = currentCount;
// Debounce the scan
clearTimeout(this.workflowScanTimeout);
this.workflowScanTimeout = setTimeout(() => {
this.scanWorkflowForPacks();
}, 1000);
}
}, 2000); // Check every 2 seconds - very conservative
*/ // END DISABLED CODE
}
// DEPRECATED: No longer needed with on-demand scanning
stopWorkflowMonitor() {
// No-op - monitoring is disabled
return;
/* DISABLED CODE
if (this.workflowMonitorInterval) {
clearInterval(this.workflowMonitorInterval);
this.workflowMonitorInterval = null;
}
if (this.workflowScanTimeout) {
clearTimeout(this.workflowScanTimeout);
this.workflowScanTimeout = null;
}
*/ // END DISABLED CODE
}
createSearchPanel() {
// Create main container
const panel = document.createElement('div');
panel.id = 'af-find-nodes-panel';
panel.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: #2a2a2a;
border: 1px solid #555;
border-radius: 8px;
padding: 15px;
z-index: 10000;
font-family: monospace;
font-size: 12px;
color: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
width: 500px; /* Fixed width */
display: none;
`;
// Title bar
const titleBar = document.createElement('div');
titleBar.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 1px solid #555;
padding-bottom: 8px;
`;
const title = document.createElement('div');
title.textContent = 'AF - Find Nodes';
title.style.fontWeight = 'bold';
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.style.cssText = `
background: #ff4444;
border: none;
color: white;
width: 20px;
height: 20px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
line-height: 1;
`;
closeBtn.onclick = () => this.AF_Find_Nodes_HidePanel();
titleBar.appendChild(title);
titleBar.appendChild(closeBtn);
// Create tab container
const tabContainer = document.createElement('div');
tabContainer.style.cssText = `
display: flex;
margin-bottom: 10px;
border-bottom: 1px solid #555;
`;
// Define tabs
this.tabs = [
{ id: 'id', name: '🔍 By ID', placeholder: 'Enter node ID (e.g., 42)' },
{ id: 'title', name: '📛 By Title', placeholder: 'Search node titles, colors, cnr_id, aux_id...' },
{ id: 'pack', name: '📦 By Pack', placeholder: 'Search node packs (e.g., rgthree, WAS, efficiency)' },
{ id: 'type', name: '🔎 By Type', placeholder: 'Search node types (e.g., KSampler, CLIPTextEncode)' },
{ id: 'stats', name: '📊 Stats', placeholder: 'Workflow statistics and pack overview' },
{ id: 'updates', name: '🔄 Updates', placeholder: 'Check for node version updates' }
];
this.tabInputs = {};
// Create tab buttons
this.tabs.forEach(tab => {
const tabBtn = document.createElement('button');
tabBtn.textContent = tab.name;
tabBtn.dataset.tab = tab.id;
const isExperimental = tab.id === 'pack' || tab.id === 'type';
const isStats = tab.id === 'stats' || tab.id === 'updates';
const isActive = tab.id === this.currentTab;
tabBtn.style.cssText = `
flex: 1;
padding: 6px 4px;
background: ${isActive ? (isExperimental ? '#7a4a1a' : (isStats ? '#2a4a7a' : '#555')) : (isExperimental ? '#5a2a0a' : (isStats ? '#1a2a5a' : '#333'))};
border: none;
border-bottom: ${isActive ? (isExperimental ? '2px solid #ff9800' : (isStats ? '2px solid #4da6ff' : '2px solid #4CAF50')) : 'none'};
border-right: 1px solid #666;
color: white;
cursor: pointer;
font-size: 10px;
border-radius: 0;
opacity: ${isExperimental && !this.experimentalEnabled ? '0.6' : '1'};
`;
// Remove right border from last tab
if (tab.id === 'updates') {
tabBtn.style.borderRight = 'none';
}
tabBtn.onclick = () => {
if ((tab.id === 'pack' || tab.id === 'type') && !this.experimentalEnabled) {
this.AF_Find_Nodes_UpdateResults('Experimental features are disabled. Enable them in settings below.', true);
return;
}
this.switchTab(tab.id);
};
tabContainer.appendChild(tabBtn);
});
// Add experimental features toggle section
const experimentalSection = document.createElement('div');
experimentalSection.style.cssText = `
border-top: 1px solid #555;
padding: 12px 0;
font-size: 10px;
`;
const experimentalLabel = document.createElement('label');
experimentalLabel.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
color: #ff9800;
cursor: pointer;
`;
const experimentalCheckbox = document.createElement('input');
experimentalCheckbox.type = 'checkbox';
experimentalCheckbox.checked = this.experimentalEnabled;
experimentalCheckbox.style.cssText = `
margin: 0;
`;
const experimentalText = document.createElement('span');
experimentalText.innerHTML = 'Enable experimental features (may not work consistently)';
experimentalCheckbox.onchange = (e) => {
this.experimentalEnabled = e.target.checked;
localStorage.setItem('af-find-node-experimental', this.experimentalEnabled.toString());
// Update tab appearances
this.updateTabAppearance();
if (!this.experimentalEnabled && (this.currentTab === 'pack' || this.currentTab === 'type')) {
this.switchTab('id'); // Switch to safe tab if experimental was active
}
this.AF_Find_Nodes_UpdateResults(
this.experimentalEnabled ?
'Experimental features enabled. Use with caution!' :
'Experimental features disabled.'
);
};
experimentalLabel.appendChild(experimentalCheckbox);
experimentalLabel.appendChild(experimentalText);
experimentalSection.appendChild(experimentalLabel);
// Create tab-specific input
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = this.tabs[0].placeholder;
searchInput.id = 'af-find-nodes-input';
searchInput.style.cssText = `
width: 100%;
padding: 6px;
margin-bottom: 8px;
background: #1a1a1a;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
font-family: monospace;
`;
// Store reference to main input
this.tabInputs.main = searchInput;
// Search input section
const searchSection = document.createElement('div');
searchSection.style.marginBottom = '10px';
searchInput.placeholder = this.tabs[0].placeholder;
searchInput.id = 'af-find-nodes-input';
;
const buttonRow = document.createElement('div');
buttonRow.style.cssText = `
display: flex;
gap: 8px;
margin-bottom: 10px;
`;
const searchBtn = document.createElement('button');
searchBtn.textContent = 'Find';
searchBtn.style.cssText = `
flex: 1;
padding: 6px;
background: #4CAF50;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 11px;
`;
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear';
clearBtn.style.cssText = `
flex: 1;
padding: 6px;
background: #666;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 11px;
`;
const inspectorBtn = document.createElement('button');
inspectorBtn.textContent = 'Inspector';
inspectorBtn.id = 'af-find-nodes-inspector-toggle';
inspectorBtn.style.cssText = `
flex: 1;
padding: 6px;
background: #2196F3;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 11px;
display: ${this.currentTab === 'id' ? 'block' : 'none'};
`;
// Results section
const resultsSection = document.createElement('div');
resultsSection.id = 'af-find-nodes-results';
resultsSection.style.cssText = `
max-height: 70vh; /* Use viewport height instead of fixed calculation */
overflow-y: auto;
background: #1a1a1a;
border: 1px solid #555;
border-radius: 4px;
padding: 8px;
margin-bottom: 10px;
`;
const statusSection = document.createElement('div');
statusSection.id = 'af-find-nodes-status';
statusSection.style.cssText = `
font-size: 11px;
color: #aaa;
margin-bottom: 8px;
min-height: 14px;
`;
// History section
const historySection = document.createElement('div');
historySection.style.cssText = `
border-top: 1px solid #555;
padding-top: 8px;
`;
const historyTitle = document.createElement('div');
historyTitle.textContent = 'Recent Searches:';
historyTitle.style.cssText = `
font-size: 10px;
color: #aaa;
margin-bottom: 5px;
`;
const historyList = document.createElement('div');
historyList.id = 'af-find-nodes-history';
historyList.style.cssText = `
display: flex;
flex-wrap: wrap;
gap: 4px;
`;
// Create separate sections for star widget and support info
const starWidgetSection = document.createElement('div');
starWidgetSection.id = 'af-find-nodes-star-widget';
starWidgetSection.style.cssText = `
border-top: 1px solid #555;
padding-top: 8px;
margin-top: 8px;
display: ${localStorage.getItem('af-find-nodes-starred') ? 'none' : 'block'};
`;
const supportSection = document.createElement('div');
supportSection.id = 'af-find-nodes-support';
supportSection.style.cssText = `
border-top: 1px solid #555;
padding-top: 8px;
margin-top: 8px;
display: block; // Always visible
`;
// Star widget (can be hidden)
const starWidget = document.createElement('div');
starWidget.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #ccc;
cursor: pointer;
margin-bottom: 8px;
`;
const starIcon = document.createElement('span');
starIcon.textContent = '⭐';
starIcon.style.fontSize = '12px';
const starText = document.createElement('span');
starText.innerHTML = 'Please consider giving a star if you find this helpful';
starWidget.appendChild(starIcon);
starWidget.appendChild(starText);
starWidget.onclick = () => {
// Open GitHub in new tab
window.open('https://github.com/alFrame/ComfyUI-AF-Find-Nodes', '_blank');
// Hide only the star widget, not the support section
starWidgetSection.style.display = 'none';
localStorage.setItem('af-find-nodes-starred', 'true');
};
// Support info (always visible)
const supportText = document.createElement('div');
supportText.innerHTML = 'If you encounter any issues, please post them here';
// Assemble the sections
starWidgetSection.appendChild(starWidget);
supportSection.appendChild(supportText);
// Event listeners
searchBtn.onclick = () => this.AF_Find_Nodes_Search();
clearBtn.onclick = () => this.AF_Find_Nodes_ClearAll();
inspectorBtn.onclick = () => {
if (this.currentTab === 'id') {
this.AF_Find_Nodes_ToggleInspector();
} else {
this.AF_Find_Nodes_UpdateResults('Inspector mode is only available in "By ID" tab.', true);
}
};
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.AF_Find_Nodes_Search();
}
});
// Add debouncing to the search input
searchInput.addEventListener('input', (e) => {
// Only auto-search for text-based tabs, not ID tab
if (this.currentTab !== 'id') {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
if (e.target.value.trim().length >= 2) { // Only search if 2+ characters
this.AF_Find_Nodes_Search();
}
}, 300); // Wait 300ms after typing stops
}
});
// Assemble the panel
buttonRow.appendChild(searchBtn);
buttonRow.appendChild(clearBtn);
buttonRow.appendChild(inspectorBtn);
searchSection.appendChild(tabContainer);
searchSection.appendChild(searchInput);
searchSection.appendChild(buttonRow);
historySection.appendChild(historyTitle);
historySection.appendChild(historyList);
panel.appendChild(titleBar);
panel.appendChild(searchSection);
panel.appendChild(statusSection);
panel.appendChild(resultsSection);
panel.appendChild(experimentalSection);
panel.appendChild(historySection);
panel.appendChild(starWidgetSection);
panel.appendChild(supportSection);
document.body.appendChild(panel);
this.searchPanel = panel;
// Update results initially
this.AF_Find_Nodes_UpdateResults('Ready to search. Enter a node ID or use Inspector mode.');
}
switchTab(tabId) {
if ((tabId === 'pack' || tabId === 'type') && !this.experimentalEnabled) {
this.AF_Find_Nodes_UpdateResults('Experimental features are disabled. Enable them below.', true);
return;
}
this.currentTab = tabId;
// Save to localStorage
localStorage.setItem('af-find-node-last-tab', tabId);
// Update tab buttons
this.updateTabAppearance();
// Update input placeholder
const tabConfig = this.tabs.find(t => t.id === tabId);
this.tabInputs.main.placeholder = tabConfig.placeholder;
// Show/hide inspector button based on tab
this.updateInspectorButtonVisibility();
// Hide search input and entire button row for stats/updates tabs
const searchInput = document.getElementById('af-find-nodes-input');
const buttonRow = searchInput?.nextElementSibling; // The div containing Find/Clear/Inspector buttons
if (tabId === 'stats' || tabId === 'updates') {
if (searchInput) searchInput.style.display = 'none';
if (buttonRow) buttonRow.style.display = 'none';
} else {
if (searchInput) searchInput.style.display = 'block';
if (buttonRow) buttonRow.style.display = 'flex';
// Update inspector button visibility for other tabs
this.updateInspectorButtonVisibility();
}
// Clear previous results, input, and highlight (but not for stats/updates)
if (tabId !== 'stats' && tabId !== 'updates') {
this.tabInputs.main.value = '';
this.AF_Find_Nodes_ClearHighlight();
}
// Check for workflow changes before showing tab content
if (tabId === 'pack' || tabId === 'type' || tabId === 'stats') {
if (this.isWorkflowChanged()) {
this.invalidateScanCache();
console.log('AF-Find-Nodes: Workflow changed - cache invalidated');
}
}
// Handle tab-specific content
if (tabId === 'stats') {
this.showWorkflowStats();
} else if (tabId === 'updates') {
this.showUpdatesTab();
this.AF_Find_Nodes_UpdateResults('Ready to scan for node updates.');
} else if (tabId === 'pack' || tabId === 'type') {
// Show scan prompt if not scanned
if (!this.scanCompleted) {
this.showScanPrompt(tabId);
} else {
this.showResultsList([], '');
this.AF_Find_Nodes_UpdateResults(`Switched to ${tabConfig.name} search. Ready to search.`);
}
} else {
this.showResultsList([], ''); // Clear the results list
this.AF_Find_Nodes_UpdateResults(`Switched to ${tabConfig.name} search. Ready to search.`);
}
// Update history for current tab (not for stats)
if (tabId !== 'stats' && tabId !== 'updates') {
this.AF_Find_Nodes_UpdateHistory();
}
// Hide/show history section - find the parent div that contains both title and list
const historyList = document.getElementById('af-find-nodes-history');
if (historyList && historyList.parentElement) {
historyList.parentElement.style.display = (tabId === 'stats' || tabId === 'updates') ? 'none' : 'block';
}
}
// Show scan prompt for Pack/Type tabs when workflow not scanned
showScanPrompt(tabId) {
const results = document.getElementById('af-find-nodes-results');
const tabName = tabId === 'pack' ? 'Pack' : 'Type';
results.innerHTML = `
⚠️ Workflow not scanned
Click the button below to scan your workflow and index all ${tabName.toLowerCase()}s.
This will enable fast searching.
`;
this.AF_Find_Nodes_UpdateResults(`Click "Scan Workflow" to index your workflow for ${tabName} search.`);
}
// Perform workflow scan (user-triggered)
performWorkflowScan() {
const results = document.getElementById('af-find-nodes-results');
results.innerHTML = '⏳ Scanning workflow...
';
// Use setTimeout to show the loading message
setTimeout(() => {
this.scanWorkflowForPacks();
if (this.scanCompleted) {
const packCount = Object.keys(this.workflowPackIndex).length;
const typeCount = Object.keys(this.workflowTypeIndex).length;
const totalNodes = app.graph?.nodes?.length || 0;
results.innerHTML = `
✅ Scan Complete!
Indexed ${totalNodes} nodes
Found ${packCount} packs and ${typeCount} node types
You can now search by pack or type.
`;
this.AF_Find_Nodes_UpdateResults(`✅ Indexed ${packCount} packs and ${typeCount} types from ${totalNodes} nodes.`);
console.log(`AF-Find-Nodes: Scan complete - ${packCount} packs, ${typeCount} types, ${totalNodes} nodes`);
} else {
results.innerHTML = `
❌ Scan failed - No workflow loaded
`;
this.AF_Find_Nodes_UpdateResults('No workflow loaded.', true);
}
}, 100);
}
showWorkflowStats() {
// Check if workflow needs scanning
if (!this.scanCompleted) {
const results = document.getElementById('af-find-nodes-results');
results.innerHTML = `
⚠️ Workflow not scanned
Click the button below to scan your workflow and generate statistics.
`;
this.AF_Find_Nodes_UpdateResults('Click "Scan Workflow" to generate workflow statistics.');
return;
}
const totalNodes = app.graph?.nodes?.length || 0;
const packCount = Object.keys(this.workflowPackIndex).length;
const typeCount = Object.keys(this.workflowTypeIndex).length;
// Organize packs by node count
const allPacks = Object.entries(this.workflowPackIndex)
.map(([pack, nodeIds]) => ({
pack,
count: nodeIds.length,
percentage: Math.round((nodeIds.length / totalNodes) * 100),
isCore: pack === 'Core' || pack.toLowerCase().includes('core'),
nodeIds: nodeIds
}));
const corePacks = allPacks.filter(p => p.isCore);
const customPacks = allPacks.filter(p => !p.isCore);
const sortedCustomPacks = customPacks.sort((a, b) => b.count - a.count);
const sortedAllPacks = [...sortedCustomPacks, ...corePacks];
// Build stats HTML
let statsHTML = `
📊 Workflow Statistics
Total Nodes:
${totalNodes}
Unique Packs:
${packCount}
Custom Packs:
${customPacks.length}
Unique Node Types:
${typeCount}
⚡ Quick Export
Copy lists for documentation
📦 Node Packs (${sortedAllPacks.length}) - Click to expand
`;
// Build collapsible pack list
sortedAllPacks.forEach(({ pack, count, percentage, isCore, nodeIds }) => {
const isCollapsed = this.collapsedStatsPacks ? !this.collapsedStatsPacks.has(pack) : true;
statsHTML += `
${isCollapsed ? '▶' : '▼'}
📦 ${pack} ${isCore ? '🔧' : ''}
${count} node${count !== 1 ? 's' : ''}
${percentage}%
`;
// Add nodes for this pack
nodeIds.forEach(nodeId => {
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
if (node) {
statsHTML += `
[${nodeId}]
${node.type}
${node.title ? ` - ${node.title}` : ''}
`;
}
});
statsHTML += `
`;
});
statsHTML += `
`;
const results = document.getElementById('af-find-nodes-results');
results.innerHTML = statsHTML;
}
// Add method to track collapsed packs for stats tab
toggleStatsPackCollapse(pack) {
if (!this.collapsedStatsPacks) {
this.collapsedStatsPacks = new Set();
}
if (this.collapsedStatsPacks.has(pack)) {
this.collapsedStatsPacks.delete(pack);
} else {
this.collapsedStatsPacks.add(pack);
}
this.showWorkflowStats();
}
// Add method for selecting and centering nodes (reusable)
selectAndCenterNode(nodeId) {
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
if (node) {
app.canvas.deselectAllNodes();
node.is_selected = true;
app.canvas.selectNode(node);
this.AF_Find_Nodes_CenterOnNode(node);
}
}
// ========== UPDATES TAB ==========
showUpdatesTab() {
const results = document.getElementById('af-find-nodes-results');
// Set overflow to hidden since we'll manage scroll internally
results.style.overflow = 'hidden';
results.style.display = 'flex';
results.style.flexDirection = 'column';
results.style.padding = '0';
let html = `
🔄 Node Version Updates
Click "Scan for Updates" to check for node version updates in your workflow.
`;
results.innerHTML = html;
}
performUpdateScan() {
const resultsContainer = document.getElementById('af-updates-scan-results');
resultsContainer.innerHTML = '⏳ Scanning workflow...
';
setTimeout(() => {
this.updateableNodes = this.scanForUpdates();
this.selectedUpdates.clear();
if (this.updateableNodes.length === 0) {
resultsContainer.innerHTML = `
✅ All nodes are up to date!
No updates found.
`;
this.AF_Find_Nodes_UpdateResults('✅ No updates found - all nodes are current.');
return;
}
this.displayUpdateableNodes();
this.AF_Find_Nodes_UpdateResults(`Found ${this.updateableNodes.length} node(s) with available updates.`);
}, 100);
}
displayUpdateableNodes() {
const byPack = {};
this.updateableNodes.forEach(nodeInfo => {
if (!byPack[nodeInfo.pack]) {
byPack[nodeInfo.pack] = [];
}
byPack[nodeInfo.pack].push(nodeInfo);
});
// Get both containers
const mainResults = document.getElementById('af-find-nodes-results');
const scrollContainer = document.getElementById('af-updates-scan-results');
// Build the fixed header content (goes in main results, above scroll)
let fixedHeaderHTML = `
Updates Available:
${this.updateableNodes.length} node(s)
Affected Packs:
${Object.keys(byPack).length}
`;
// Build the scrollable node list
let nodesHTML = '';
const sortedPacks = Object.keys(byPack).sort();
const coreIndex = sortedPacks.indexOf('Core');
if (coreIndex > -1) {
sortedPacks.splice(coreIndex, 1);
sortedPacks.push('Core');
}
sortedPacks.forEach(pack => {
const nodes = byPack[pack];
const isCollapsed = this.collapsedPacks.has(pack);
const isCore = pack === 'Core';
nodesHTML += `
`;
nodes.forEach(nodeInfo => {
const statusColor = this.getUpdateStatusColor(nodeInfo.category);
const statusText = this.getUpdateStatusText(nodeInfo.category);
const versionDisplay = this.formatVersionDisplay(nodeInfo.workflow, nodeInfo.installed);
nodesHTML += `
[${nodeInfo.nodeId}] ${nodeInfo.type}
${nodeInfo.title}
${versionDisplay}
Status: ${statusText}
`;
});
nodesHTML += `
`;
});
const coreNodes = byPack['Core'];
if (coreNodes && coreNodes.length > 0) {
nodesHTML += `
⚠️ Core ComfyUI Nodes Warning
${coreNodes.length} Core ComfyUI node(s) will be updated.
This may affect workflow compatibility. Backup recommended.
`;
}
// Update the display: Remove any existing fixed headers first
const existingFixedHeaders = mainResults.querySelectorAll('div[style*="flex-shrink: 0"]');
existingFixedHeaders.forEach((header, index) => {
if (index > 0) header.remove(); // Keep first one (scan button), remove duplicates
});
// Now insert/update the fixed header
const scanButton = mainResults.querySelector('div[style*="flex-shrink: 0"]');
if (scanButton && scrollContainer) {
// Remove existing fixed header if present
const nextDiv = scanButton.nextElementSibling;
if (nextDiv && nextDiv !== scrollContainer) {
nextDiv.remove();
}
scanButton.insertAdjacentHTML('afterend', fixedHeaderHTML);
scrollContainer.innerHTML = nodesHTML;
}
}
getUpdateStatusColor(category) {
switch(category) {
case 'newer': return '#4CAF50';
case 'format-changed': return '#ff9800';
case 'hash-changed': return '#ff9800';
default: return '#888';
}
}
getUpdateStatusText(category) {
switch(category) {
case 'newer': return '✅ Update available (semantic version)';
case 'format-changed': return '⚠️ Version format changed - review recommended';
case 'hash-changed': return '⚠️ Git hash changed - review recommended';
default: return '❓ Unable to determine version relationship';
}
}
formatVersionDisplay(workflow, installed) {
const truncate = (str, len = 12) => {
if (str.length <= len) return str;
return str.substring(0, len) + '...';
};
return `
Workflow:
${truncate(workflow)}
→
Installed:
${truncate(installed)} ⬆️
`;
}
togglePackCollapse(packName) {
if (this.collapsedPacks.has(packName)) {
this.collapsedPacks.delete(packName);
} else {
this.collapsedPacks.add(packName);
}
this.displayUpdateableNodes();
}
togglePackSelection(packName, checked) {
const nodes = this.updateableNodes.filter(n => n.pack === packName);
nodes.forEach(nodeInfo => {
if (checked) {
this.selectedUpdates.add(nodeInfo.nodeId);
} else {
this.selectedUpdates.delete(nodeInfo.nodeId);
}
const checkbox = document.getElementById(`af-update-${nodeInfo.nodeId}`);
if (checkbox) checkbox.checked = checked;
});
this.updateApplyButton();
}
toggleNodeSelection(nodeId, checked) {
if (checked) {
this.selectedUpdates.add(nodeId);
} else {
this.selectedUpdates.delete(nodeId);
}
this.updateApplyButton();
}
selectAllUpdates() {
this.updateableNodes.forEach(nodeInfo => {
this.selectedUpdates.add(nodeInfo.nodeId);
const checkbox = document.getElementById(`af-update-${nodeInfo.nodeId}`);
if (checkbox) checkbox.checked = true;
});
this.updateApplyButton();
}
deselectAllUpdates() {
this.selectedUpdates.clear();
this.updateableNodes.forEach(nodeInfo => {
const checkbox = document.getElementById(`af-update-${nodeInfo.nodeId}`);
if (checkbox) checkbox.checked = false;
});
this.updateApplyButton();
}
updateApplyButton() {
const btn = document.getElementById('af-apply-updates-btn');
if (btn) {
const count = this.selectedUpdates.size;
btn.textContent = `📥 Update Selected Nodes (${count})`;
btn.style.opacity = count > 0 ? '1' : '0.5';
btn.style.cursor = count > 0 ? 'pointer' : 'not-allowed';
}
}
highlightUpdateNode(nodeId) {
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
if (node) {
// Clear existing selection and select this node
app.canvas.deselectAllNodes();
node.is_selected = true;
app.canvas.selectNode(node);
this.AF_Find_Nodes_CenterOnNode(node);
}
}
toggleAutoBackup(enabled) {
this.autoBackupEnabled = enabled;
localStorage.setItem('af-find-node-auto-backup', enabled.toString());
}
createManualBackup() {
this.AF_Find_Nodes_UpdateResults('💾 Creating backup...', false);
try {
const workflow = app.graph.serialize();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T');
const filename = `workflow_backup_${timestamp[0]}_${timestamp[1].split('.')[0]}.json`;
const blob = new Blob([JSON.stringify(workflow, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.AF_Find_Nodes_UpdateResults(`✅ Backup created: ${filename}`, false);
} catch (error) {
console.error('Backup failed:', error);
this.AF_Find_Nodes_UpdateResults('❌ Failed to create backup. Check console.', true);
}
}
async applySelectedUpdates() {
if (this.selectedUpdates.size === 0) {
this.AF_Find_Nodes_UpdateResults('No nodes selected for update.', true);
return;
}
if (this.autoBackupEnabled) {
this.AF_Find_Nodes_UpdateResults('💾 Creating backup before updates...', false);
this.createManualBackup();
await new Promise(resolve => setTimeout(resolve, 500));
}
const count = this.selectedUpdates.size;
if (!confirm(`Update ${count} node(s)?\n\nThis will recreate the selected nodes with the latest versions.\nConnections and values will be preserved where possible.`)) {
return;
}
this.AF_Find_Nodes_UpdateResults(`⏳ Updating ${count} node(s)...`, false);
let successCount = 0;
let failCount = 0;
for (const nodeId of this.selectedUpdates) {
try {
const success = await this.recreateNode(nodeId);
if (success) {
successCount++;
} else {
failCount++;
}
} catch (error) {
console.error(`Failed to update node ${nodeId}:`, error);
failCount++;
}
}
if (failCount === 0) {
this.AF_Find_Nodes_UpdateResults(`✅ Successfully updated ${successCount} node(s)!`, false);
} else {
this.AF_Find_Nodes_UpdateResults(`⚠️ Updated ${successCount}, failed ${failCount}. Check console.`, true);
}
// Clear selection after updates
app.canvas.deselectAllNodes();
setTimeout(() => {
this.performUpdateScan();
}, 1000);
}
async recreateNode(nodeId) {
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
if (!node) {
console.error(`Node ${nodeId} not found`);
return false;
}
try {
const nodeState = {
id: node.id,
type: node.type,
pos: [...node.pos],
size: [...node.size],
title: node.title,
color: node.color,
bgcolor: node.bgcolor,
mode: node.mode,
flags: {...node.flags},
widgets_values: node.widgets_values ? [...node.widgets_values] : [],
inputs: node.inputs ? node.inputs.map(input => ({
name: input.name,
type: input.type,
link: input.link
})) : [],
outputs: node.outputs ? node.outputs.map(output => ({
name: output.name,
type: output.type,
links: output.links ? [...output.links] : []
})) : []
};
const inputLinks = [];
if (node.inputs) {
node.inputs.forEach((input, idx) => {
if (input.link != null) {
const link = app.graph.links[input.link];
if (link) {
inputLinks.push({
targetSlot: idx,
originNode: link.origin_id,
originSlot: link.origin_slot
});
}
}
});
}
const outputLinks = [];
if (node.outputs) {
node.outputs.forEach((output, idx) => {
if (output.links && output.links.length > 0) {
output.links.forEach(linkId => {
const link = app.graph.links[linkId];
if (link) {
outputLinks.push({
originSlot: idx,
targetNode: link.target_id,
targetSlot: link.target_slot
});
}
});
}
});
}
app.graph.remove(node);
const newNode = LiteGraph.createNode(nodeState.type);
if (!newNode) {
console.error(`Failed to create node of type: ${nodeState.type}`);
return false;
}
newNode.pos = nodeState.pos;
newNode.size = nodeState.size;
if (nodeState.title) newNode.title = nodeState.title;
if (nodeState.color) newNode.color = nodeState.color;
if (nodeState.bgcolor) newNode.bgcolor = nodeState.bgcolor;
if (nodeState.mode !== undefined) newNode.mode = nodeState.mode;
if (nodeState.flags) Object.assign(newNode.flags, nodeState.flags);
if (newNode.widgets && nodeState.widgets_values) {
nodeState.widgets_values.forEach((val, idx) => {
if (newNode.widgets[idx]) {
newNode.widgets[idx].value = val;
}
});
}
app.graph.add(newNode);
inputLinks.forEach(linkInfo => {
const originNode = app.graph.getNodeById(linkInfo.originNode);
if (originNode && newNode.inputs[linkInfo.targetSlot]) {
originNode.connect(linkInfo.originSlot, newNode, linkInfo.targetSlot);
}
});
outputLinks.forEach(linkInfo => {
const targetNode = app.graph.getNodeById(linkInfo.targetNode);
if (targetNode && newNode.outputs[linkInfo.originSlot]) {
newNode.connect(linkInfo.originSlot, targetNode, linkInfo.targetSlot);
}
});
// Make sure this node isn't in our highlight tracking
this.originalNodeColors.delete(nodeId);
app.graph.setDirtyCanvas(true, true);
return true;
} catch (error) {
console.error(`Error recreating node ${nodeId}:`, error);
return false;
}
}
// Add these export methods to your class:
exportPackList() {
const packList = Object.entries(this.workflowPackIndex)
.map(([pack, nodeIds]) => ({ pack, count: nodeIds.length }))
.sort((a, b) => b.count - a.count)
.map(({ pack, count }) => `• ${pack}: ${count} node${count !== 1 ? 's' : ''}`)
.join('\n');
const exportText = `Node Packs Used in This Workflow:\n\n${packList}\n\nTotal Nodes: ${app.graph?.nodes?.length || 0}\nGenerated by AF-Find-Nodes`;
// Copy to clipboard
navigator.clipboard.writeText(exportText).then(() => {
this.AF_Find_Nodes_UpdateResults('✅ Pack list copied to clipboard! Ready for README.', false);
}).catch(err => {
// Fallback: Show in alert
alert(exportText);
this.AF_Find_Nodes_UpdateResults('📋 Pack list displayed (check alert popup)', false);
});
}
copyNodeTypes(includeUUIDs = false) {
// console.log('copyNodeTypes called with includeUUIDs:', includeUUIDs);
// console.log('workflowTypeIndex keys:', Object.keys(this.workflowTypeIndex).length);
// console.log('Sample types:', Object.keys(this.workflowTypeIndex).slice(0, 5));
let typeEntries = Object.entries(this.workflowTypeIndex)
.map(([type, nodeIds]) => ({ type, count: nodeIds.length }));
// Filter out UUIDs unless requested
if (!includeUUIDs) {
typeEntries = typeEntries.filter(({ type }) => !this.isUUID(type));
}
typeEntries = typeEntries
.sort((a, b) => b.count - a.count)
.map(({ type, count }) => `• ${type}: ${count} node${count !== 1 ? 's' : ''}`);
const filteredCount = typeEntries.length;
const totalCount = Object.keys(this.workflowTypeIndex).length;
const uuidCount = totalCount - filteredCount;
// Start building the export text
let exportText = `Node Types in This Workflow:\n\n${typeEntries.join('\n')}\n\n`;
if (includeUUIDs) {
exportText += `Total Types: ${totalCount}\n`;
} else {
exportText += `Filtered Types: ${filteredCount} (${uuidCount} UUIDs excluded)\n`;
}
exportText += `Generated by AF-Find-Nodes`;
navigator.clipboard.writeText(exportText).then(() => {
const msg = includeUUIDs ?
'✅ All node types copied to clipboard!' :
'✅ Filtered node types copied to clipboard!';
this.AF_Find_Nodes_UpdateResults(msg, false);
}).catch(err => {
alert(exportText);
this.AF_Find_Nodes_UpdateResults('📋 Node types displayed (check alert popup)', false);
});
}
updateInspectorButtonVisibility() {
const inspectorBtn = document.getElementById('af-find-nodes-inspector-toggle');
if (inspectorBtn) {
if (this.currentTab === 'id') {
inspectorBtn.style.display = 'block';
} else {
inspectorBtn.style.display = 'none';
// Also disable inspector mode if switching away from ID tab
if (this.inspectorMode) {
this.AF_Find_Nodes_SetInspectorMode(false);
}
}
}
}
updateTabAppearance() {
document.querySelectorAll('#af-find-nodes-panel [data-tab]').forEach(btn => {
const tabId = btn.dataset.tab;
const isExperimental = tabId === 'pack' || tabId === 'type';
const isStats = tabId === 'stats' || tabId === 'updates';
const isActive = tabId === this.currentTab;
btn.style.background = isActive ?
(isExperimental ? '#7a4a1a' : (isStats ? '#2a4a7a' : '#555')) :
(isExperimental ? '#5a2a0a' : (isStats ? '#1a2a5a' : '#333'));
btn.style.borderBottom = isActive ?
(isExperimental ? '2px solid #ff9800' : (isStats ? '2px solid #4da6ff' : '2px solid #4CAF50')) :
'none';
btn.style.opacity = isExperimental && !this.experimentalEnabled ? '0.6' : '1';
});
}
AF_Find_Nodes_ShowPanel() {
if (!this.searchPanel) {
this.createSearchPanel();
}
this.searchPanel.style.display = 'block';
this.isVisible = true;
// Check if workflow changed (no automatic scan)
if (this.isWorkflowChanged()) {
this.invalidateScanCache();
console.log('AF-Find-Nodes: Workflow changed - cache invalidated');
}
// Show status based on scan state
if (this.scanCompleted) {
const totalNodes = app.graph?.nodes?.length || 0;
const packCount = Object.keys(this.workflowPackIndex).length;
const typeCount = Object.keys(this.workflowTypeIndex).length;
let statsMsg = `Loaded workflow: ${totalNodes} nodes`;
if (packCount > 0) {
statsMsg += `, ${packCount} packs`;
}
if (typeCount > 0) {
statsMsg += `, ${typeCount} types`;
}
this.AF_Find_Nodes_UpdateResults(statsMsg);
} else {
this.AF_Find_Nodes_UpdateResults('Ready to search. Use Pack/Type/Stats tabs to scan workflow.');
}
// Set to remembered tab instead of always 'id'
this.switchTab(this.currentTab);
// Clear the search field when dialog is shown but keep results
document.getElementById('af-find-nodes-input').value = '';
document.getElementById('af-find-nodes-input').focus();
// Update tab appearance to reflect experimental setting
this.updateTabAppearance();
// No automatic monitoring - scanning happens on-demand only
}
AF_Find_Nodes_HidePanel() {
if (this.searchPanel) {
this.searchPanel.style.display = 'none';
}
this.isVisible = false;
this.AF_Find_Nodes_ClearAll();
this.AF_Find_Nodes_SetInspectorMode(false);
// Clear selection when closing
app.canvas.deselectAllNodes();
// No monitoring to stop - on-demand scanning only
}
AF_Find_Nodes_TogglePanel() {
if (this.isVisible) {
this.AF_Find_Nodes_HidePanel();
} else {
this.AF_Find_Nodes_ShowPanel();
}
}
AF_Find_Nodes_Search() {
const searchTerm = this.tabInputs.main.value.trim();
if (!searchTerm) {
this.AF_Find_Nodes_UpdateResults('Please enter a search term', true);
return;
}
let results = [];
switch (this.currentTab) {
case 'id':
results = this.searchById(searchTerm);
break;
case 'title':
results = this.searchByTitle(searchTerm);
break;
case 'pack':
results = this.searchByPack(searchTerm);
break;
case 'type':
results = this.searchByType(searchTerm);
break;
}
this.handleSearchResults(results, searchTerm);
}
searchById(searchTerm) {
const nodeId = parseInt(searchTerm);
if (isNaN(nodeId)) {
this.AF_Find_Nodes_UpdateResults('Invalid node ID. Please enter a number.', true);
return [];
}
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
return node ? [node] : [];
}
searchByTitle(searchTerm) {
const term = searchTerm.toLowerCase();
return app.graph.nodes.filter(node => {
return (
(node.title && node.title.toLowerCase().includes(term)) ||
(node.color && node.color.toLowerCase().includes(term)) ||
(node.cnr_id && node.cnr_id.toString().includes(term)) ||
(node.aux_id && node.aux_id.toString().includes(term)) ||
(node.name && node.name.toLowerCase().includes(term))
);
});
}
getNodeBadgeInfo(node) {
if (!node) return null;
// Check ComfyUI's node registry first - this is where pack info is usually stored
if (app.node_types && node.type) {
const nodeDef = app.node_types[node.type];
if (nodeDef?.category) {
// Check if this is a core ComfyUI node
const category = nodeDef.category;
if (this.isCoreComfyUICategory(category)) {
return 'Core';
}
return category;
}
if (nodeDef?.name) {
return nodeDef.name;
}
}
// Check node constructor properties
if (node.constructor?.nodeData?.category) {
const category = node.constructor.nodeData.category;
if (this.isCoreComfyUICategory(category)) {
return 'Core';
}
return category;
}
if (node.constructor?.category) {
const category = node.constructor.category;
if (this.isCoreComfyUICategory(category)) {
return 'Core';
}
return category;
}
if (node.constructor?.title) {
return node.constructor.title;
}
// Check for comfyClass which sometimes contains pack info
if (node.comfyClass) {
return node.comfyClass;
}
// Check cnr_id as a pack identifier
if (node.cnr_id) {
return node.cnr_id.toString();
}
// For specific known packs, we can also check the type string
const nodeType = node.type || '';
if (nodeType.includes('WAS')) return 'WAS Suite';
if (nodeType.includes('rgthree')) return 'rgthree';
if (nodeType.includes('efficiency')) return 'Efficiency';
if (nodeType.includes('ComfyUI-Impact-Pack')) return 'Impact Pack';
if (nodeType.includes('pysssss')) return 'Custom Scripts';
if (nodeType.includes('easy')) return 'EasyUse';
if (nodeType.includes('Comfyroll')) return 'Comfyroll Studio';
if (nodeType.includes('tinyterra')) return 'TinyTerra';
// Check if it's a UUID (custom subgraph)
if (this.isUUID(nodeType)) {
return 'Custom Subgraph';
}
return null;
}
// Add this helper method to check if a category is a core ComfyUI category
isCoreComfyUICategory(category) {
if (!category) return false;
const coreCategories = [
'latent', 'conditioning', 'image', 'sampling', 'loaders', 'utils',
'mask', 'inpaint', 'model_merging', 'advanced/loaders', 'advanced/conditioning',
'latent/advanced', 'image/advanced', 'video', 'essentials'
];
const categoryLower = category.toLowerCase();
// Check exact matches
if (coreCategories.includes(categoryLower)) {
return true;
}
// Check partial matches (for nested categories like "advanced/loaders")
for (const coreCat of coreCategories) {
if (categoryLower.includes(coreCat) || coreCat.includes(categoryLower)) {
return true;
}
}
return false;
}
// Helper to check if a string is a UUID
isUUID(str) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
}
// ========== VERSION DETECTION METHODS ==========
isSemanticVersion(ver) {
if (!ver || typeof ver !== 'string') return false;
return /^v?\d+(\.\d+){0,2}(\.\d+)*$/.test(ver);
}
isGitHash(ver) {
if (!ver || typeof ver !== 'string') return false;
return /^[0-9a-f]{7,40}$/i.test(ver);
}
compareSemanticVersions(v1, v2) {
const clean1 = v1.replace(/^v/, '').split('.').map(n => parseInt(n) || 0);
const clean2 = v2.replace(/^v/, '').split('.').map(n => parseInt(n) || 0);
const maxLen = Math.max(clean1.length, clean2.length);
for (let i = 0; i < maxLen; i++) {
const num1 = clean1[i] || 0;
const num2 = clean2[i] || 0;
if (num2 > num1) return 1;
if (num2 < num1) return -1;
}
return 0;
}
getVersionInfo(node) {
const workflowVer = node.properties?.ver || node.ver || 'unknown';
let installedVer = 'unknown';
try {
// Create a temporary fresh instance of the same node type
const tempNode = LiteGraph.createNode(node.type);
if (tempNode) {
// Check the fresh node's version in its properties
installedVer = tempNode.properties?.ver ||
tempNode.ver ||
'unknown';
// Important: Don't add it to graph, just check and discard
tempNode.graph = null;
}
} catch (e) {
console.warn(`[AF-Updates] Failed to create temp node for ${node.type}:`, e);
}
// Only log when we have both versions and they differ
if (workflowVer !== 'unknown' && installedVer !== 'unknown' && workflowVer !== installedVer) {
// console.log(`[AF-Updates] MISMATCH: ${node.type}`);
// console.log(` Workflow: ${workflowVer}`);
// console.log(` Installed: ${installedVer}`);
}
return {
workflow: workflowVer,
installed: installedVer,
category: this.categorizeVersionChange(workflowVer, installedVer),
node: node,
type: node.type
};
}
categorizeVersionChange(oldVer, newVer) {
if (oldVer === newVer) return 'same';
if (oldVer === 'unknown' || newVer === 'unknown') return 'unknown';
if (this.isSemanticVersion(oldVer) && this.isSemanticVersion(newVer)) {
const comparison = this.compareSemanticVersions(oldVer, newVer);
if (comparison > 0) return 'newer';
if (comparison < 0) return 'older';
return 'same';
}
if (this.isGitHash(oldVer) && this.isSemanticVersion(newVer)) {
return 'format-changed';
}
if (this.isGitHash(oldVer) && this.isGitHash(newVer)) {
return 'hash-changed';
}
return 'unknown';
}
scanForUpdates() {
if (!app.graph || !app.graph.nodes) {
this.AF_Find_Nodes_UpdateResults('No workflow loaded.', true);
return [];
}
const updateableNodes = [];
app.graph.nodes.forEach(node => {
const versionInfo = this.getVersionInfo(node);
if (versionInfo.category === 'newer' ||
versionInfo.category === 'format-changed' ||
versionInfo.category === 'hash-changed') {
const packInfo = this.getNodeBadgeInfo(node);
updateableNodes.push({
...versionInfo,
pack: packInfo || 'Unknown',
nodeId: node.id,
title: node.title || 'Untitled'
});
}
});
return updateableNodes;
}
getPackAliases(packName) {
if (!packName) return 'unknown';
const lowerPackName = packName.toLowerCase();
// First, check if it's a core ComfyUI category
if (this.isCoreComfyUICategory(lowerPackName)) {
return 'Core';
}
const aliasMap = {
'pysssss': ['custom scripts', 'custom-scripts', 'pysssss'],
'was': ['was suite', 'was', 'wolfang'],
'rgthree': ['rgthree', 'rg3'],
'efficiency': ['efficiency', 'eff'],
'comfyui-manager': ['manager', 'comfyui-manager'],
'easyuse': ['easy use', 'easy-use', 'easyuse', 'easy'],
'controlnet': ['controlnet', 'control net', 'comfyui_controlnet_aux'],
'comfyroll': ['comfyroll studio', 'comfyroll', '🧩 comfyroll studio'],
'tinyterra': ['tinyterra', '🌏 tinyterra'],
'crystools': ['crystools', 'crystools 🪛'],
'impactpack': ['impactpack', 'impact pack', 'comfyui-impact-pack'],
'fen': ["fen's simple nodes", 'fens'],
'wlsh': ['wlsh nodes', 'wlsh'],
'itools': ['itools'],
'ollama': ['ollama'],
'bootleg': ['bootleg'],
'kjnodes': ['kjnodes'],
'marigold': ['marigold'],
'sam': ['segment_anything2', 'segment anything2'],
'depthanything': ['depthanythingv2', 'depthanything']
};
// First check for exact matches
for (const [canonicalName, aliases] of Object.entries(aliasMap)) {
if (aliases.includes(lowerPackName)) {
return canonicalName;
}
}
// Then check for partial matches
for (const [canonicalName, aliases] of Object.entries(aliasMap)) {
for (const alias of aliases) {
if (lowerPackName.includes(alias) || alias.includes(lowerPackName)) {
return canonicalName;
}
}
}
// Check if it's a subgraph/custom node
if (this.isUUID(lowerPackName)) {
return 'Custom Subgraph';
}
// Return the original pack name if no aliases found
return packName;
}
checkNodeFilePath(node, term) {
// Some nodes store file path info that contains pack names
if (node.constructor?.nodeData?.filename) {
return node.constructor.nodeData.filename.toLowerCase().includes(term);
}
return false;
}
searchByPack(searchTerm) {
const term = searchTerm.toLowerCase().trim();
if (!term) {
return [];
}
// Ensure we have a scanned index
this.scanWorkflowForPacks();
// If we have a pre-scanned index, use it for lightning-fast search
if (Object.keys(this.workflowPackIndex).length > 0) {
const results = [];
const normalizedTerm = this.normalizePackName(term);
// Search through our pack index
Object.entries(this.workflowPackIndex).forEach(([packName, nodeIds]) => {
// Check if pack name matches (case-insensitive, partial match)
const packNameLower = packName.toLowerCase();
if (packNameLower.includes(normalizedTerm) ||
normalizedTerm.includes(packNameLower) ||
packNameLower === normalizedTerm) {
// Get all nodes for this pack
nodeIds.forEach(nodeId => {
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
if (node) {
// Add some metadata for display
node._packSource = packName;
results.push(node);
}
});
}
});
if (results.length > 0) {
// Sort by node ID for consistency
results.sort((a, b) => a.id - b.id);
return results;
}
}
// Fallback to original search if index search finds nothing
// console.log('AF-Find-Nodes: Using fallback pack search');
return this.originalSearchByPack(searchTerm);
}
// Keep the original search as fallback - add this method AFTER searchByPack:
originalSearchByPack(searchTerm) {
const term = searchTerm.toLowerCase().trim();
if (!term) {
return [];
}
if (!app.graph || !app.graph.nodes) {
console.warn("AF_Find_Nodes: No graph or nodes available");
return [];
}
const normalizedTerm = this.getPackAliases(term);
return app.graph.nodes.filter(node => {
const nodeSource = this.getNodeBadgeInfo(node);
const nodeType = (node.type || '').toLowerCase();
const nodeCnrId = (node.cnr_id || '').toString().toLowerCase();
const nodeSourceLower = (nodeSource || '').toLowerCase();
return (nodeSource && nodeSourceLower.includes(normalizedTerm)) ||
nodeType.includes(normalizedTerm) ||
nodeCnrId.includes(normalizedTerm) ||
this.checkNodeFilePath(node, normalizedTerm);
});
}
scanWorkflowForPacks() {
if (!app.graph || !app.graph.nodes || app.graph.nodes.length === 0) {
this.workflowPackIndex = {};
this.workflowTypeIndex = {};
this.scanCompleted = false;
return;
}
// Create a simple signature to detect if workflow changed
const nodeIds = app.graph.nodes.map(n => n.id).sort();
const signature = nodeIds.join('_') + '_' + app.graph.nodes.length;
// If signature matches and we already scanned, skip
if (signature === this.lastWorkflowSignature && this.scanCompleted) {
return;
}
console.log('AF-Find-Nodes: Scanning workflow for packs/types...');
// Reset indices
this.workflowPackIndex = {};
this.workflowTypeIndex = {};
let packCount = 0;
let typeCount = 0;
let uuidCount = 0;
// Quick scan through all nodes
app.graph.nodes.forEach(node => {
const nodeId = node.id;
const nodeType = node.type || 'Unknown';
// Index by type (always available)
if (!this.workflowTypeIndex[nodeType]) {
this.workflowTypeIndex[nodeType] = [];
typeCount++;
}
this.workflowTypeIndex[nodeType].push(nodeId);
// Index by pack (our main target)
const packInfo = this.getNodeBadgeInfo(node);
if (packInfo) {
// Use your existing alias system to normalize pack names
const normalizedPack = this.getPackAliases(packInfo);
// Track UUIDs separately
if (this.isUUID(normalizedPack)) {
uuidCount++;
}
if (!this.workflowPackIndex[normalizedPack]) {
this.workflowPackIndex[normalizedPack] = [];
packCount++;
}
this.workflowPackIndex[normalizedPack].push(nodeId);
}
});
this.lastWorkflowSignature = signature;
this.scanCompleted = true;
console.log(`AF-Find-Nodes: Indexed ${packCount} packs and ${typeCount} node types`);
if (uuidCount > 0) {
console.log(`AF-Find-Nodes: Found ${uuidCount} UUID-based node types`);
}
}
// Helper method to get normalized pack name
normalizePackName(packName) {
if (!packName) return 'unknown';
return this.getPackAliases(packName.toLowerCase());
}
handleSearchResults(nodes, searchTerm) {
if (nodes.length === 0) {
this.showResultsList([], searchTerm);
this.AF_Find_Nodes_UpdateResults(`No nodes found for "${searchTerm}"`, true);
return;
}
this.showResultsList(nodes, searchTerm);
this.AF_Find_Nodes_AddToHistory(searchTerm);
if (nodes.length === 1) {
this.selectFromList(nodes[0].id);
this.AF_Find_Nodes_UpdateResults(`Found: ${this.getNodeDescription(nodes[0])}`);
} else {
this.AF_Find_Nodes_UpdateResults(`Found ${nodes.length} nodes. Click any to select.`);
}
}
showResultsList(nodes, searchTerm) {
let resultsHTML = '';
if (nodes.length === 0) {
resultsHTML = `No nodes found for "${searchTerm}"
`;
} else {
resultsHTML = `Found ${nodes.length} nodes:
`;
nodes.forEach((node, index) => {
const nodeSource = this.getNodeBadgeInfo(node);
resultsHTML += `
[${node.id}] ${node.type || 'Unknown'}
${node.title || 'Untitled'}
${nodeSource ? `
Pack: ${nodeSource}
` : ''}
`;
});
}
const results = document.getElementById('af-find-nodes-results');
results.innerHTML = resultsHTML;
}
selectFromList(nodeId) {
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
if (node) {
// Clear existing selection and select this node
app.canvas.deselectAllNodes();
node.is_selected = true;
app.canvas.selectNode(node);
this.AF_Find_Nodes_CenterOnNode(node);
this.AF_Find_Nodes_UpdateResults(`Selected: ${this.getNodeDescription(node)}`);
// Keep the list open, just update the selection highlight
const currentSearch = this.tabInputs.main.value.trim();
const currentNodes = this.getCurrentSearchResults(currentSearch);
this.showResultsList(currentNodes, currentSearch);
}
}
getCurrentSearchResults(searchTerm) {
switch (this.currentTab) {
case 'id':
return this.searchById(searchTerm);
case 'title':
return this.searchByTitle(searchTerm);
case 'pack':
return this.searchByPack(searchTerm);
case 'type':
return this.searchByType(searchTerm);
default:
return [];
}
}
getNodeDescription(node) {
return `[${node.id}] ${node.type || 'Unknown Type'}: ${node.title || 'Untitled'}`;
}
AF_Find_Nodes_FindNodeById(nodeId) {
if (!app.graph || !app.graph.nodes) return null;
// Node ids are strings on frontend 1.47+, numbers on older versions - compare as strings
return app.graph.nodes.find(node => String(node.id) === String(nodeId));
}
AF_Find_Nodes_HighlightNode(node) {
this.AF_Find_Nodes_ClearHighlight();
// Store original color
this.originalNodeColors.set(node.id, {
color: node.color,
bgcolor: node.bgcolor
});
// Apply highlight
node.color = '#ff9600';
node.bgcolor = '#ff960033';
this.highlightedNode = node;
app.graph.setDirtyCanvas(true, true);
}
AF_Find_Nodes_ClearHighlight() {
if (this.highlightedNode) {
const original = this.originalNodeColors.get(this.highlightedNode.id);
if (original) {
this.highlightedNode.color = original.color;
this.highlightedNode.bgcolor = original.bgcolor;
}
this.originalNodeColors.delete(this.highlightedNode.id);
this.highlightedNode = null;
app.graph.setDirtyCanvas(true, true);
}
}
AF_Find_Nodes_ClearAll() {
// Don't do anything if we're in stats or updates tab
if (this.currentTab === 'stats' || this.currentTab === 'updates') {
return;
}
// Clear the search field
const input = document.getElementById('af-find-nodes-input');
if (input) {
input.value = '';
}
// Clear highlight
this.AF_Find_Nodes_ClearHighlight();
// Clear results display but keep the container visible
this.showResultsList([], '');
this.AF_Find_Nodes_UpdateResults('Search field cleared.');
}
AF_Find_Nodes_CenterOnNode(node) {
if (!app.canvas || !node) return;
try {
// Clear current selection
app.canvas.deselectAllNodes();
// Select the target node
node.is_selected = true;
app.canvas.selectNode(node);
// Reset zoom to a reasonable level for viewing
const targetScale = 1.0; // or 1.2 for a bit closer
app.canvas.ds.scale = targetScale;
// Center on the node
const canvas = app.canvas;
const nodeWidth = node.size[0];
const nodeHeight = node.size[1];
const nodeCenterX = node.pos[0] + (nodeWidth / 2);
const nodeCenterY = node.pos[1] + (nodeHeight / 2);
canvas.ds.offset[0] = (canvas.canvas.width / 2) - (nodeCenterX * canvas.ds.scale);
canvas.ds.offset[1] = (canvas.canvas.height / 2) - (nodeCenterY * canvas.ds.scale);
app.graph.setDirtyCanvas(true, true);
} catch (error) {
console.warn("AF_Find_Nodes: Could not center on node, using fallback", error);
// Simple fallback
const canvas = app.canvas;
canvas.ds.offset[0] = -node.pos[0] * canvas.ds.scale;
canvas.ds.offset[1] = -node.pos[1] * canvas.ds.scale;
canvas.setDirty(true, true);
}
}
AF_Find_Nodes_ToggleInspector() {
this.AF_Find_Nodes_SetInspectorMode(!this.inspectorMode);
// Fix width when toggling inspector
/* if (this.searchPanel) {
this.searchPanel.style.width = '340px';
} */
}
AF_Find_Nodes_SetInspectorMode(enabled) {
if (enabled && this.currentTab !== 'id') {
this.switchTab('id');
}
this.inspectorMode = enabled;
const btn = document.getElementById('af-find-nodes-inspector-toggle');
if (btn) {
btn.style.background = enabled ? '#ff9800' : '#2196F3';
btn.textContent = enabled ? 'Exit Inspector' : 'Inspector';
}
// Setup or cleanup selection monitor
this.setupSelectionMonitor();
if (enabled) {
this.AF_Find_Nodes_UpdateResults('Inspector mode active. Click any node to see its ID.');
} else {
this.AF_Find_Nodes_UpdateResults('Inspector mode disabled.');
}
}
AF_Find_Nodes_HandleNodeClick(node) {
if (this.inspectorMode && node) {
const input = document.getElementById('af-find-nodes-input');
if (input) {
input.value = node.id.toString();
}
// Clear existing selection and select this node
app.canvas.deselectAllNodes();
node.is_selected = true;
app.canvas.selectNode(node);
this.AF_Find_Nodes_CenterOnNode(node);
this.AF_Find_Nodes_AddToHistory(node.id);
this.AF_Find_Nodes_UpdateResults(`Selected node ${node.id}: ${node.type || 'Unknown Type'}`);
}
}
AF_Find_Nodes_AddToHistory(searchTerm) {
const history = this.tabHistory[this.currentTab];
// Remove if already exists
this.tabHistory[this.currentTab] = history.filter(item => item !== searchTerm);
// Add to front
this.tabHistory[this.currentTab].unshift(searchTerm);
// Limit size
if (this.tabHistory[this.currentTab].length > this.maxHistory) {
this.tabHistory[this.currentTab] = this.tabHistory[this.currentTab].slice(0, this.maxHistory);
}
// Save to localStorage
localStorage.setItem(`af-find-node-history-${this.currentTab}`, JSON.stringify(this.tabHistory[this.currentTab]));
this.AF_Find_Nodes_UpdateHistory();
}
AF_Find_Nodes_UpdateHistory() {
const historyContainer = document.getElementById('af-find-nodes-history');
if (!historyContainer) return;
historyContainer.innerHTML = '';
const currentHistory = this.tabHistory[this.currentTab];
currentHistory.forEach(item => {
const historyBtn = document.createElement('button');
historyBtn.textContent = item;
historyBtn.style.cssText = `
background: #444;
border: none;
color: #fff;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
margin: 1px;
`;
historyBtn.onclick = () => {
this.tabInputs.main.value = item;
this.AF_Find_Nodes_Search();
};
historyContainer.appendChild(historyBtn);
});
if (currentHistory.length === 0) {
const noHistory = document.createElement('div');
noHistory.textContent = 'No recent searches';
noHistory.style.cssText = 'color: #666; font-size: 10px; font-style: italic;';
historyContainer.appendChild(noHistory);
}
}
AF_Find_Nodes_UpdateResults(message, isError = false) {
const status = document.getElementById('af-find-nodes-status');
if (status) {
status.innerHTML = message;
status.style.color = isError ? '#ff6b6b' : '#aaa'; // Red for errors, white otherwise
}
}
// Keyboard shortcut handler
AF_Find_Nodes_HandleKeyboard(event) {
// Dynamic shortcut from ComfyUI settings
const shortcut = this.keyboardShortcut;
if (shortcut && shortcut !== "Disabled") {
const parts = shortcut.split('+').map(p => p.trim());
const key = parts[parts.length - 1].toUpperCase();
const needsCtrl = parts.some(p => p.toLowerCase() === 'ctrl');
const needsAlt = parts.some(p => p.toLowerCase() === 'alt');
const needsShift = parts.some(p => p.toLowerCase() === 'shift');
if (event.key.toUpperCase() === key &&
event.ctrlKey === needsCtrl &&
event.altKey === needsAlt &&
event.shiftKey === needsShift) {
event.preventDefault();
this.AF_Find_Nodes_TogglePanel();
}
}
// Escape to close panel
if (event.code === 'Escape' && this.isVisible) {
event.preventDefault();
this.AF_Find_Nodes_HidePanel();
}
}
searchByType(searchTerm) {
const term = searchTerm.toLowerCase().trim();
if (!term) {
return [];
}
// Ensure we have a scanned index
this.scanWorkflowForPacks();
// Use pre-scanned index if available
if (Object.keys(this.workflowTypeIndex).length > 0) {
const results = [];
// Search through our type index
Object.entries(this.workflowTypeIndex).forEach(([nodeType, nodeIds]) => {
if (nodeType.toLowerCase().includes(term)) {
nodeIds.forEach(nodeId => {
const node = this.AF_Find_Nodes_FindNodeById(nodeId);
if (node) {
results.push(node);
}
});
}
});
if (results.length > 0) {
results.sort((a, b) => a.id - b.id);
return results;
}
}
// Fallback to original type search
return app.graph.nodes.filter(node => {
const nodeType = node.type || '';
return nodeType.toLowerCase().includes(term);
});
}
}
// Initialize the search widget with guard against double initialization
window.AF_Find_Nodes_Widget = new AF_Find_Nodes_Widget();
// Register the extension - SAFE VERSION with NO overrides
app.registerExtension({
name: "AF-Find-Nodes",
settings: [
{
id: "AF.FindNodes.keyboardShortcut",
name: "Keyboard shortcut to open Find Nodes panel (e.g. Ctrl+Shift+F, Ctrl+G, Disabled)",
type: "text",
defaultValue: "Ctrl+Shift+F",
category: ["AF - Nodes", "AF - Find Nodes", "Shortcuts"],
tooltip: "Key combo to toggle the Find Nodes panel. Use modifier+key format. Set to 'Disabled' to turn off.",
onChange: (value) => {
if (window.AF_Find_Nodes_Widget) {
window.AF_Find_Nodes_Widget.keyboardShortcut = value;
}
}
}
],
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// We don't need to modify node definitions
},
async setup() {
// Read shortcut from ComfyUI settings (may now be available)
window.AF_Find_Nodes_Widget.keyboardShortcut =
app.extensionManager?.setting.get("AF.FindNodes.keyboardShortcut") ?? "Ctrl+Shift+F";
// Add keyboard event listeners
document.addEventListener('keydown', (e) => {
window.AF_Find_Nodes_Widget.AF_Find_Nodes_HandleKeyboard(e);
});
console.log("AF - Find Nodes extension loaded. Use Ctrl+Shift+F to open search panel.");
// Workflow scanning now happens on-demand only (no automatic monitoring)
},
async destroyed() {
// Clean up selection interval if it exists
if (window.AF_Find_Nodes_Widget) {
if (window.AF_Find_Nodes_Widget.selectionInterval) {
clearInterval(window.AF_Find_Nodes_Widget.selectionInterval);
window.AF_Find_Nodes_Widget.selectionInterval = null;
}
}
}
});
} // End of guard against double initialization