Skip to content

Commit 5d64595

Browse files
author
BRADSEC
committed
feat: add sort_by_length and use_regex options to StringMultiReplace (Issue #11)
1 parent f0ba603 commit 5d64595

3 files changed

Lines changed: 160 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "comfyui_stringessentials"
33
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."
4-
version = "2.0.7"
4+
version = "2.0.8"
55
license = {file = "LICENSE"}
66

77
[project.urls]

string_multi_replace_node.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ def INPUT_TYPES(cls):
1717
"label_on": "enabled", "label_off": "disabled",
1818
"tooltip": "When enabled, punctuation marks adjacent to matched text are preserved"}),
1919
"remove_extra_spaces": ("BOOLEAN", {"default": True,
20-
"label_on": "enabled", "label_off": "disabled"})
20+
"label_on": "enabled", "label_off": "disabled"}),
21+
"sort_by_length": ("BOOLEAN", {"default": True,
22+
"label_on": "enabled", "label_off": "disabled",
23+
"tooltip": "When enabled, longer search strings are replaced first to prevent substring clobbering. Disable for input-order replacement."}),
24+
"use_regex": ("BOOLEAN", {"default": False,
25+
"label_on": "enabled", "label_off": "disabled",
26+
"tooltip": "When enabled, search strings are treated as regex patterns instead of literal text"})
2127
}
2228
}
2329

@@ -31,7 +37,7 @@ def cleanup_text(self, text):
3137
text = re.sub(r'\s{2,}', ' ', text) # Normalize multiple spaces
3238
return text.strip()
3339

