feat: add sort_by_length and use_regex options to StringMultiReplace (Issue #11)

This commit is contained in:
BRADSEC
2026-03-03 20:25:08 +10:00
parent f0ba6032c2
commit 5d645958bd
3 changed files with 160 additions and 10 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui_stringessentials"
description = "Simple string manipulation nodes for ComfyUI (strip/remove text strings, search and replace text strings, preview modified string outputs). Useful for modifying text prompts or results from LLM outputs. Nodes will be located under standard Add Node > utils menu. Node names: String Textbox, String Strip, String Multi Replace, String Conditional Append, String Contains Any and String Preview."
version = "2.0.7"
version = "2.0.8"
license = {file = "LICENSE"}
[project.urls]
+17 -9
View File
@@ -17,7 +17,13 @@ class StringMultiReplaceNode:
"label_on": "enabled", "label_off": "disabled",
"tooltip": "When enabled, punctuation marks adjacent to matched text are preserved"}),
"remove_extra_spaces": ("BOOLEAN", {"default": True,
"label_on": "enabled", "label_off": "disabled"})
"label_on": "enabled", "label_off": "disabled"}),
"sort_by_length": ("BOOLEAN", {"default": True,
"label_on": "enabled", "label_off": "disabled",
"tooltip": "When enabled, longer search strings are replaced first to prevent substring clobbering. Disable for input-order replacement."}),
"use_regex": ("BOOLEAN", {"default": False,
"label_on": "enabled", "label_off": "disabled",
"tooltip": "When enabled, search strings are treated as regex patterns instead of literal text"})
}
}
@@ -31,7 +37,7 @@ class StringMultiReplaceNode:
text = re.sub(r'\s{2,}', ' ', text) # Normalize multiple spaces
return text.strip()
def string_replace(self, input_string, replacement_pairs, replacement_delimiter, match_case, match_whole_string, preserve_punctuation, remove_extra_spaces):
def string_replace(self, input_string, replacement_pairs, replacement_delimiter, match_case, match_whole_string, preserve_punctuation, remove_extra_spaces, sort_by_length=True, use_regex=False):
# Create a list of all replacements
replacements = []
for line in replacement_pairs.splitlines():
@@ -48,28 +54,30 @@ class StringMultiReplaceNode:
replacements.append((len(search_str), search_str, replace_str))
# Sort by length in descending order
replacements.sort(reverse=True)
# Sort by length in descending order (longest first to prevent substring clobbering)
if sort_by_length:
replacements.sort(reverse=True)
result = input_string
flags = 0 if match_case else re.IGNORECASE
# Perform replacements
for _, search_str, replace_str in replacements:
escaped = search_str if use_regex else re.escape(search_str)
if match_whole_string:
if preserve_punctuation:
# Match whole words but preserve adjacent punctuation
pattern = r'\b' + re.escape(search_str) + r'\b'
pattern = r'\b' + escaped + r'\b'
else:
# Original behavior: remove punctuation after match
pattern = r'(?:\b|(?<=\s)|^)' + re.escape(search_str) + r'(?:[,;:.])?(?=\s|$)'
pattern = r'(?:\b|(?<=\s)|^)' + escaped + r'(?:[,;:.])?(?=\s|$)'
else:
if preserve_punctuation:
# Simple literal replacement preserving punctuation
pattern = re.escape(search_str)
# Simple replacement preserving punctuation
pattern = escaped
else:
# Original behavior: remove punctuation after match
pattern = re.escape(search_str) + r'(?:[,;:.])?'
pattern = escaped + r'(?:[,;:.])?'
regex = re.compile(pattern, flags=flags)
result = regex.sub(replace_str, result)
+142
View File
@@ -512,5 +512,147 @@ anime
self.assertEqual(matched, "anim")
# Tests for sort_by_length parameter (Issue #11)
def test_replace_sort_by_length_default(self):
# Default behavior: longest match first prevents substring clobbering
input_string = "this is a test"
replacement_pairs = """is::WAS
this::THAT"""
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=True,
preserve_punctuation=True,
remove_extra_spaces=True,
sort_by_length=True
)[0]
# "this" (4 chars) replaced before "is" (2 chars), so "this" is not mangled
self.assertEqual(result, "THAT WAS a test")
def test_replace_sort_by_length_disabled(self):
# Input-order replacement: pairs processed in the order given
input_string = "cat is here"
replacement_pairs = """cat::dog
dog::fish"""
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=True,
preserve_punctuation=True,
remove_extra_spaces=True,
sort_by_length=False
)[0]
# "cat" -> "dog" first, then "dog" -> "fish" (chained replacement)
self.assertEqual(result, "fish is here")
def test_replace_sort_by_length_enabled_no_chaining(self):
# With sort enabled, equal-length pairs are sorted reverse-alphabetically,
# so "dog::fish" runs before "cat::dog". Since "dog" isn't in the original
# string, only "cat::dog" produces a match. No chaining occurs.
input_string = "cat is here"
replacement_pairs = """cat::dog
dog::fish"""
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=True,
preserve_punctuation=True,
remove_extra_spaces=True,
sort_by_length=True
)[0]
self.assertEqual(result, "dog is here")
# Tests for use_regex parameter (Issue #11)
def test_replace_use_regex_basic(self):
# Regex pattern matching
input_string = "hello123world456"
replacement_pairs = r"\d+:: "
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=False,
preserve_punctuation=True,
remove_extra_spaces=False,
use_regex=True
)[0]
self.assertEqual(result, "hello world ")
def test_replace_use_regex_disabled(self):
# Without regex, special chars are treated literally
input_string = r"hello\d+world"
replacement_pairs = r"\d+::REPLACED"
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=False,
preserve_punctuation=True,
remove_extra_spaces=True,
use_regex=False
)[0]
self.assertEqual(result, "helloREPLACEDworld")
def test_replace_use_regex_with_groups(self):
# Regex with capture groups in replacement
input_string = "2024-01-15"
replacement_pairs = r"(\d{4})-(\d{2})-(\d{2})::\3/\2/\1"
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=False,
preserve_punctuation=True,
remove_extra_spaces=True,
use_regex=True
)[0]
self.assertEqual(result, "15/01/2024")
def test_replace_use_regex_character_class(self):
# Regex with character class
input_string = "Hello, World! How are you?"
replacement_pairs = "[!?,]::;"
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=False,
preserve_punctuation=True,
remove_extra_spaces=True,
use_regex=True
)[0]
self.assertEqual(result, "Hello; World; How are you;")
def test_replace_regex_with_input_order(self):
# Combine regex + input-order for chained regex replacements
input_string = "foo123bar456baz"
replacement_pairs = r"""\d+::_
foo_bar::REPLACED"""
result = self.replace_node.string_replace(
input_string=input_string,
replacement_pairs=replacement_pairs,
replacement_delimiter="::",
match_case=False,
match_whole_string=False,
preserve_punctuation=True,
remove_extra_spaces=True,
sort_by_length=False,
use_regex=True
)[0]
# First: \d+ -> _ gives "foo_bar_baz"
# Then: foo_bar -> REPLACED gives "REPLACED_baz"
self.assertEqual(result, "REPLACED_baz")
if __name__ == '__main__':
unittest.main()