diff --git a/README.md b/README.md index e310275..6b24d18 100644 --- a/README.md +++ b/README.md @@ -16,91 +16,271 @@ These nodes are very lightweight and require no additional dependencies. # Features -- **STRING**: String manipulation nodes - - Basic functions: capitalize, casefold, center, concat, count, encode/decode, find/rfind, join, lower/upper, replace, split, strip, etc. - - String checking: contains, endswith, startswith - - Case conversion: lower, upper, swapcase, title, capitalize - - Validation: isalnum, isalpha, isdigit, isnumeric, etc. - -- **LIST**: Python list manipulation nodes (as a single variable) - - Conversion: convert to data list - - Creation: any to LIST - - Modification: append, extend, insert, remove, pop, clear, set_item - - Access: get_item, slice, index, contains - - Information: length, count - - Operations: sort, reverse, min, max - -- **SET**: Python set manipulation nodes (as a single variable) - - Conversion: convert to data list, to LIST - - Creation: any to SET, LIST to SET - - Modification: add, remove, discard, pop, clear - - Information: length, contains - - Set operations: union, intersection, difference, symmetric_difference - - Comparison: is_subset, is_superset, is_disjoint - -- **data list**: ComfyUI list manipulation nodes (for processing individual items) - - Creation: create_empty - - Modification: append, filter, extend, insert, remove, pop, clear, set_item - - Access: get_item, slice, index, contains - - Information: length, count - - Operations: sort, reverse, copy, zip, min, max - -- **cast**: Type conversion nodes for ComfyUI data types - - Basic conversions: to STRING, to INT, to FLOAT, to BOOLEAN - - Collection conversions: to LIST, to SET, to DICT - - Special conversions: data list to LIST, data list to SET - -- **path**: File system path manipulation nodes - - Basic operations: join, split, splitext, basename, dirname, normalize - - Path information: abspath, exists, is_file, is_dir, is_absolute, get_size, get_extension - - Directory operations: list_dir, get_cwd - - Path searching: glob, common_prefix - - Path conversions: relative, expand_vars - -- **DICT**: Dictionary manipulation nodes - - Creation: create, from_items, from_lists, fromkeys, any_to_DICT - - Access: get, get_multiple, keys, values, items - - Modification: set, update, setdefault, merge - - Removal: pop, popitem, remove, clear - - Information: length, contains_key - - Operations: copy, filter_by_keys, exclude_keys, invert, compare - - Conversion: get_keys_values - -- **INT**: Integer operation nodes - - Basic operations: add, subtract, multiply, divide, modulus, power - - Bit operations: bit_length, to_bytes, from_bytes, bit_count - -- **FLOAT**: Floating-point operation nodes - - Basic operations: add, subtract, multiply, divide, power, round - - Specialized: is_integer, as_integer_ratio, to_hex, from_hex - -- **BOOLEAN**: Boolean logic nodes - - Logic operations: and, or, not, xor, nand, nor - -- **Comparison**: Value comparison nodes - - Basic comparisons: equal (==), not equal (!=), greater than (>), less than (<), etc. - - String comparison: case-sensitive/insensitive string comparison with various operators - - Special comparisons: number in range, is null, compare length - - Container operations: length comparison for strings, lists, and other containers - -- **Flow Control**: Workflow control nodes - - Conditional: if/else for branching logic based on conditions - - Selection: switch/case for selecting from multiple options based on an index - -- **Math**: Mathematical operations - - Trigonometric: sin, cos, tan, asin, acos, atan, atan2 - - Logarithmic/Exponential: log, log10, exp, sqrt - - Numerical: min, max - - Constants: pi, e - - Conversion: degrees/radians - - Rounding: floor, ceil - - Other: abs (absolute value) +## Nodes + +### BOOLEAN: Boolean logic nodes +- Logic operations: and, or, not, xor, nand, nor + +### cast: Type conversion nodes for ComfyUI data types +- Basic data type conversions: + - to BOOLEAN - Converts any input to a Boolean using Python's truthy/falsy rules + - to FLOAT - Converts numeric input to a floating-point number (raises ValueError for invalid inputs) + - to INT - Converts numeric input to an integer (raises ValueError for invalid inputs) + - to STRING - Converts any input to a string using Python's str() function +- Collection type conversions: + - to DICT - Converts compatible inputs (mappings or lists of key-value pairs) to a dictionary + - to LIST - Converts input to a LIST (wraps single items in a list, preserves existing lists) + - to SET - Converts input to a SET (creates a set from single items or collections, removing duplicates) + +### Comparison: Value comparison nodes +- Basic comparisons: equal (==), not equal (!=), greater than (>), greater than or equal (>=), less than (<), less than or equal (<=) +- String comparison: StringComparison node with case-sensitive/insensitive options and various operators (==, !=, >, <, >=, <=) +- Special comparisons: NumberInRange (check if a value is within specified bounds), IsNull (check if a value is null) +- Container operations: CompareLength (compare the length of strings, lists, and other containers) + +### data list: ComfyUI list manipulation nodes (for processing individual items) +- Creation: + - create Data List - Generic creation from any type inputs (dynamically expandable) + - Type-specific creation: + - create Data List from BOOLEANs - Creates a data list from Boolean values + - create Data List from FLOATs - Creates a data list from floating-point values + - create Data List from INTs - Creates a data list from integer values + - create Data List from STRINGs - Creates a data list from string values +- Modification: + - append - Adds an item to the end of a data list + - extend - Combines elements from multiple data lists + - insert - Inserts an item at a specified position + - set item - Replaces an item at a specified position + - remove - Removes the first occurrence of a specified value + - pop - Removes and returns an item at a specified position + - pop random - Removes and returns a random element +- Filtering: + - filter - Filters a data list using boolean values + - filter select - Separates items into two lists based on boolean filters +- Access: + - get item - Retrieves an item at a specified position + - first - Returns the first element in a data list + - last - Returns the last element in a data list + - slice - Creates a subset of a data list using start/stop/step parameters + - index - Finds the position of a value in a data list + - contains - Checks if a data list contains a specified value +- Information: + - length - Returns the number of items in a data list + - count - Counts occurrences of a value in a data list +- Operations: + - sort - Orders items (with optional reverse parameter) + - reverse - Reverses the order of items + - zip - Combines multiple data lists element-wise + - min - Finds the minimum value in a list of numbers + - max - Finds the maximum value in a list of numbers +- Conversion: + - convert to LIST - Converts a data list to a LIST object + - convert to SET - Converts a data list to a SET (removing duplicates) + +### DICT: Dictionary manipulation nodes +- Creation: create (generic and type-specific versions), create from items (data list and LIST versions), create from lists, fromkeys +- Access: get, get_multiple, keys, values, items +- Modification: set, update, setdefault, merge +- Removal: pop, popitem, pop random, remove +- Information: length, contains_key +- Operations: filter_by_keys, exclude_keys, invert, compare +- Conversion: get_keys_values + +### FLOAT: Floating-point operation nodes +- Creation: create FLOAT from string +- Basic arithmetic: add, subtract, multiply, divide, divide (zero safe), power +- Formatting: round (to specified decimal places) +- Conversion: to_hex (hexadecimal representation), from_hex (create from hex string) +- Analysis: is_integer (check if float has no fractional part), as_integer_ratio (get numerator/denominator) + +### Flow Control: Workflow control nodes +- Conditional branching: + - if/else - Basic conditional that returns one of two values based on a condition + - if/elif/.../else - Extended conditional with multiple conditions and outcomes +- Selection: + - switch/case - Selects from multiple options based on an integer index + - flow select - Directs a value to either "true" or "false" output path based on a condition +- Execution control: + - force execution order - Coordinates the execution sequence of nodes in a workflow + +### INT: Integer operation nodes +- Creation: create INT (from string), create INT with base (convert string with specified base) +- Basic arithmetic: add, subtract, multiply, divide, divide (zero safe), modulus, power +- Bit operations: bit_length (bits needed to represent number), bit_count (count of 1 bits) +- Byte conversion: to_bytes (convert integer to bytes), from_bytes (convert bytes to integer) + +### LIST: Python list manipulation nodes (as a single variable) +- Creation: create LIST (generic), create from type-specific values (BOOLEANs, FLOATs, INTs, STRINGs) +- Modification: append, extend, insert, remove, pop, pop random, set_item +- Access: get_item, first, last, slice, index, contains +- Information: length, count +- Operations: sort (with optional reverse), reverse, min, max +- Conversion: convert to data list, convert to SET + +### Math: Mathematical operations +- Trigonometric functions: + - Basic: sin, cos, tan - Calculate sine, cosine, and tangent of angles (in degrees or radians) + - Inverse: asin, acos, atan - Calculate inverse sine, cosine, and tangent (returns degrees or radians) + - Special: atan2 - Calculate arc tangent of y/x with correct quadrant handling +- Logarithmic/Exponential functions: + - log - Natural logarithm (base e) with optional custom base + - log10 - Base-10 logarithm + - exp - Exponential function (e^x) + - sqrt - Square root +- Constants: + - pi - Mathematical constant π (3.14159...) + - e - Mathematical constant e (2.71828...) +- Angle conversion: + - degrees - Convert radians to degrees + - radians - Convert degrees to radians +- Rounding operations: + - floor - Return largest integer less than or equal to input + - ceil - Return smallest integer greater than or equal to input +- Min/Max functions: + - min - Return minimum of two values + - max - Return maximum of two values +- Other: + - abs - Absolute value (magnitude without sign) + +### path: File system path manipulation nodes +- Basic operations: + - join - Joins multiple path components intelligently with correct separators + - split - Splits a path into directory and filename components + - splitext - Splits a path into name and extension components + - basename - Extracts the filename component from a path + - dirname - Extracts the directory component from a path + - normalize - Collapses redundant separators and resolves up-level references +- Path information: + - abspath - Returns the absolute (full) path by resolving relative components + - exists - Checks if a path exists in the filesystem + - is_file - Checks if a path points to a regular file + - is_dir - Checks if a path points to a directory + - is_absolute - Checks if a path is absolute (begins at root directory) + - get_size - Returns the size of a file in bytes + - get_extension - Extracts the file extension from a path (including the dot) +- Directory operations: + - list_dir - Lists files and directories in a specified path with filtering options + - get_cwd - Returns the current working directory +- Path searching: + - glob - Finds paths matching a pattern with wildcard support + - common_prefix - Finds the longest common leading component of given paths +- Path conversions: + - relative - Computes a relative path from a start path to a target path + - expand_vars - Replaces environment variables in a path with their values + +### SET: Python set manipulation nodes (as a single variable) +- Creation: + - create SET - Generic creation from any type inputs (dynamically expandable) + - Type-specific creation: + - create SET from BOOLEANs - Creates a set from Boolean values + - create SET from FLOATs - Creates a set from floating-point values + - create SET from INTs - Creates a set from integer values + - create SET from STRINGs - Creates a set from string values +- Modification: + - add - Adds an item to a set + - remove - Removes an item from a set (raises error if not present) + - discard - Removes an item if present (no error if missing) + - pop - Removes and returns an arbitrary element + - pop random - Removes and returns a random element +- Information: + - length - Returns the number of items in a set + - contains - Checks if a set contains a specified value +- Set operations: + - union - Combines elements from multiple sets + - intersection - Returns elements common to all input sets + - difference - Returns elements in first set but not in second set + - symmetric_difference - Returns elements in either set but not in both +- Set comparison: + - is_subset - Checks if first set is a subset of second set + - is_superset - Checks if first set is a superset of second set + - is_disjoint - Checks if two sets have no common elements +- Conversion: + - convert to data list - Converts a SET to a ComfyUI data list + - convert to LIST - Converts a SET to a LIST + +### STRING: String manipulation nodes +Available nodes grouped by functionality: + +#### Text case conversion +- capitalize - Converts first character to uppercase, rest to lowercase +- casefold - Aggressive lowercase for case-insensitive comparisons +- lower - Converts string to lowercase +- swapcase - Swaps case of all characters +- title - Converts string to titlecase +- upper - Converts string to uppercase + +#### Text inspection and validation +- contains (in) - Checks if string contains a substring +- endswith - Checks if string ends with a specific suffix +- find - Finds first occurrence of a substring +- length - Returns the number of characters in the string +- rfind - Finds last occurrence of a substring +- startswith - Checks if string starts with a specific prefix + +#### Character type checking +- isalnum - Checks if all characters are alphanumeric +- isalpha - Checks if all characters are alphabetic +- isascii - Checks if all characters are ASCII +- isdecimal - Checks if all characters are decimal +- isdigit - Checks if all characters are digits +- isidentifier - Checks if string is a valid Python identifier +- islower - Checks if all characters are lowercase +- isnumeric - Checks if all characters are numeric +- isprintable - Checks if all characters are printable +- isspace - Checks if all characters are whitespace +- istitle - Checks if string is titlecased +- isupper - Checks if all characters are uppercase + +#### Text formatting and alignment +- center - Centers text within specified width +- expandtabs - Replaces tabs with spaces +- ljust - Left-aligns text within specified width +- rjust - Right-aligns text within specified width +- zfill - Pads string with zeros on the left + +#### Text splitting and joining +- join (from data list) - Joins strings from a data list +- join (from LIST) - Joins strings from a LIST +- rsplit (from data list) - Splits string from right into a data list +- rsplit (from LIST) - Splits string from right into a LIST +- split (to data list) - Splits string into a data list +- split (to LIST) - Splits string into a LIST +- splitlines (from data list) - Splits string at line boundaries into a data list +- splitlines (to LIST) - Splits string at line boundaries into a LIST + +#### Text modification +- concat - Combines two strings together +- count - Counts occurrences of a substring +- replace - Replaces occurrences of a substring +- strip - Removes leading and trailing characters +- lstrip - Removes leading characters +- rstrip - Removes trailing characters +- removeprefix - Removes prefix if present +- removesuffix - Removes suffix if present + +#### Encoding and escaping +- decode - Converts bytes-like string to text +- encode - Converts string to bytes +- escape - Converts special characters to escape sequences +- unescape - Converts escape sequences to actual characters +- format_map - Formats string using values from dictionary ## Understanding LIST vs. data list vs. SET ComfyUI has different data types that serve different purposes: -### 1. LIST datatype +### 1. data list +- A native ComfyUI list where **items are processed individually** +- Acts like a standard array/list in most programming contexts +- Items can be accessed individually by compatible nodes +- Supports built-in ComfyUI iteration over each item +- Best for: + - Working directly with multiple items in parallel + - Batch processing scenarios + - When you need to apply the same operation to multiple inputs + - When your operation needs to work with individual items separately + +### 2. LIST datatype - A Python list represented as a **single variable** in the workflow - Treated as a self-contained object that can be passed between nodes - Cannot directly connect to nodes that expect individual items @@ -110,7 +290,7 @@ ComfyUI has different data types that serve different purposes: - Passing collections between different parts of your workflow - Complex data storage that shouldn't be split apart -### 2. SET datatype +### 3. SET datatype - A Python set represented as a **single variable** in the workflow - Stores unique values with no duplicates - Supports mathematical set operations (union, intersection, etc.) @@ -120,23 +300,6 @@ ComfyUI has different data types that serve different purposes: - Set operations (union, difference, etc.) - When element order doesn't matter -### 3. data list -- A native ComfyUI list where **items are processed individually** -- Acts like a standard array/list in most programming contexts -- Items can be accessed individually by compatible nodes -- Supports built-in ComfyUI iteration over each item -- Best for: - - Working directly with multiple items in parallel - - Batch processing scenarios - - When you need to apply the same operation to multiple inputs - - When your operation needs to work with individual items separately - -### Converting between types -- Use `convert to data list` node to transform a LIST or SET into a ComfyUI data list -- Use `convert to LIST` node to transform a ComfyUI data list into a LIST object -- Use `LIST to SET` to convert a LIST to a SET (removing duplicates) -- Use `SET to LIST` to convert a SET to a LIST - ### When to use which type - Use **LIST** when you need: - Ordered collection with potential duplicates diff --git a/pyproject.toml b/pyproject.toml index 0a9b282..cc8688d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,28 @@ build-backend = "setuptools.build_meta" [project] name = "basic_data_handling" -version = "0.3.4" +version = "0.3.5" description = """NOTE: Still in development! Expect breaking changes! + Basic Python functions for manipulating data that every programmer is used to. -Currently supported ComfyUI data types: BOOLEAN, FLOAT, INT, STRING and data lists. -Additionally supported Python data types as custom data types: DICT, LIST. -Data handling is supported with comparisons and control flow operations. -Also contains math, regex, and file system path manipulation functions.""" +Comprehensive node collection for data manipulation in ComfyUI workflows. + +Supported data types: +- ComfyUI native: BOOLEAN, FLOAT, INT, STRING, and data lists +- Python types as custom data types: DICT, LIST, SET + +Feature categories: +- Boolean logic operations (and, or, not, xor, nand, nor) +- Type casting/conversion between all supported data types +- Comparison operations (equality, numerical comparison, range checking) +- Data structures manipulation (data lists, LIST, DICT, SET) +- Flow control (conditionals, branching, execution order) +- Mathematical operations (arithmetic, trigonometry, logarithmic functions) +- String manipulation (case conversion, formatting, splitting, validation) +- File system path handling (path operations, information, searching) +- SET operations (creation, modification, comparison, mathematical set theory) + +All nodes are lightweight with no additional dependencies required.""" authors = [ {name = "StableLlama"} ] diff --git a/src/basic_data_handling/data_list_nodes.py b/src/basic_data_handling/data_list_nodes.py index 583f76d..c3ffa58 100644 --- a/src/basic_data_handling/data_list_nodes.py +++ b/src/basic_data_handling/data_list_nodes.py @@ -29,7 +29,7 @@ class DataListCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.ANY, {"_dynamic": "number"}), + "item_0": (IO.ANY, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -41,7 +41,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return (list(kwargs.values()),) + values = list(kwargs.values()) + return (values[:-1],) class DataListCreateFromBoolean(ComfyNodeABC): @@ -56,7 +57,7 @@ class DataListCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.BOOLEAN, {"_dynamic": "number"}), + "item_0": (IO.BOOLEAN, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -68,7 +69,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return ([bool(value) for value in kwargs.values()],) + values = [bool(value) for value in kwargs.values()] + return (values[:-1],) class DataListCreateFromFloat(ComfyNodeABC): @@ -83,7 +85,7 @@ class DataListCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.FLOAT, {"_dynamic": "number"}), + "item_0": (IO.FLOAT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -95,7 +97,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return ([float(value) for value in kwargs.values()],) + values = [float(value) for value in kwargs.values()] + return (values[:-1],) class DataListCreateFromInt(ComfyNodeABC): @@ -110,7 +113,7 @@ class DataListCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.INT, {"_dynamic": "number"}), + "item_0": (IO.INT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -122,7 +125,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list]: - return ([int(value) for value in kwargs.values()],) + values = [int(value) for value in kwargs.values()] + return (values[:-1],) class DataListCreateFromString(ComfyNodeABC): @@ -137,7 +141,7 @@ class DataListCreateFromString(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.STRING, {"_dynamic": "number"}), + "item_0": (IO.STRING, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -149,7 +153,8 @@ def INPUT_TYPES(cls): OUTPUT_IS_LIST = (True,) def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([str(value) for value in kwargs.values()],) + values = [str(value) for value in kwargs.values()] + return (values[:-1],) class DataListAppend(ComfyNodeABC): @@ -806,7 +811,6 @@ def slice(self, **kwargs: list[Any]) -> tuple[list[Any]]: start = kwargs.get('start', [0])[0] stop = kwargs.get('stop', [INT_MAX])[0] step = kwargs.get('step', [1])[0] - print(f"start: {start}, stop: {stop}, step: {step}; input_list: {input_list}") return (input_list[start:stop:step],) diff --git a/src/basic_data_handling/dict_nodes.py b/src/basic_data_handling/dict_nodes.py index c2f0871..1b4a8f9 100644 --- a/src/basic_data_handling/dict_nodes.py +++ b/src/basic_data_handling/dict_nodes.py @@ -27,8 +27,8 @@ class DictCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.ANY, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.ANY, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -40,7 +40,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -59,8 +59,8 @@ class DictCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.BOOLEAN, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.BOOLEAN, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -72,7 +72,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -91,8 +91,8 @@ class DictCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.FLOAT, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.FLOAT, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -104,7 +104,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -123,8 +123,8 @@ class DictCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.INT, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.INT, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -136,7 +136,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: @@ -155,8 +155,8 @@ class DictCreateFromString(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), - "value_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0}), + "key_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), + "value_0": (IO.STRING, {"_dynamic": "number", "_dynamicGroup": 0, "widgetType": "STRING"}), }) } @@ -168,7 +168,7 @@ def INPUT_TYPES(cls): def create(self, **kwargs: list[Any]) -> tuple[dict]: result = {} # Process all key_X/value_X pairs from dynamic inputs - for i in range(len(kwargs) // 2): # Divide by 2 since we have key/value pairs + for i in range(len(kwargs) // 2 - 1): # Divide by 2 since we have key/value pairs key_name = f"key_{i}" value_name = f"value_{i}" if key_name in kwargs and value_name in kwargs: diff --git a/src/basic_data_handling/list_nodes.py b/src/basic_data_handling/list_nodes.py index 49fe20f..cb35268 100644 --- a/src/basic_data_handling/list_nodes.py +++ b/src/basic_data_handling/list_nodes.py @@ -29,7 +29,7 @@ class ListCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.ANY, {"_dynamic": "number"}), + "item_0": (IO.ANY, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -39,7 +39,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return (list(kwargs.values()),) + values = list(kwargs.values()) + return (values[:-1],) class ListCreateFromBoolean(ComfyNodeABC): @@ -54,7 +55,7 @@ class ListCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.BOOLEAN, {"_dynamic": "number"}), + "item_0": (IO.BOOLEAN, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -64,7 +65,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([bool(value) for value in kwargs.values()],) + values = [bool(value) for value in kwargs.values()] + return (values[:-1],) class ListCreateFromFloat(ComfyNodeABC): @@ -79,7 +81,7 @@ class ListCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.FLOAT, {"_dynamic": "number"}), + "item_0": (IO.FLOAT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -89,7 +91,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([float(value) for value in kwargs.values()],) + values = [float(value) for value in list(kwargs.values())[:-1]] + return (values,) class ListCreateFromInt(ComfyNodeABC): @@ -104,7 +107,7 @@ class ListCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.INT, {"_dynamic": "number"}), + "item_0": (IO.INT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -114,7 +117,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([int(value) for value in kwargs.values()],) + values = [int(value) for value in list(kwargs.values())[:-1]] + return (values,) class ListCreateFromString(ComfyNodeABC): @@ -139,7 +143,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_list" def create_list(self, **kwargs: list[Any]) -> tuple[list[Any]]: - return ([str(value) for value in kwargs.values()],) + values = [str(value) for value in list(kwargs.values())[:-1]] + return (values,) class ListAppend(ComfyNodeABC): diff --git a/src/basic_data_handling/set_nodes.py b/src/basic_data_handling/set_nodes.py index 7bebdd9..bb52ef8 100644 --- a/src/basic_data_handling/set_nodes.py +++ b/src/basic_data_handling/set_nodes.py @@ -27,7 +27,7 @@ class SetCreate(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.ANY, {"_dynamic": "number"}), + "item_0": (IO.ANY, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -37,7 +37,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set(kwargs.values()),) + values = list(kwargs.values())[:-1] + return (set(values),) class SetCreateFromBoolean(ComfyNodeABC): @@ -52,7 +53,7 @@ class SetCreateFromBoolean(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.BOOLEAN, {"_dynamic": "number"}), + "item_0": (IO.BOOLEAN, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -62,7 +63,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([bool(value) for value in kwargs.values()]),) + values = [bool(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetCreateFromFloat(ComfyNodeABC): @@ -77,7 +79,7 @@ class SetCreateFromFloat(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.FLOAT, {"_dynamic": "number"}), + "item_0": (IO.FLOAT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -87,7 +89,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([float(value) for value in kwargs.values()]),) + values = [float(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetCreateFromInt(ComfyNodeABC): @@ -102,7 +105,7 @@ class SetCreateFromInt(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.INT, {"_dynamic": "number"}), + "item_0": (IO.INT, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -112,7 +115,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([int(value) for value in kwargs.values()]),) + values = [int(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetCreateFromString(ComfyNodeABC): @@ -127,7 +131,7 @@ class SetCreateFromString(ComfyNodeABC): def INPUT_TYPES(cls): return { "optional": ContainsDynamicDict({ - "item_0": (IO.STRING, {"_dynamic": "number"}), + "item_0": (IO.STRING, {"_dynamic": "number", "widgetType": "STRING"}), }) } @@ -137,7 +141,8 @@ def INPUT_TYPES(cls): FUNCTION = "create_set" def create_set(self, **kwargs: list[Any]) -> tuple[set[Any]]: - return (set([str(value) for value in kwargs.values()]),) + values = [str(value) for value in list(kwargs.values())[:-1]] + return (set(values),) class SetAdd(ComfyNodeABC): diff --git a/src/basic_data_handling/string_nodes.py b/src/basic_data_handling/string_nodes.py index 8e59d29..3fa6f26 100644 --- a/src/basic_data_handling/string_nodes.py +++ b/src/basic_data_handling/string_nodes.py @@ -890,6 +890,74 @@ def removesuffix(self, string, suffix): return (string.removesuffix(suffix),) +class StringUnescape(ComfyNodeABC): + """ + Unescapes a string by converting escape sequences to their actual characters. + + This node converts escape sequences like '\n' (two characters) to actual newlines (one character), + '\t' to tabs, '\\' to backslashes, etc. Useful for processing strings where escape sequences + are represented literally rather than interpreted. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "string": (IO.STRING, {"default": ""}), + } + } + + RETURN_TYPES = (IO.STRING,) + CATEGORY = "Basic/STRING" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "unescape" + + def unescape(self, string): + # Decode escaped sequences only for control characters + result = ( + string + .replace(r'\\', '\u0000') + .replace(r'\n', '\n') + .replace(r'\t', '\t') + .replace(r'\"', '"') + .replace(r'\'', "'") + .replace('\u0000', '\\') + ) + return (result,) + + +class StringEscape(ComfyNodeABC): + """ + Escapes a string by converting special characters to escape sequences. + + This node converts characters like newlines, tabs, quotes, and backslashes to their escaped + representation (like '\n', '\t', '\"', '\\'). Useful when you need to prepare strings + for formats that require escaped sequences instead of literal special characters. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "string": (IO.STRING, {"default": ""}), + } + } + + RETURN_TYPES = (IO.STRING,) + CATEGORY = "Basic/STRING" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "escape" + + def escape(self, string): + result = ( + string + .replace('\\', r'\\') + .replace('\n', r'\n') + .replace('\t', r'\t') + .replace('"', r'\"') + .replace("'", r"\'") + ) + return (result,) + + class StringReplace(ComfyNodeABC): """ Replaces occurrences of a substring with another substring. @@ -1384,6 +1452,7 @@ def zfill(self, string, width): "Basic data handling: StringDecode": StringDecode, "Basic data handling: StringEncode": StringEncode, "Basic data handling: StringEndswith": StringEndswith, + "Basic data handling: StringEscape": StringEscape, "Basic data handling: StringExpandtabs": StringExpandtabs, "Basic data handling: StringFind": StringFind, "Basic data handling: StringFormatMap": StringFormatMap, @@ -1422,6 +1491,7 @@ def zfill(self, string, width): "Basic data handling: StringStrip": StringStrip, "Basic data handling: StringSwapcase": StringSwapcase, "Basic data handling: StringTitle": StringTitle, + "Basic data handling: StringUnescape": StringUnescape, "Basic data handling: StringUpper": StringUpper, "Basic data handling: StringZfill": StringZfill, } @@ -1435,6 +1505,7 @@ def zfill(self, string, width): "Basic data handling: StringDecode": "decode", "Basic data handling: StringEncode": "encode", "Basic data handling: StringEndswith": "endswith", + "Basic data handling: StringEscape": "escape", "Basic data handling: StringExpandtabs": "expandtabs", "Basic data handling: StringFind": "find", "Basic data handling: StringFormatMap": "format_map", @@ -1473,6 +1544,7 @@ def zfill(self, string, width): "Basic data handling: StringStrip": "strip", "Basic data handling: StringSwapcase": "swapcase", "Basic data handling: StringTitle": "title", + "Basic data handling: StringUnescape": "unescape", "Basic data handling: StringUpper": "upper", "Basic data handling: StringZfill": "zfill", } diff --git a/tests/test_dict_nodes.py b/tests/test_dict_nodes.py index 7b21d50..f314a0d 100644 --- a/tests/test_dict_nodes.py +++ b/tests/test_dict_nodes.py @@ -55,7 +55,7 @@ def test_dict_set(): def test_dict_create_from_boolean(): node = DictCreateFromBoolean() # Test with dynamic inputs - result = node.create(key_0="key1", value_0=True, key_1="key2", value_1=False) + result = node.create(key_0="key1", value_0=True, key_1="key2", value_1=False, key_2="", value_2="") assert result == ({"key1": True, "key2": False},) # Test with empty inputs assert node.create() == ({},) @@ -64,7 +64,7 @@ def test_dict_create_from_boolean(): def test_dict_create_from_float(): node = DictCreateFromFloat() # Test with dynamic inputs - result = node.create(key_0="key1", value_0=1.5, key_1="key2", value_1=2.5) + result = node.create(key_0="key1", value_0=1.5, key_1="key2", value_1=2.5, key_2="", value_2="") assert result == ({"key1": 1.5, "key2": 2.5},) # Test with empty inputs assert node.create() == ({},) @@ -73,7 +73,7 @@ def test_dict_create_from_float(): def test_dict_create_from_int(): node = DictCreateFromInt() # Test with dynamic inputs - result = node.create(key_0="key1", value_0=1, key_1="key2", value_1=2) + result = node.create(key_0="key1", value_0=1, key_1="key2", value_1=2, key_2="", value_2="") assert result == ({"key1": 1, "key2": 2},) # Test with empty inputs assert node.create() == ({},) @@ -82,7 +82,7 @@ def test_dict_create_from_int(): def test_dict_create_from_string(): node = DictCreateFromString() # Test with dynamic inputs - result = node.create(key_0="key1", value_0="value1", key_1="key2", value_1="value2") + result = node.create(key_0="key1", value_0="value1", key_1="key2", value_1="value2", key_2="", value_2="") assert result == ({"key1": "value1", "key2": "value2"},) # Test with empty inputs assert node.create() == ({},) diff --git a/tests/test_list_nodes.py b/tests/test_list_nodes.py index 2c8abbf..668fba8 100644 --- a/tests/test_list_nodes.py +++ b/tests/test_list_nodes.py @@ -176,36 +176,36 @@ def test_list_max(): def test_list_create(): node = ListCreate() - assert node.create_list(item_0=1, item_1=2, item_2=3) == ([1, 2, 3],) + assert node.create_list(item_0=1, item_1=2, item_2=3, item_3="") == ([1, 2, 3],) assert node.create_list() == ([],) # Empty list - assert node.create_list(item_0="test") == (["test"],) + assert node.create_list(item_0="test", item_1="") == (["test"],) def test_list_create_from_boolean(): node = ListCreateFromBoolean() - assert node.create_list(item_0=True, item_1=False, item_2=True) == ([True, False, True],) - assert node.create_list(item_0=True) == ([True],) + assert node.create_list(item_0=True, item_1=False, item_2=True, item_3="") == ([True, False, True],) + assert node.create_list(item_0=True, item_1="") == ([True],) assert node.create_list() == ([],) def test_list_create_from_float(): node = ListCreateFromFloat() - assert node.create_list(item_0=1.1, item_1=2.2, item_2=3.3) == ([1.1, 2.2, 3.3],) - assert node.create_list(item_0=0.0) == ([0.0],) + assert node.create_list(item_0=1.1, item_1=2.2, item_2=3.3, item_3="") == ([1.1, 2.2, 3.3],) + assert node.create_list(item_0=0.0, item_1="") == ([0.0],) assert node.create_list() == ([],) def test_list_create_from_int(): node = ListCreateFromInt() - assert node.create_list(item_0=1, item_1=2, item_2=3) == ([1, 2, 3],) - assert node.create_list(item_0=0) == ([0],) + assert node.create_list(item_0=1, item_1=2, item_2=3, item_3="") == ([1, 2, 3],) + assert node.create_list(item_0=0, item_1="") == ([0],) assert node.create_list() == ([],) def test_list_create_from_string(): node = ListCreateFromString() - assert node.create_list(item_0="a", item_1="b", item_2="c") == (["a", "b", "c"],) - assert node.create_list(item_0="test") == (["test"],) + assert node.create_list(item_0="a", item_1="b", item_2="c", item_3="") == (["a", "b", "c"],) + assert node.create_list(item_0="test", item_1="") == (["test"],) assert node.create_list() == ([],) diff --git a/tests/test_set_nodes.py b/tests/test_set_nodes.py index 83a91d9..6ad37a1 100644 --- a/tests/test_set_nodes.py +++ b/tests/test_set_nodes.py @@ -26,29 +26,29 @@ def test_set_create(): node = SetCreate() # Testing with kwargs to simulate dynamic inputs - assert node.create_set(item_0=1, item_1=2, item_2=3) == ({1, 2, 3},) - assert node.create_set(item_0="a", item_1="b") == ({"a", "b"},) + assert node.create_set(item_0=1, item_1=2, item_2=3, item_3="") == ({1, 2, 3},) + assert node.create_set(item_0="a", item_1="b", item_2="") == ({"a", "b"},) assert node.create_set() == (set(),) # Empty set with no arguments # Mixed types - assert node.create_set(item_0=1, item_1="b", item_2=True) == ({1, "b", True},) + assert node.create_set(item_0=1, item_1="b", item_2=True, item_3="") == ({1, "b", True},) def test_set_create_from_int(): node = SetCreateFromInt() - assert node.create_set(item_0=1, item_1=2, item_2=3) == ({1, 2, 3},) - assert node.create_set(item_0=5) == ({5},) # Single item set - assert node.create_set(item_0=1, item_1=1) == ({1},) # Duplicate items become single item + assert node.create_set(item_0=1, item_1=2, item_2=3, item_3="") == ({1, 2, 3},) + assert node.create_set(item_0=5, item_1="") == ({5},) # Single item set + assert node.create_set(item_0=1, item_1=1, item_2="") == ({1},) # Duplicate items become single item assert node.create_set() == (set(),) # Empty set with no arguments def test_set_create_from_string(): node = SetCreateFromString() - result = node.create_set(item_0="apple", item_1="banana") + result = node.create_set(item_0="apple", item_1="banana", item_2="") assert isinstance(result[0], set) assert result[0] == {"apple", "banana"} # Duplicate strings - result = node.create_set(item_0="apple", item_1="apple") + result = node.create_set(item_0="apple", item_1="apple", item_2="") assert result[0] == {"apple"} # Empty set @@ -57,19 +57,19 @@ def test_set_create_from_string(): def test_set_create_from_float(): node = SetCreateFromFloat() - assert node.create_set(item_0=1.5, item_1=2.5) == ({1.5, 2.5},) - assert node.create_set(item_0=3.14) == ({3.14},) # Single item set - assert node.create_set(item_0=1.0, item_1=1.0) == ({1.0},) # Duplicate items + assert node.create_set(item_0=1.5, item_1=2.5, item_2="") == ({1.5, 2.5},) + assert node.create_set(item_0=3.14, item_1="") == ({3.14},) # Single item set + assert node.create_set(item_0=1.0, item_1=1.0, item_2="") == ({1.0},) # Duplicate items assert node.create_set() == (set(),) # Empty set with no arguments def test_set_create_from_boolean(): node = SetCreateFromBoolean() - assert node.create_set(item_0=True, item_1=False) == ({True, False},) - assert node.create_set(item_0=True, item_1=True) == ({True},) # Duplicate booleans + assert node.create_set(item_0=True, item_1=False, item_2="") == ({True, False},) + assert node.create_set(item_0=True, item_1=True, item_2="") == ({True},) # Duplicate booleans assert node.create_set() == (set(),) # Empty set with no arguments # Test conversion from non-boolean values - assert node.create_set(item_0=1, item_1=0) == ({True, False},) + assert node.create_set(item_0=1, item_1=0, item_2="") == ({True, False},) def test_set_add(): diff --git a/tests/test_string_nodes.py b/tests/test_string_nodes.py index 61897d3..eec010f 100644 --- a/tests/test_string_nodes.py +++ b/tests/test_string_nodes.py @@ -10,6 +10,7 @@ StringDecode, StringEncode, StringEndswith, + StringEscape, StringExpandtabs, StringFind, StringFormatMap, @@ -47,6 +48,7 @@ StringStrip, StringSwapcase, StringTitle, + StringUnescape, StringUpper, StringZfill, ) @@ -364,6 +366,38 @@ def test_zfill(): assert node.zfill("123", 2) == ("123",) # Width smaller than string length assert node.zfill("", 3) == ("000",) # Empty string +def test_unescape(): + node = StringUnescape() + # Test control characters + assert node.unescape(r"Hello\nWorld") == ("Hello\nWorld",) # Newline + assert node.unescape(r"Hello\tWorld") == ("Hello\tWorld",) # Tab + assert node.unescape(r"C:\\Program Files\\App") == (r"C:\Program Files\App",) # Backslash + assert node.unescape(r"Quote: \"Text\"") == ("Quote: \"Text\"",) # Double quote + assert node.unescape(r"Quote: \'Text\'") == ("Quote: 'Text'",) # Single quote + + # Test that normal Unicode characters remain unchanged + assert node.unescape("German: äöüß") == ("German: äöüß",) # Umlauts should be preserved + assert node.unescape("Hello äöü\nWorld") == ("Hello äöü\nWorld",) # Umlauts with control char + + # Test complex string with multiple escape sequences + assert node.unescape(r"Path: C:\\folder\\file.txt\nLine1\tLine2") == ("Path: C:\\folder\\file.txt\nLine1\tLine2",) + +def test_escape(): + node = StringEscape() + # Test control characters + assert node.escape("Hello\nWorld") == (r"Hello\nWorld",) # Newline + assert node.escape("Hello\tWorld") == (r"Hello\tWorld",) # Tab + assert node.escape(r"C:\Program Files\App") == (r"C:\\Program Files\\App",) # Backslash + assert node.escape('Quote: "Text"') == (r'Quote: \"Text\"',) # Double quote + assert node.escape("Quote: 'Text'") == (r"Quote: \'Text\'",) # Single quote + + # Test that normal Unicode characters remain unchanged + assert node.escape("German: äöüß") == (r"German: äöüß",) # Umlauts should be preserved + assert node.escape("Hello äöü\nWorld") == (r"Hello äöü\nWorld",) # Umlauts with control char + + # Test complex string with multiple special characters + assert node.escape("Path: C:\\folder\\file.txt\nLine1\tLine2") == (r"Path: C:\\folder\\file.txt\nLine1\tLine2",) + def test_length(): node = StringLength() assert node.length("hello") == (5,) diff --git a/web/js/dynamicnode.js b/web/js/dynamicnode.js index 7db19b2..a3b170c 100644 --- a/web/js/dynamicnode.js +++ b/web/js/dynamicnode.js @@ -33,261 +33,489 @@ const TypeSlotEvent = { Disconnect: false }; +// Helper for escaping strings to be used in RegExp +const escapeRegExp = (string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +}; + + app.registerExtension({ name: 'Basic data handling: dynamic input', - async beforeRegisterNodeDef(nodeType, nodeData, app) { - // Filter: Only process nodes with class names starting with "Basic data handling:" + async beforeRegisterNodeDef(nodeType, nodeData, appInstance) { if (!nodeType.comfyClass.startsWith('Basic data handling:')) { return; } const combinedInputData = { - ...nodeData?.input?.required ?? {}, - ...nodeData?.input?.optional ?? {} + ...(nodeData?.input?.required ?? {}), + ...(nodeData?.input?.optional ?? {}) + }; + + let combinedInputDataOrder = []; + if (nodeData.input_order) { + if (nodeData.input_order.required) combinedInputDataOrder.push(...nodeData.input_order.required); + if (nodeData.input_order.optional) combinedInputDataOrder.push(...nodeData.input_order.optional); + } else if (nodeData.input) { + if (nodeData.input.required) combinedInputDataOrder.push(...Object.keys(nodeData.input.required)); + if (nodeData.input.optional) combinedInputDataOrder.push(...Object.keys(nodeData.input.optional)); } - const combinedInputDataOrder = [ - ...nodeData?.input_order?.required ?? [], - ...nodeData?.input_order?.optional ?? [] - ]; - /** Array of (generic) dynamic inputs. */ const dynamicInputs = []; - /** Array of groups of (generic) dynamic inputs. */ const dynamicInputGroups = []; + for (const name of combinedInputDataOrder) { - const dynamic = combinedInputData[name][1]?._dynamic; + if (!combinedInputData[name]) continue; + + const dynamicSetting = combinedInputData[name][1]?._dynamic; const dynamicGroup = combinedInputData[name][1]?._dynamicGroup ?? 0; - if (dynamic) { - let matcher; + if (dynamicSetting) { // Only process for inputs marked with _dynamic + const inputOptions = combinedInputData[name][1] || {}; + const dynamicComfyType = combinedInputData[name][0]; - switch (dynamic) { - case 'number': - matcher = new RegExp(`^(${name.replace(/\d*$/, '')})(\\d+)$`); - break; + let isActuallyWidget = false; + let effectiveWidgetGuiType; - case 'letter': - matcher = new RegExp(`^()([a-zA-Z])$`); - break; + if (inputOptions.widget?.type) { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions.widget.type; + } else if (inputOptions.widget && typeof inputOptions.widget === 'string') { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions.widget; + } + else if (inputOptions._widgetType) { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions._widgetType; + } else if (inputOptions.widgetType) { + isActuallyWidget = true; + effectiveWidgetGuiType = inputOptions.widgetType; + } + else { + const comfyTypeUpper = String(dynamicComfyType).toUpperCase(); + const implicitWidgetGuiTypesMap = { + "STRING": "text", "INT": "number", "FLOAT": "number", + "NUMBER": "number", "BOOLEAN": "toggle" + }; + + if (implicitWidgetGuiTypesMap[comfyTypeUpper] && inputOptions.forceInput !== true && dynamicComfyType !== '*') { + isActuallyWidget = true; + effectiveWidgetGuiType = implicitWidgetGuiTypesMap[comfyTypeUpper]; + } else if (comfyTypeUpper === "COMBO" && inputOptions.values) { + isActuallyWidget = true; + effectiveWidgetGuiType = "combo"; + } else { + isActuallyWidget = false; + effectiveWidgetGuiType = dynamicComfyType; + } + } - default: - continue; + let determinedBaseName; + let suffixExtractionMatcher; + + if (dynamicSetting === 'number') { + const r = /^(.*?)(\d+)$/; + const m = name.match(r); + determinedBaseName = (m && m[1] !== undefined) ? m[1] : name; + suffixExtractionMatcher = new RegExp(`^(${escapeRegExp(determinedBaseName)})(\\d*)$`); + } else if (dynamicSetting === 'letter') { + const r = /^(.*?)([a-zA-Z])$/; + const m = name.match(r); + determinedBaseName = (m && m[1] !== undefined) ? m[1] : name; + suffixExtractionMatcher = new RegExp(`^(${escapeRegExp(determinedBaseName)})([a-zA-Z])$`); + } else { + console.warn(`[Basic Data Handling] Unknown dynamic type: ${dynamicSetting} for input ${name} on node ${nodeType.comfyClass}`); + continue; } - const baseName = name.match(matcher)?.[1] ?? name; - const dynamicType = combinedInputData[name][0]; + if (dynamicInputGroups[dynamicGroup] === undefined) { dynamicInputGroups[dynamicGroup] = []; } - dynamicInputGroups[dynamicGroup].push( - dynamicInputs.length - ) - dynamicInputs.push({name, baseName, matcher, dynamic, dynamicType, dynamicGroup}); + dynamicInputGroups[dynamicGroup].push(dynamicInputs.length); + + dynamicInputs.push({ + name, + baseName: determinedBaseName, + matcher: suffixExtractionMatcher, + dynamic: dynamicSetting, + dynamicComfyType, + dynamicGroup, + isWidget: isActuallyWidget, + actualWidgetGuiType: effectiveWidgetGuiType, + originalOptions: inputOptions + }); } } + if (dynamicInputs.length === 0) { return; } - /** - * Utility: Check if an input is dynamic. - * @param {string} inputName - Name of the input to check. - */ - const isDynamicInput = (inputName) => - dynamicInputs.some((di) => di.matcher.test(inputName)); - - /** - * Utility: Update inputs' slot indices after reordering. - * @param {ComfyNode} node - The node to update. - */ + const getDynamicInputDefinition = (inputName) => { + for (const di of dynamicInputs) { + const m = inputName.match(di.matcher); + if (m && m[1] === di.baseName) { + return { definition: di, suffix: m[2] || "" }; + } + } + return null; + }; + + const isDynamicInput = (inputName) => !!getDynamicInputDefinition(inputName); + const updateSlotIndices = (node) => { node.inputs.forEach((input, index) => { input.slot_index = index; - if (input.isConnected) { - const link = node.graph._links.get(input.link); - if (link) { - link.target_slot = index; - } else { - console.error(`Input ${index} has an invalid link.`); + if (input.link !== null && input.link !== undefined) { + const linkInfo = node.graph.links[input.link]; + if (linkInfo) { + linkInfo.target_slot = index; } } }); }; + const getWidgetDefaultValue = (widgetDef) => { // widgetDef = { type: actualWidgetGuiType, options: originalOptions } + const type = widgetDef.type; + const opts = widgetDef.options || {}; + + switch (String(type).toLowerCase()) { + case 'number': + return opts.default ?? 0; + case 'combo': + return opts.default ?? (opts.values && opts.values.length > 0 ? opts.values[0] : ''); + case 'text': + case 'string': + return opts.default ?? ''; + case 'toggle': + case 'boolean': + return opts.default ?? false; + default: + return opts.default ?? ''; + } + }; - // Add helper method to insert input at a specific position - nodeType.prototype.addInputAtPosition = function (name, type, position, isWidget, shape) { - if (isWidget) { - this.addWidget(type, name, '', ()=>{}, {}); + const isDynamicInputEmpty = (node, inputIndex) => { + const input = node.inputs[inputIndex]; + if (input.link !== null && input.link !== undefined) return false; + + const diData = getDynamicInputDefinition(input.name); + if (diData && diData.definition.isWidget) { + if (input.widget && input.widget.name) { + const widget = node.widgets.find(w => w.name === input.widget.name); + if (widget) { + return widget.value === getWidgetDefaultValue({ + type: diData.definition.actualWidgetGuiType, + options: diData.definition.originalOptions + }); + } else { + return true; + } + } else { + return true; + } + } + return true; + }; + + const removeWidgetForInput = (node, inputIdx) => { + if (node.inputs[inputIdx].widget && node.inputs[inputIdx].widget.name) { + const widgetName = node.inputs[inputIdx].widget.name; + const widgetIdx = node.widgets.findIndex((w) => w.name === widgetName); + if (widgetIdx !== -1) { + node.widgets.splice(widgetIdx, 1); + } + } + }; + + nodeType.prototype.getDynamicGroup = function(inputName) { + const diData = getDynamicInputDefinition(inputName); + return diData ? diData.definition.dynamicGroup : undefined; + }; + + const addStandardWidget = function(name, widgetGuiType, defaultValue, fullConfigOptions = {}) { + let currentVal = defaultValue; + const widgetSpecificOpts = { ...fullConfigOptions }; + + if (String(widgetGuiType).toLowerCase() === "combo") { + if (!widgetSpecificOpts.values && Array.isArray(fullConfigOptions.default)) { + widgetSpecificOpts.values = fullConfigOptions.default; + } else if (!widgetSpecificOpts.values && fullConfigOptions.options?.values) { + widgetSpecificOpts.values = fullConfigOptions.options.values; + } - const GET_CONFIG = Symbol(); - const input = this.addInput(name, type, { - shape, - widget: {name, [GET_CONFIG]: () =>{}} - }) + if (!widgetSpecificOpts.values) { + widgetSpecificOpts.values = [currentVal]; + } + + if (widgetSpecificOpts.values && !widgetSpecificOpts.values.includes(currentVal) && widgetSpecificOpts.values.length > 0) { + currentVal = widgetSpecificOpts.values[0]; + } + } + return this.addWidget(widgetGuiType, name, currentVal, () => {}, widgetSpecificOpts); + }; + + nodeType.prototype.addInputAtPosition = function (name, comfyInputType, widgetGuiType, position, isWidgetFlag, shape, widgetDefaultVal, widgetConfOpts) { + if (isWidgetFlag) { + addStandardWidget.call(this, name, widgetGuiType, widgetDefaultVal, widgetConfOpts); + this.addInput(name, comfyInputType, { shape, widget: { name } }); } else { - this.addInput(name, type, {shape}); // Add new input + this.addInput(name, comfyInputType, { shape }); + } + + const newInputIndex = this.inputs.length - 1; + if (position < newInputIndex && position < this.inputs.length -1 ) { + const newInput = this.inputs.pop(); + this.inputs.splice(position, 0, newInput); } - const newInput = this.inputs.pop(); // Fetch the newly added input (last item) - this.inputs.splice(position, 0, newInput); // Place it at the desired position - updateSlotIndices(this); // Update indices - return newInput; + updateSlotIndices(this); + return this.inputs[position]; }; - // flag to prevent loops. It is ok to be "global" as the code is not - // running in parallel. let isProcessingConnection = false; - // Override onConnectionsChange: Handle connections for dynamic inputs - const onConnectionsChange = nodeType.prototype.onConnectionsChange; - nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, link, ioSlot) { - const result = onConnectionsChange?.apply(this, arguments); + const generateDynamicInputName = (dynamicBehavior, baseName, count) => { + if (dynamicBehavior === 'letter') { + return `${baseName}${String.fromCharCode(97 + count)}`; + } else { // 'number' + return `${baseName}${count}`; + } + }; - if (type !== TypeSlot.Input || isProcessingConnection || !isDynamicInput(this.inputs[slotIndex].name)) { - return result; + nodeType.prototype.renumberDynamicInputs = function(baseNameToRenumber, dynamicBehavior) { + const inputsToRenumber = []; + for (let i = 0; i < this.inputs.length; i++) { + const input = this.inputs[i]; + const diData = getDynamicInputDefinition(input.name); + if (diData && diData.definition.baseName === baseNameToRenumber && diData.definition.dynamic === dynamicBehavior) { + inputsToRenumber.push({ + inputRef: input, + widgetName: input.widget ? input.widget.name : null + }); + } } - function getDynamicGroup(inputName) { - // Find the dynamicGroup by matching the baseName with input name - for (const di of dynamicInputs) { - if (inputName.startsWith(di.baseName)) { - return di.dynamicGroup; + for (let i = 0; i < inputsToRenumber.length; i++) { + const { inputRef, widgetName } = inputsToRenumber[i]; + const newName = generateDynamicInputName(dynamicBehavior, baseNameToRenumber, i); + + if (inputRef.name === newName) continue; + + if (widgetName) { + const widget = this.widgets.find(w => w.name === widgetName); + if (widget) { + widget.name = newName; } + if (inputRef.widget) inputRef.widget.name = newName; } - return undefined; + inputRef.name = newName; } + updateSlotIndices(this); + }; - isProcessingConnection = true; + const handleEmptyDynamicInput = function() { // (Content largely unchanged from previous good version, ensure it uses new DI props if needed) + let overallChangesMade = false; + let scanAgainPass = true; - try { - // Get dynamic input slots - const dynamicSlots = []; - const dynamicGroupCount = []; - const dynamicGroupConnected = []; - for (const [index, input] of this.inputs.entries()) { - const isDynamic = isDynamicInput(input.name); - if (isDynamic) { - const connected = input.isConnected; - const dynamicGroup = getDynamicGroup(input.name); - if (dynamicGroup in dynamicGroupCount) { - if (input.name.startsWith(dynamicInputs[dynamicInputGroups[dynamicGroup][0]].baseName)) { - dynamicGroupConnected[dynamicGroup][dynamicGroupCount[dynamicGroup]] ||= connected; - dynamicGroupCount[dynamicGroup]++; - } + while (scanAgainPass) { + scanAgainPass = false; + const dynamicGroupInfo = new Map(); + + for (let i = 0; i < this.inputs.length; i++) { + const currentInput = this.inputs[i]; + const diData = getDynamicInputDefinition(currentInput.name); + + if (!diData) continue; + const { definition: di, suffix } = diData; + const group = di.dynamicGroup; + + if (!dynamicGroupInfo.has(group)) { + dynamicGroupInfo.set(group, { items: new Map() }); + } + const groupData = dynamicGroupInfo.get(group); + if (!groupData.items.has(suffix)) { + groupData.items.set(suffix, { inputsInfo: [], allEmpty: true }); + } + const itemData = groupData.items.get(suffix); + itemData.inputsInfo.push({ diDefinition: di, originalInput: currentInput, originalIndex: i }); + if (!isDynamicInputEmpty(this, i)) { // isDynamicInputEmpty uses new DI props + itemData.allEmpty = false; + } + } + + for (const [groupId, groupData] of dynamicGroupInfo) { + const { items } = groupData; + if (items.size === 0) continue; + + const sortedItemSuffixes = Array.from(items.keys()).sort((a, b) => { + const numA = parseInt(a, 10); + const numB = parseInt(b, 10); + if (!isNaN(numA) && !isNaN(numB)) return numA - numB; + if (a.length !== b.length) return a.length - b.length; + return String(a).localeCompare(String(b)); + }); + + let activeItemCount = 0; + const emptyItemSuffixes = []; + for (const suffix of sortedItemSuffixes) { + if (items.get(suffix).allEmpty) { + emptyItemSuffixes.push(suffix); } else { - dynamicGroupCount[dynamicGroup] = 1; - dynamicGroupConnected[dynamicGroup] = [connected]; + activeItemCount++; } - dynamicSlots.push({ - index, - name: input.name, - isWidget: input.widget !== undefined, - shape: input.shape, - connected, - isDynamic, - dynamicGroup, - dynamicGroupCount: dynamicGroupCount[dynamicGroup] - }); + } - // sanity check to make sure every widget is in reality a widget. When loading a workflow this - // isn't the case so we must fix it ourselves. - if (this.widgets && !this.widgets.some((w) => w.name === input.name)) { - this.addWidget(input.type, input.name, '', ()=>{}, {}); + if (emptyItemSuffixes.length === 0) continue; + + let placeholderSuffix = null; + if (activeItemCount === 0 && sortedItemSuffixes.length > 0) { + placeholderSuffix = sortedItemSuffixes[0]; + } else if (activeItemCount > 0 && emptyItemSuffixes.length > 0) { + placeholderSuffix = emptyItemSuffixes[emptyItemSuffixes.length - 1]; + } + + const inputsToRemoveDetails = []; + for (const suffix of emptyItemSuffixes) { + if (suffix !== placeholderSuffix) { + const itemData = items.get(suffix); + for (const inputDetail of itemData.inputsInfo) { + inputsToRemoveDetails.push(inputDetail); + } } - } - } - - // Handle connection event - if (isConnected === TypeSlotEvent.Connect) { - const hasEmptyDynamic = dynamicGroupConnected[0].some(dgc => !dgc); - - if (!hasEmptyDynamic) { - // No empty slot - add a new one after the last dynamic input - const lastDynamicIdx = Math.max(...dynamicSlots.map((slot) => slot.index), -1); - let insertPosition = lastDynamicIdx + 1; - let inputInRange = true; - - for (const groupMember of dynamicInputGroups[dynamicInputs[0].dynamicGroup]) { - const baseName = dynamicInputs[groupMember].baseName; - const dynamicType = dynamicInputs[groupMember].dynamicType; - let newName; - if (dynamicInputs[0].dynamic === 'letter') { - if (dynamicSlots.length >= 26) { - inputInRange = false; - } - // For letter type, use the next letter in sequence - newName = String.fromCharCode(97 + dynamicSlots.length); // 97 is ASCII for 'a' + } + + if (inputsToRemoveDetails.length > 0) { + inputsToRemoveDetails.sort((a, b) => b.originalIndex - a.originalIndex); + + for (const { originalInput, originalIndex } of inputsToRemoveDetails) { + if (this.inputs[originalIndex] === originalInput) { + removeWidgetForInput(this, originalIndex); + this.removeInput(originalIndex); + scanAgainPass = true; + overallChangesMade = true; } else { - // For number type, use baseName + index as before - newName = `${baseName}${dynamicSlots.length}`; + scanAgainPass = true; + overallChangesMade = true; } + } + if (scanAgainPass) break; + } + } + if (scanAgainPass) continue; + } - if (inputInRange) { - // Insert the new empty input at the correct position - this.addInputAtPosition(newName, dynamicType, insertPosition++, dynamicSlots[groupMember].isWidget, dynamicSlots[groupMember].shape); - // Renumber inputs after addition - this.renumberDynamicInputs(baseName, dynamicInputs, dynamicInputs[0].dynamic); + if (overallChangesMade) { + const renumberedBaseNames = new Set(); + for (const groupIdx_str in dynamicInputGroups) { + const groupIdx = parseInt(groupIdx_str, 10); + if (dynamicInputGroups[groupIdx]) { + for (const memberIdx of dynamicInputGroups[groupIdx]) { + const { baseName, dynamic } = dynamicInputs[memberIdx]; // 'dynamic' here is behavior ('number'/'letter') + if (!renumberedBaseNames.has(baseName + dynamic)) { // Ensure unique combination + this.renumberDynamicInputs(baseName, dynamic); + renumberedBaseNames.add(baseName + dynamic); } } } - } else if (isConnected === TypeSlotEvent.Disconnect) { - let foundEmptyIndex = -1; + } + this.setDirtyCanvas(true, true); + } + }; - for (let idx = 0; idx < this.inputs.length; idx++) { - const input = this.inputs[idx]; + const handleDynamicInputActivation = function(dynamicGroup) { + const { slots: dynamicSlotsFromGetter } = this.getDynamicSlots(); - if (!isDynamicInput(input.name)) { - continue; - } + for (const slot of dynamicSlotsFromGetter) { + if (slot.dynamicGroup === dynamicGroup && this.widgets && + !this.widgets.some((w) => w.name === slot.name)) { - if (!input.isConnected) { // Check if the input is empty - // remove empty input - but only when it's not the last one - const dynamicGroup = getDynamicGroup(input.name); - let isLast = true; - for (let i = idx + 1; i < this.inputs.length; i++) { - isLast &&= !this.inputs[i].name.startsWith(dynamicInputs[dynamicInputGroups[dynamicGroup][0]].baseName); - } - if (isLast) { - continue; - } + const diData = getDynamicInputDefinition(slot.name); + if (diData && diData.definition.isWidget) { // Use our enhanced definition + const originalInputDef = diData.definition; - for (let i = idx + 1; i < this.inputs.length; i++) { - this.swapInputs(i - 1, i); - } - const lastIdx = this.inputs.length - 1; - if (this.inputs[lastIdx].widget !== undefined) { - const widgetIdx = this.widgets.findIndex((w) => w.name === this.inputs[lastIdx].widget.name) - this.widgets.splice(widgetIdx, 1); - this.widgets_values?.splice(widgetIdx, 1); - } - this.removeInput(lastIdx); - } + addStandardWidget.call(this, slot.name, + originalInputDef.actualWidgetGuiType, + getWidgetDefaultValue({ type: originalInputDef.actualWidgetGuiType, options: originalInputDef.originalOptions }), + originalInputDef.originalOptions + ); } + } + } - // Renumber dynamic inputs to ensure proper ordering - for (const groupMember of dynamicInputGroups[dynamicInputs[0].dynamicGroup]) { - const baseName = dynamicInputs[groupMember].baseName; - this.renumberDynamicInputs(baseName, dynamicInputs, dynamicInputs[0].dynamic); + let allExistingItemsActive = true; + const itemSuffixesInGroup = new Set(); + const groupSlots = dynamicSlotsFromGetter.filter(s => s.dynamicGroup === dynamicGroup); + + groupSlots.forEach(s => { + const diData = getDynamicInputDefinition(s.name); + if (diData) itemSuffixesInGroup.add(diData.suffix); + }); + + if (itemSuffixesInGroup.size === 0 && dynamicInputGroups[dynamicGroup]?.length > 0) { + allExistingItemsActive = true; + } else { + let hasCompletelyEmptyItem = false; + for (const suffix of itemSuffixesInGroup) { + const inputsOfThisItem = groupSlots.filter(s => { + const diData = getDynamicInputDefinition(s.name); + return diData && diData.suffix === suffix; + }); + if (inputsOfThisItem.length > 0 && inputsOfThisItem.every(s => !s.connected)) { // s.connected from getDynamicSlots + hasCompletelyEmptyItem = true; + break; } + } + allExistingItemsActive = !hasCompletelyEmptyItem; + } + + if (allExistingItemsActive) { + let lastDynamicIdx = -1; + if (groupSlots.length > 0) { + lastDynamicIdx = Math.max(...groupSlots.map(slot => slot.index)); + } else { + let maxIdx = -1; + for(const di of dynamicInputs){ // di is a full definition object + if(di.dynamicGroup < dynamicGroup) { + for(let i=this.inputs.length-1; i>=0; --i){ + if(this.inputs[i].name.startsWith(di.baseName)){ + maxIdx = Math.max(maxIdx, i); + break; + } + } + } + } + lastDynamicIdx = (maxIdx === -1) ? (this.inputs.length -1) : maxIdx; } + this.addNewDynamicInputForGroup(dynamicGroup, lastDynamicIdx); + } - this.setDirtyCanvas(true, true); - } catch (e) { - console.error(e); - debugger; - alert(e); - } finally { + this.setDirtyCanvas(true, true); + }; + + // --- Event Handlers (onConnectionsChange, onConnectInput, onRemoved, onWidgetChanged) --- + const onConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, link, ioSlot) { + const originalReturn = onConnectionsChange?.apply(this, arguments); + if (type === TypeSlot.Input && slotIndex < this.inputs.length && this.inputs[slotIndex] && isDynamicInput(this.inputs[slotIndex].name)) { + if (isProcessingConnection) return originalReturn; + isProcessingConnection = true; + const dynamicGroup = this.getDynamicGroup(this.inputs[slotIndex].name); + if (dynamicGroup !== undefined) { + if (isConnected === TypeSlotEvent.Connect) { + handleDynamicInputActivation.call(this, dynamicGroup); + } else if (isConnected === TypeSlotEvent.Disconnect) { + handleEmptyDynamicInput.call(this); + } + } isProcessingConnection = false; } - - return result; + return originalReturn; }; const onConnectInput = nodeType.prototype.onConnectInput; nodeType.prototype.onConnectInput = function(inputIndex, outputType, outputSlot, outputNode, outputIndex) { - const result = onRemoved?.apply(this, arguments) ?? true; - - if (this.inputs[inputIndex].isConnected) { + const result = onConnectInput?.apply(this, arguments) ?? true; + if (this.inputs[inputIndex].link !== null && this.inputs[inputIndex].link !== undefined ) { const pre_isProcessingConnection = isProcessingConnection; isProcessingConnection = true; this.disconnectInput(inputIndex, true); @@ -298,96 +526,148 @@ app.registerExtension({ const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { - const result = onRemoved?.apply(this, arguments); - - // When this is called, the input links are already removed - but - // due to the implementation of the remove() method it might not - // have worked with the dynamic inputs. So we need to fix it here. - for (let i = this.inputs.length-1; i >= 0; i--) { - if ( this.inputs[i].isConnected) { - this.disconnectInput(i, true); + if(!isProcessingConnection){ + isProcessingConnection = true; + for (let i = this.inputs.length - 1; i >= 0; i--) { + if (this.inputs[i].link !== null && this.inputs[i].link !== undefined) { + this.disconnectInput(i, true); + } } + isProcessingConnection = false; } - - return result; + return onRemoved?.apply(this, arguments); } - // Method to swap two inputs in the "this.inputs" array by their indices - nodeType.prototype.swapInputs = function(indexA, indexB) { - // Validate indices - if ( - indexA < 0 || indexB < 0 || - indexA >= this.inputs.length || - indexB >= this.inputs.length || - indexA === indexB - ) { - console.error("Invalid input indices for swapping:", indexA, indexB); - return; + const onWidgetChanged = nodeType.prototype.onWidgetChanged; + nodeType.prototype.onWidgetChanged = function (widgetName, newValue, oldValue, widgetObject) { + const originalReturn = onWidgetChanged?.apply(this, arguments); + if (isProcessingConnection || !isDynamicInput(widgetName)) { + return originalReturn; } + const dynamicGroup = this.getDynamicGroup(widgetName); + if (dynamicGroup === undefined) return originalReturn; - // reflect the swap with the widgets - if (this.inputs[indexA].widget !== undefined) { - if (this.inputs[indexB].widget === undefined) { - console.error("Bad swap: input A is a widget but input B is not", indexA, indexB); - } - const widgetIdxA = this.widgets.findIndex((w) => w.name === this.inputs[indexA].widget.name); - const widgetIdxB = this.widgets.findIndex((w) => w.name === this.inputs[indexB].widget.name); - [this.widgets[widgetIdxA].y, this.widgets[widgetIdxB].y] = [this.widgets[widgetIdxB].y, this.widgets[widgetIdxA].y]; - [this.widgets[widgetIdxA].last_y, this.widgets[widgetIdxB].last_y] = [this.widgets[widgetIdxB].last_y, this.widgets[widgetIdxA].last_y]; - [this.widgets[widgetIdxA], this.widgets[widgetIdxB]] = [this.widgets[widgetIdxB], this.widgets[widgetIdxA]]; - if (this.widgets_values) { - [this.widgets_values[widgetIdxA], this.widgets_values[widgetIdxB]] = [this.widgets_values[widgetIdxB], this.widgets_values[widgetIdxA]]; - } - } + const diData = getDynamicInputDefinition(widgetName); + const defaultValue = diData ? getWidgetDefaultValue({type: diData.definition.actualWidgetGuiType, options: diData.definition.originalOptions}) : getWidgetDefaultValue(widgetObject) /*fallback*/; - // Swap the inputs in the array - [this.inputs[indexA].boundingRect, this.inputs[indexB].boundingRect] = [this.inputs[indexB].boundingRect, this.inputs[indexA].boundingRect]; - [this.inputs[indexA].pos, this.inputs[indexB].pos] = [this.inputs[indexB].pos, this.inputs[indexA].pos]; - [this.inputs[indexA], this.inputs[indexB]] = [this.inputs[indexB], this.inputs[indexA]]; - updateSlotIndices(this); // Refresh indices + const wasEffectivelyEmpty = oldValue === defaultValue; + const isNowEffectivelyEmpty = newValue === defaultValue; - // Redraw the node to ensure the graph updates properly - // -> not needed as the calling method must do it! - this.setDirtyCanvas(true, true); + if (wasEffectivelyEmpty === isNowEffectivelyEmpty) return originalReturn; + + isProcessingConnection = true; + if (wasEffectivelyEmpty && !isNowEffectivelyEmpty) { + handleDynamicInputActivation.call(this, dynamicGroup); + } else if (!wasEffectivelyEmpty && isNowEffectivelyEmpty) { + handleEmptyDynamicInput.call(this); + } + isProcessingConnection = false; + return originalReturn; }; - // Add method to safely renumber dynamic inputs without breaking connections - nodeType.prototype.renumberDynamicInputs = function(baseName, dynamicInputs, dynamic) { - // Get current dynamic inputs info - const dynamicInputInfo = []; + nodeType.prototype.getDynamicSlots = function(filterDynamicGroup = null) { // (Content largely unchanged, relies on isDynamicInputEmpty which is updated) + const dynamicSlotsResult = []; + const dynamicGroupCount = {}; + const dynamicGroupConnected = {}; - for (let i = 0; i < this.inputs.length; i++) { - const input = this.inputs[i]; - const isDynamic = isDynamicInput(input.name); - - if (isDynamic && input.name.startsWith(baseName)) { - dynamicInputInfo.push({ - index: i, - widgetIdx: input.widget !== undefined ? this.widgets.findIndex((w) => w.name === input.widget.name) : undefined, - name: input.name, - connected: input.isConnected - }); + const itemsState = new Map(); + + for (const [index, input] of this.inputs.entries()) { + const diData = getDynamicInputDefinition(input.name); + if (!diData) continue; + + const { definition: di, suffix } = diData; + const currentDynamicGroup = di.dynamicGroup; + + if (filterDynamicGroup !== null && currentDynamicGroup !== filterDynamicGroup) continue; + + if (!itemsState.has(currentDynamicGroup)) itemsState.set(currentDynamicGroup, new Map()); + const groupItems = itemsState.get(currentDynamicGroup); + if (!groupItems.has(suffix)) groupItems.set(suffix, { isActive: false, inputCount: 0, activeInputCount: 0 }); + + const itemState = groupItems.get(suffix); + itemState.inputCount++; + const isInputActive = !isDynamicInputEmpty(this, index); // Uses updated isDynamicInputEmpty + if (isInputActive) itemState.activeInputCount++; + + dynamicSlotsResult.push({ + index, name: input.name, isWidget: input.widget !== undefined, shape: input.shape, + connected: isInputActive, + isDynamic: true, dynamicGroup: currentDynamicGroup, + }); + } + + for(const [groupId, groupItems] of itemsState) { + dynamicGroupCount[groupId] = groupItems.size; + dynamicGroupConnected[groupId] = []; + const sortedSuffixes = Array.from(groupItems.keys()).sort((a,b) => { + const numA = parseInt(a,10); const numB = parseInt(b,10); + if(!isNaN(numA) && !isNaN(numB)) return numA-numB; + if(a.length !== b.length) return a.length - b.length; + return String(a).localeCompare(String(b)); + }); + + for(const suffix of sortedSuffixes){ + const item = groupItems.get(suffix); + dynamicGroupConnected[groupId].push(item.activeInputCount > 0); } } + return { slots: dynamicSlotsResult, groupCount: dynamicGroupCount, groupConnected: dynamicGroupConnected }; + }; + + nodeType.prototype.addNewDynamicInputForGroup = function(dynamicGroup, lastKnownInputIndexInGroup) { + let insertPosition = lastKnownInputIndexInGroup + 1; + let inputInRange = true; - // Just rename the inputs in place - don't remove/add to keep connections intact - for (let i = 0; i < dynamicInputInfo.length; i++) { - const info = dynamicInputInfo[i]; - const input = this.inputs[info.index]; - const newName = dynamic === "number" ? `${baseName}${i}` : String.fromCharCode(97 + i); // 97 is ASCII for 'a' - - if (input.widget !== undefined) { - const widgetIdx = info.widgetIdx; - input.widget.name = newName; - this.widgets[widgetIdx].name = newName; - this.widgets[widgetIdx].label = newName; + const groupMemberDefinitions = dynamicInputGroups[dynamicGroup].map(idx => dynamicInputs[idx]); + + const { slots: currentGroupSlots } = this.getDynamicSlots(dynamicGroup); + const existingSuffixes = new Set(); + currentGroupSlots.forEach(s => { + const diData = getDynamicInputDefinition(s.name); + if(diData) existingSuffixes.add(diData.suffix); + }); + + let newItemNumericSuffix = 0; + // Find the smallest non-negative integer not in existingSuffixes (if they are numbers) + // or just use existingSuffixes.size if complex/letter based for simplicity (renumbering will fix) + // For simplicity here, using size, assuming renumbering will handle actual ordering. + // A more robust suffix generation might be needed if strict ordering before renumbering is critical. + const isNumericSuffix = groupMemberDefinitions.length > 0 && groupMemberDefinitions[0].dynamic === 'number'; + if (isNumericSuffix) { + while (existingSuffixes.has(String(newItemNumericSuffix))) { + newItemNumericSuffix++; } + } else { // letter or complex + newItemNumericSuffix = existingSuffixes.size; // This is the 'count' for letter generation + } + + + for (const diDefinition of groupMemberDefinitions) { + // Use the new properties from diDefinition + const { baseName, dynamic, dynamicComfyType, isWidget, actualWidgetGuiType, originalOptions } = diDefinition; - if (input.name !== newName) { - input.name = newName; - input.localized_name = newName; + if (dynamic === 'letter' && newItemNumericSuffix >= 26) { + inputInRange = false; continue; } + const newName = generateDynamicInputName(dynamic, baseName, newItemNumericSuffix); + + const refSlot = currentGroupSlots.find(s => s.name.startsWith(baseName)) || currentGroupSlots[0]; + + const widgetDefault = getWidgetDefaultValue({ type: actualWidgetGuiType, options: originalOptions }); + + this.addInputAtPosition( + newName, + dynamicComfyType, // ComfyUI input type (e.g., "STRING", "*") + actualWidgetGuiType, // GUI widget type (e.g., "text", "number", "combo") + insertPosition++, + isWidget, // The crucial boolean + refSlot?.shape, + widgetDefault, + originalOptions // Full original options for widget config + ); } + return inputInRange; }; } });