34-
def string_replace(self, input_string, replacement_pairs, replacement_delimiter, match_case, match_whole_string, preserve_punctuation, remove_extra_spaces):
40+
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):
3541
# Create a list of all replacements
3642
replacements = []
3743
for line in replacement_pairs.splitlines():
@@ -48,28 +54,30 @@ def string_replace(self, input_string, replacement_pairs, replacement_delimiter,
4854

4955
replacements.append((len(search_str), search_str, replace_str))
5056

51-
# Sort by length in descending order
52-
replacements.sort(reverse=True)
57+
# Sort by length in descending order (longest first to prevent substring clobbering)
58+
if sort_by_length:
59+
replacements.sort(reverse=True)
5360

5461
result = input_string
5562
flags = 0 if match_case else re.IGNORECASE
5663

5764
# Perform replacements
5865
for _, search_str, replace_str in replacements:
66+
escaped = search_str if use_regex else re.escape(search_str)
5967
if match_whole_string:
6068
if preserve_punctuation:
6169
# Match whole words but preserve adjacent punctuation
62-
pattern = r'\b' + re.escape(search_str) + r'\b'
70+
pattern = r'\b' + escaped + r'\b'
6371
else:
6472
# Original behavior: remove punctuation after match
65-
pattern = r'(?:\b|(?<=\s)|^)' + re.escape(search_str) + r'(?:[,;:.])?(?=\s|$)'
73+
pattern = r'(?:\b|(?<=\s)|^)' + escaped + r'(?:[,;:.])?(?=\s|$)'
6674
else:
6775
if preserve_punctuation:
68-
# Simple literal replacement preserving punctuation
69-
pattern = re.escape(search_str)
76+
# Simple replacement preserving punctuation
77+
pattern = escaped
7078
else:
7179
# Original behavior: remove punctuation after match
72-
pattern = re.escape(search_str) + r'(?:[,;:.])?'
80+
pattern = escaped + r'(?:[,;:.])?'
7381

7482
regex = re.compile(pattern, flags=flags)
7583
result = regex.sub(replace_str, result)

test_string_nodes.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,5 +512,147 @@ def test_contains_any_partial_match(self):
512512
self.assertEqual(matched, "anim")
513513

514514

515+
# Tests for sort_by_length parameter (Issue #11)
516+
def test_replace_sort_by_length_default(self):
517+
# Default behavior: longest match first prevents substring clobbering
518+
input_string = "this is a test"
519+
replacement_pairs = """is::WAS
520+
this::THAT"""
521+
result = self.replace_node.string_replace(
522+
input_string=input_string,
523+
replacement_pairs=replacement_pairs,
524+
replacement_delimiter="::",
525+
match_case=False,
526+
match_whole_string=True,
527+
preserve_punctuation=True,
528+
remove_extra_spaces=True,
529+
sort_by_length=True
530+
)[0]
531+
# "this" (4 chars) replaced before "is" (2 chars), so "this" is not mangled
532+
self.assertEqual(result, "THAT WAS a test")
533+
534+
def test_replace_sort_by_length_disabled(self):
535+
# Input-order replacement: pairs processed in the order given
536+
input_string = "cat is here"
537+
replacement_pairs = """cat::dog
538+
dog::fish"""
539+
result = self.replace_node.string_replace(
540+
input_string=input_string,
541+
replacement_pairs=replacement_pairs,
542+
replacement_delimiter="::",
543+
match_case=False,
544+
match_whole_string=True,
545+
preserve_punctuation=True,
546+
remove_extra_spaces=True,
547+
sort_by_length=False
548+
)[0]
549+
# "cat" -> "dog" first, then "dog" -> "fish" (chained replacement)
550+
self.assertEqual(result, "fish is here")
551+
552+
def test_replace_sort_by_length_enabled_no_chaining(self):
553+
# With sort enabled, equal-length pairs are sorted reverse-alphabetically,
554+
# so "dog::fish" runs before "cat::dog". Since "dog" isn't in the original
555+
# string, only "cat::dog" produces a match. No chaining occurs.
556+
input_string = "cat is here"
557+
replacement_pairs = """cat::dog
558+
dog::fish"""
559+
result = self.replace_node.string_replace(
560+
input_string=input_string,
561+
replacement_pairs=replacement_pairs,
562+
replacement_delimiter="::",
563+
match_case=False,
564+
match_whole_string=True,
565+
preserve_punctuation=True,
566+
remove_extra_spaces=True,
567+
sort_by_length=True
568+
)[0]
569+
self.assertEqual(result, "dog is here")
570+
571+
# Tests for use_regex parameter (Issue #11)
572+
def test_replace_use_regex_basic(self):
573+
# Regex pattern matching
574+
input_string = "hello123world456"
575+
replacement_pairs = r"\d+:: "
576+
result = self.replace_node.string_replace(
577+
input_string=input_string,
578+
replacement_pairs=replacement_pairs,
579+
replacement_delimiter="::",
580+
match_case=False,
581+
match_whole_string=False,
582+
preserve_punctuation=True,
583+
remove_extra_spaces=False,
584+
use_regex=True
585+
)[0]
586+
self.assertEqual(result, "hello world ")
587+
588+
def test_replace_use_regex_disabled(self):
589+
# Without regex, special chars are treated literally
590+
input_string = r"hello\d+world"
591+
replacement_pairs = r"\d+::REPLACED"
592+
result = self.replace_node.string_replace(
593+
input_string=input_string,
594+
replacement_pairs=replacement_pairs,
595+
replacement_delimiter="::",
596+
match_case=False,
597+
match_whole_string=False,
598+
preserve_punctuation=True,
599+
remove_extra_spaces=True,
600+
use_regex=False
601+
)[0]
602+
self.assertEqual(result, "helloREPLACEDworld")
603+
604+
def test_replace_use_regex_with_groups(self):
605+
# Regex with capture groups in replacement
606+
input_string = "2024-01-15"
607+
replacement_pairs = r"(\d{4})-(\d{2})-(\d{2})::\3/\2/\1"
608+
result = self.replace_node.string_replace(
609+
input_string=input_string,
610+
replacement_pairs=replacement_pairs,
611+
replacement_delimiter="::",
612+
match_case=False,
613+
match_whole_string=False,
614+
preserve_punctuation=True,
615+
remove_extra_spaces=True,
616+
use_regex=True
617+
)[0]
618+
self.assertEqual(result, "15/01/2024")
619+
620+
def test_replace_use_regex_character_class(self):
621+
# Regex with character class
622+
input_string = "Hello, World! How are you?"
623+
replacement_pairs = "[!?,]::;"
624+
result = self.replace_node.string_replace(
625+
input_string=input_string,
626+
replacement_pairs=replacement_pairs,
627+
replacement_delimiter="::",
628+
match_case=False,
629+
match_whole_string=False,
630+
preserve_punctuation=True,
631+
remove_extra_spaces=True,
632+
use_regex=True
633+
)[0]
634+
self.assertEqual(result, "Hello; World; How are you;")
635+
636+
def test_replace_regex_with_input_order(self):
637+
# Combine regex + input-order for chained regex replacements
638+
input_string = "foo123bar456baz"
639+
replacement_pairs = r"""\d+::_
640+
foo_bar::REPLACED"""
641+
result = self.replace_node.string_replace(
642+
input_string=input_string,
643+
replacement_pairs=replacement_pairs,
644+
replacement_delimiter="::",
645+
match_case=False,
646+
match_whole_string=False,
647+
preserve_punctuation=True,
648+
remove_extra_spaces=True,
649+
sort_by_length=False,
650+
use_regex=True
651+
)[0]
652+
# First: \d+ -> _ gives "foo_bar_baz"
653+
# Then: foo_bar -> REPLACED gives "REPLACED_baz"
654+
self.assertEqual(result, "REPLACED_baz")
655+
656+
515657
if __name__ == '__main__':
516658
unittest.main()

0 commit comments

Comments
 (0)