initial commit
This commit is contained in:
@@ -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 <repo-url> 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
|
||||
+21
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
"""Nodes package for ComfyUI Multi-Replace."""
|
||||
@@ -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))
|
||||
@@ -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)))
|
||||
Reference in New Issue
Block a user