From 5d645958bd7dc9c6add90de843d55d550ff067e1 Mon Sep 17 00:00:00 2001 From: BRADSEC Date: Tue, 3 Mar 2026 20:25:08 +1000 Subject: [PATCH] feat: add sort_by_length and use_regex options to StringMultiReplace (Issue #11) --- pyproject.toml | 2 +- string_multi_replace_node.py | 26 ++++--- test_string_nodes.py | 142 +++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 25b116f..7b300a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/string_multi_replace_node.py b/string_multi_replace_node.py index 236120f..57b24b4 100644 --- a/string_multi_replace_node.py +++ b/string_multi_replace_node.py @@ -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) diff --git a/test_string_nodes.py b/test_string_nodes.py index d9d3d19..9481ce8 100644 --- a/test_string_nodes.py +++ b/test_string_nodes.py @@ -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() \ No newline at end of file