commit 2496f619896923ac35e147793bcedd98aae9428a Author: Shaun Johnston Date: Tue Dec 16 09:14:51 2025 +1000 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e14d8c6 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# ComfyUI Multi-Replace + +A ComfyUI custom node package for creating and applying multiple find/replace pairs to text. + +## Features + +- **Find/Replace Pairs Node**: Create an arbitrary number of find/replace pairs + - Supports up to 50 pairs per node + - Each pair can have values entered directly or connected from other nodes + - Dynamic UI shows only active pairs + - Outputs: pairs object, JSON, and CSV formats + +- **Text Replacer Node**: Apply find/replace pairs to text + - Accepts pairs from the FindReplacePairs node + - Supports regular expressions + - Case-sensitive/insensitive matching + - Replace all or first occurrence only + - Outputs: modified text, pairs passthrough (for chaining), changes log, and replacement count + +## Installation + +1. Clone or copy this folder to your ComfyUI `custom_nodes` directory: + ``` + cd ComfyUI/custom_nodes + git clone comfyui-multi-replace + ``` + +2. Restart ComfyUI + +## Usage + +### Basic Usage + +1. Add a **Find/Replace Pairs** node +2. Set the number of pairs you need with `pair_count` +3. Enter your find patterns and replacements +4. Connect the `pairs` output to a **Text Replacer** node +5. Enter or connect your input text +6. Get the modified text from the `result` output + +### Connecting Dynamic Values + +Each find/replace pair has optional input connectors (`find_N_input` and `replace_N_input`) that can receive values from other nodes. Connected inputs take priority over widget values. + +### Chaining Replacers + +The Text Replacer node passes through the pairs object, allowing you to chain multiple replacers or use the same pairs set multiple times. + +### Output Formats + +**Find/Replace Pairs outputs:** +- `pairs` - Internal pairs object for use with TextReplacer +- `json_output` - JSON array of pairs: `[{"find": "...", "replace": "...", "index": 1}, ...]` +- `csv_output` - CSV format: `find,replace` per line + +**Text Replacer outputs:** +- `result` - The text after all replacements +- `pairs_passthrough` - The pairs object (for chaining) +- `changes_log` - Human-readable log of changes made +- `replacement_count` - Total number of replacements performed + +## Node Reference + +### Find/Replace Pairs + +| Input | Type | Description | +|-------|------|-------------| +| `pair_count` | INT | Number of active pairs (1-50) | +| `find_N` | STRING | Find pattern for pair N | +| `replace_N` | STRING | Replacement for pair N | +| `find_N_input` | STRING (connector) | Optional connected find pattern | +| `replace_N_input` | STRING (connector) | Optional connected replacement | + +### Text Replacer + +| Input | Type | Description | +|-------|------|-------------| +| `pairs` | FR_PAIRS | Pairs from FindReplacePairs node | +| `input_text` | STRING | Text to perform replacements on | +| `input_text_connected` | STRING (connector) | Optional connected text input | +| `use_regex` | BOOLEAN | Treat patterns as regex | +| `case_sensitive` | BOOLEAN | Case-sensitive matching | +| `replace_all` | BOOLEAN | Replace all vs first occurrence | + +## License + +MIT License diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ca8eb5e --- /dev/null +++ b/__init__.py @@ -0,0 +1,21 @@ +""" +ComfyUI Multi-Replace Custom Node +A node package for creating and applying multiple find/replace pairs. +""" + +from .nodes.find_replace_pairs import FindReplacePairs +from .nodes.text_replacer import TextReplacer + +NODE_CLASS_MAPPINGS = { + "FindReplacePairs": FindReplacePairs, + "TextReplacer": TextReplacer, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "FindReplacePairs": "Find/Replace Pairs", + "TextReplacer": "Text Replacer", +} + +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/js/multi_replace.js b/js/multi_replace.js new file mode 100644 index 0000000..339e823 --- /dev/null +++ b/js/multi_replace.js @@ -0,0 +1,104 @@ +import { app } from "../../scripts/app.js"; + +/** + * ComfyUI Multi-Replace Extension + * Handles dynamic visibility of find/replace pair inputs based on pair_count + */ + +const MAX_PAIRS = 50; + +app.registerExtension({ + name: "comfyui-multi-replace", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "FindReplacePairs") { + // Store original onNodeCreated + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function() { + if (origOnNodeCreated) { + origOnNodeCreated.apply(this, arguments); + } + + // Find the pair_count widget + const pairCountWidget = this.widgets?.find(w => w.name === "pair_count"); + + if (pairCountWidget) { + // Store original callback + const origCallback = pairCountWidget.callback; + + // Override callback to update visibility + pairCountWidget.callback = (value, ...args) => { + if (origCallback) { + origCallback.call(pairCountWidget, value, ...args); + } + this.updatePairVisibility(value); + }; + + // Initial visibility update + setTimeout(() => { + this.updatePairVisibility(pairCountWidget.value); + }, 100); + } + }; + + // Method to update visibility of pair widgets + nodeType.prototype.updatePairVisibility = function(pairCount) { + if (!this.widgets) return; + + const count = parseInt(pairCount) || 1; + + for (const widget of this.widgets) { + const match = widget.name.match(/^(find|replace)_(\d+)$/); + if (match) { + const pairIndex = parseInt(match[2]); + // Show widgets for active pairs + 1 empty pair for new entry + const shouldShow = pairIndex <= count; + + // ComfyUI uses different methods to hide widgets + if (shouldShow) { + widget.type = widget._originalType || widget.type; + widget.computeSize = widget._originalComputeSize; + } else { + widget._originalType = widget._originalType || widget.type; + widget._originalComputeSize = widget._originalComputeSize || widget.computeSize; + widget.type = "hidden"; + widget.computeSize = () => [0, -4]; + } + } + } + + // Recalculate node size + this.setSize(this.computeSize()); + app.graph.setDirtyCanvas(true, true); + }; + + // Override onConfigure to restore visibility on load + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function(config) { + if (origOnConfigure) { + origOnConfigure.apply(this, arguments); + } + + setTimeout(() => { + const pairCountWidget = this.widgets?.find(w => w.name === "pair_count"); + if (pairCountWidget) { + this.updatePairVisibility(pairCountWidget.value); + } + }, 100); + }; + } + }, + + async nodeCreated(node) { + if (node.comfyClass === "FindReplacePairs") { + // Additional setup after node is fully created + const pairCountWidget = node.widgets?.find(w => w.name === "pair_count"); + if (pairCountWidget) { + setTimeout(() => { + node.updatePairVisibility?.(pairCountWidget.value); + }, 200); + } + } + } +}); diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..a855af7 --- /dev/null +++ b/nodes/__init__.py @@ -0,0 +1 @@ +"""Nodes package for ComfyUI Multi-Replace.""" diff --git a/nodes/find_replace_pairs.py b/nodes/find_replace_pairs.py new file mode 100644 index 0000000..804b822 --- /dev/null +++ b/nodes/find_replace_pairs.py @@ -0,0 +1,148 @@ +""" +Find/Replace Pairs Node +Creates a collection of find/replace pairs with dynamic inputs. +""" + +import json +from typing import Any + + +class FindReplacePairs: + """ + A node that creates an arbitrary number of find/replace pairs. + Each pair can have values entered directly or connected from other nodes. + """ + + MAX_PAIRS = 50 # Maximum number of pairs supported + + @classmethod + def INPUT_TYPES(cls): + """ + Define dynamic inputs for find/replace pairs. + We define a base set of inputs plus many optional pair inputs. + """ + inputs = { + "required": {}, + "optional": { + "pair_count": ("INT", { + "default": 1, + "min": 1, + "max": cls.MAX_PAIRS, + "step": 1, + "display": "number" + }), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + } + } + + # Add optional inputs for each potential pair + for i in range(1, cls.MAX_PAIRS + 1): + inputs["optional"][f"find_{i}"] = ("STRING", { + "default": "", + "multiline": False, + "placeholder": f"Find pattern {i}" + }) + inputs["optional"][f"replace_{i}"] = ("STRING", { + "default": "", + "multiline": False, + "placeholder": f"Replacement {i}" + }) + # Also allow connected string inputs (these override the widget values) + inputs["optional"][f"find_{i}_input"] = ("STRING", { + "forceInput": True, + }) + inputs["optional"][f"replace_{i}_input"] = ("STRING", { + "forceInput": True, + }) + + return inputs + + RETURN_TYPES = ("FR_PAIRS", "STRING", "STRING",) + RETURN_NAMES = ("pairs", "json_output", "csv_output",) + OUTPUT_TOOLTIPS = ( + "Find/Replace pairs object for use with TextReplacer node", + "JSON representation of the pairs", + "CSV representation of the pairs (find,replace per line)", + ) + + FUNCTION = "create_pairs" + CATEGORY = "text/replace" + DESCRIPTION = "Creates a collection of find/replace pairs that can be used with the TextReplacer node." + + def create_pairs(self, pair_count=1, unique_id=None, **kwargs): + """ + Create the find/replace pairs collection. + + Args: + pair_count: Number of active pairs + unique_id: Node's unique ID + **kwargs: Dynamic find_N, replace_N, find_N_input, replace_N_input values + + Returns: + Tuple of (pairs_dict, json_string, csv_string) + """ + pairs = [] + + for i in range(1, pair_count + 1): + # Check for connected input first, fall back to widget value + find_key = f"find_{i}_input" + find_widget_key = f"find_{i}" + replace_key = f"replace_{i}_input" + replace_widget_key = f"replace_{i}" + + # Get find value (connected input takes priority) + find_value = kwargs.get(find_key) + if find_value is None or find_value == "": + find_value = kwargs.get(find_widget_key, "") + + # Get replace value (connected input takes priority) + replace_value = kwargs.get(replace_key) + if replace_value is None or replace_value == "": + replace_value = kwargs.get(replace_widget_key, "") + + # Only add non-empty find patterns + if find_value: + pairs.append({ + "find": find_value, + "replace": replace_value, + "index": i + }) + + # Create the pairs object + pairs_obj = { + "pairs": pairs, + "count": len(pairs), + "source_node": unique_id + } + + # Create JSON output + json_output = json.dumps(pairs, indent=2) + + # Create CSV output (find,replace per line) + csv_lines = [] + for pair in pairs: + # Escape commas and quotes in values for CSV + find_escaped = pair["find"].replace('"', '""') + replace_escaped = pair["replace"].replace('"', '""') + if ',' in find_escaped or '"' in find_escaped: + find_escaped = f'"{find_escaped}"' + if ',' in replace_escaped or '"' in replace_escaped: + replace_escaped = f'"{replace_escaped}"' + csv_lines.append(f"{find_escaped},{replace_escaped}") + csv_output = "\n".join(csv_lines) + + return (pairs_obj, json_output, csv_output) + + @classmethod + def IS_CHANGED(cls, pair_count=1, **kwargs): + """Force re-evaluation when inputs change.""" + # Create a hash of all input values + values = [str(pair_count)] + for i in range(1, pair_count + 1): + values.append(str(kwargs.get(f"find_{i}", ""))) + values.append(str(kwargs.get(f"replace_{i}", ""))) + values.append(str(kwargs.get(f"find_{i}_input", ""))) + values.append(str(kwargs.get(f"replace_{i}_input", ""))) + return hash(tuple(values)) diff --git a/nodes/text_replacer.py b/nodes/text_replacer.py new file mode 100644 index 0000000..1e0a0b5 --- /dev/null +++ b/nodes/text_replacer.py @@ -0,0 +1,147 @@ +""" +Text Replacer Node +Applies find/replace pairs to input text. +""" + +import re +from typing import Any + + +class TextReplacer: + """ + A node that applies find/replace pairs to an input string. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pairs": ("FR_PAIRS", { + "tooltip": "Find/Replace pairs from the FindReplacePairs node" + }), + "input_text": ("STRING", { + "default": "", + "multiline": True, + "placeholder": "Text to perform replacements on" + }), + }, + "optional": { + "input_text_connected": ("STRING", { + "forceInput": True, + "tooltip": "Connected text input (overrides widget)" + }), + "use_regex": ("BOOLEAN", { + "default": False, + "tooltip": "Treat find patterns as regular expressions" + }), + "case_sensitive": ("BOOLEAN", { + "default": True, + "tooltip": "Whether replacements are case-sensitive" + }), + "replace_all": ("BOOLEAN", { + "default": True, + "tooltip": "Replace all occurrences (vs only first)" + }), + } + } + + RETURN_TYPES = ("STRING", "FR_PAIRS", "STRING", "INT",) + RETURN_NAMES = ("result", "pairs_passthrough", "changes_log", "replacement_count",) + OUTPUT_TOOLTIPS = ( + "The text after all replacements have been applied", + "The find/replace pairs (passed through for chaining)", + "Log of all replacements made", + "Total number of replacements performed", + ) + + FUNCTION = "apply_replacements" + CATEGORY = "text/replace" + DESCRIPTION = "Applies find/replace pairs to input text. Supports regex and case-insensitive matching." + + def apply_replacements( + self, + pairs: dict, + input_text: str = "", + input_text_connected: str = None, + use_regex: bool = False, + case_sensitive: bool = True, + replace_all: bool = True + ): + """ + Apply all find/replace pairs to the input text. + + Args: + pairs: The pairs object from FindReplacePairs node + input_text: Text widget value + input_text_connected: Connected text input (takes priority) + use_regex: Whether to use regex matching + case_sensitive: Whether matching is case-sensitive + replace_all: Whether to replace all occurrences + + Returns: + Tuple of (result_text, pairs_passthrough, changes_log, replacement_count) + """ + # Use connected input if available + text = input_text_connected if input_text_connected is not None else input_text + + # Get the pairs list + pairs_list = pairs.get("pairs", []) + + total_replacements = 0 + log_entries = [] + result = text + + for pair in pairs_list: + find_pattern = pair.get("find", "") + replace_with = pair.get("replace", "") + + if not find_pattern: + continue + + # Count occurrences before replacement + if use_regex: + flags = 0 if case_sensitive else re.IGNORECASE + try: + matches = len(re.findall(find_pattern, result, flags)) + if replace_all: + result = re.sub(find_pattern, replace_with, result, flags=flags) + else: + result = re.sub(find_pattern, replace_with, result, count=1, flags=flags) + except re.error as e: + log_entries.append(f"Regex error for pattern '{find_pattern}': {e}") + continue + else: + if case_sensitive: + matches = result.count(find_pattern) + if replace_all: + result = result.replace(find_pattern, replace_with) + else: + result = result.replace(find_pattern, replace_with, 1) + else: + # Case-insensitive plain text replacement + pattern = re.escape(find_pattern) + matches = len(re.findall(pattern, result, re.IGNORECASE)) + if replace_all: + result = re.sub(pattern, replace_with, result, flags=re.IGNORECASE) + else: + result = re.sub(pattern, replace_with, result, count=1, flags=re.IGNORECASE) + + replacements_made = min(matches, 1) if not replace_all else matches + total_replacements += replacements_made + + if replacements_made > 0: + log_entries.append( + f"Replaced '{find_pattern}' -> '{replace_with}' ({replacements_made}x)" + ) + + # Create changes log + changes_log = "\n".join(log_entries) if log_entries else "No replacements made" + + return (result, pairs, changes_log, total_replacements) + + @classmethod + def IS_CHANGED(cls, pairs, input_text="", input_text_connected=None, **kwargs): + """Force re-evaluation when inputs change.""" + text = input_text_connected if input_text_connected is not None else input_text + pairs_hash = hash(str(pairs)) + return hash((text, pairs_hash, str(kwargs)))