From a0807f3d2d7502950e9b664d16c2e0beb1152435 Mon Sep 17 00:00:00 2001 From: BRADSEC <7948876+bradsec@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:30:49 +1000 Subject: [PATCH] feat: add V3 (comfy_api) dual-API wrappers; clear error on invalid regex; tooltips; bump 2.0.11 v3_nodes.py builds a V3 io.ComfyNode per V1 class from its INPUT_TYPES/ RETURN_TYPES and delegates execute() to the V1 method (no forked logic), including the StringPreview OUTPUT_NODE ui.PreviewText path. comfy_entrypoint exported import-guarded. StringMultiReplace use_regex now raises a clear ValueError naming the bad pattern instead of a raw re.error. Added tooltips to strip/multi_replace boolean flags. Verified ComfyUI 0.25.0. --- README.md | 4 ++ __init__.py | 9 +++ pyproject.toml | 2 +- string_multi_replace_node.py | 19 +++++-- string_strip_node.py | 9 ++- v3_nodes.py | 104 +++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 v3_nodes.py diff --git a/README.md b/README.md index 902bd03..c4dbd64 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ git clone https://github.com/bradsec/ComfyUI_StringEssentials.git --- +## Compatibility + +Dual API: exports the legacy V1 `NODE_CLASS_MAPPINGS` and a V3 `comfy_entrypoint` (`comfy_api`). Verified on ComfyUI 0.25.0. On builds whose loader reads `NODE_CLASS_MAPPINGS` first, the V1 path is used and `comfy_entrypoint` is skipped, so nodes are not registered twice. + ## Support If you find this useful, please consider [starring the repo](https://github.com/bradsec/ComfyUI_StringEssentials). Stars help other people discover these nodes. diff --git a/__init__.py b/__init__.py index e1197e2..919fcc1 100644 --- a/__init__.py +++ b/__init__.py @@ -28,3 +28,12 @@ NODE_DISPLAY_NAME_MAPPINGS = { WEB_DIRECTORY = "./js" __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] + +# V3 schema wrappers, exposed only when comfy_api is importable. On the 0.25.0 +# loader the V1 NODE_CLASS_MAPPINGS above win and comfy_entrypoint is skipped, +# so the nodes are not registered twice. +try: + from .v3_nodes import comfy_entrypoint # noqa: F401 + __all__.append("comfy_entrypoint") +except ImportError: + pass diff --git a/pyproject.toml b/pyproject.toml index 274a356..e1e51a0 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.10" +version = "2.0.11" license = {file = "LICENSE"} [project.urls] diff --git a/string_multi_replace_node.py b/string_multi_replace_node.py index bf04500..ec8e0db 100644 --- a/string_multi_replace_node.py +++ b/string_multi_replace_node.py @@ -14,14 +14,17 @@ class StringMultiReplaceNode: "replacement_delimiter": ("STRING", {"default": "::", "tooltip": "Character(s) that separate search and replace strings. Default is two '::' (colons)"}), "match_case": ("BOOLEAN", {"default": False, - "label_on": "enabled", "label_off": "disabled"}), + "label_on": "enabled", "label_off": "disabled", + "tooltip": "When enabled, matching is case-sensitive."}), "match_whole_string": ("BOOLEAN", {"default": False, - "label_on": "enabled", "label_off": "disabled"}), + "label_on": "enabled", "label_off": "disabled", + "tooltip": "When enabled, only whole-word matches are replaced, not substrings inside other words."}), "preserve_punctuation": ("BOOLEAN", {"default": True, "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", + "tooltip": "When enabled, collapses repeated spaces and trims the result after replacement."}), "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."}), @@ -83,7 +86,15 @@ class StringMultiReplaceNode: # Original behavior: remove punctuation after match pattern = escaped + r'(?:[,;:.])?' - regex = re.compile(pattern, flags=flags) + try: + regex = re.compile(pattern, flags=flags) + except re.error as e: + # use_regex lets the user supply raw regex; surface the bad + # pattern and the reason instead of a bare traceback. + raise ValueError( + f"StringMultiReplace: invalid regex for search '{search_str}': {e}. " + f"Disable use_regex to treat search strings as literal text." + ) result = regex.sub(replace_str, result) if remove_extra_spaces: diff --git a/string_strip_node.py b/string_strip_node.py index 4fde873..718ffe1 100644 --- a/string_strip_node.py +++ b/string_strip_node.py @@ -12,14 +12,17 @@ class StringStripNode: "strings_to_remove": ("STRING", {"multiline": True, "default": "", "placeholder": "Enter strings to remove (one per line)", "tooltip": "Enter strings to remove (one per line). Can include multiple words or strings on one line like: 'This image is'. This will only match this entire string."}), "match_case": ("BOOLEAN", {"default": False, - "label_on": "enabled", "label_off": "disabled"}), + "label_on": "enabled", "label_off": "disabled", + "tooltip": "When enabled, matching is case-sensitive."}), "match_whole_string": ("BOOLEAN", {"default": True, - "label_on": "enabled", "label_off": "disabled"}), + "label_on": "enabled", "label_off": "disabled", + "tooltip": "When enabled, only whole-word matches are removed, not substrings inside other words."}), "preserve_punctuation": ("BOOLEAN", {"default": True, "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", + "tooltip": "When enabled, collapses repeated spaces and trims the result after removal."}) } } diff --git a/v3_nodes.py b/v3_nodes.py new file mode 100644 index 0000000..65d9185 --- /dev/null +++ b/v3_nodes.py @@ -0,0 +1,104 @@ +"""V3 (comfy_api schema) wrappers for the StringEssentials nodes. + +Import-guarded: if comfy_api is unavailable (older ComfyUI), this module exposes +nothing and the V1 NODE_CLASS_MAPPINGS in __init__.py remain the only path. + +The wrappers are thin adapters. Each V3 node is built from the matching V1 class +by reading its INPUT_TYPES / RETURN_TYPES / RETURN_NAMES, and its execute() +delegates to the V1 class method, so the string logic is never forked. +""" + +try: + from comfy_api.v0_0_2 import io, ui, ComfyExtension + _V3_AVAILABLE = True +except ImportError: + _V3_AVAILABLE = False + + +if _V3_AVAILABLE: + + _OUT = {"STRING": io.String, "BOOLEAN": io.Boolean, "INT": io.Int} + + def _to_input(name, spec, optional): + t = spec[0] + opts = spec[1] if len(spec) > 1 else {} + if isinstance(t, list): + return io.Combo.Input(name, options=t, default=opts.get("default"), + tooltip=opts.get("tooltip"), optional=optional) + if t == "STRING": + return io.String.Input(name, default=opts.get("default", ""), + multiline=opts.get("multiline", False), + placeholder=opts.get("placeholder"), + tooltip=opts.get("tooltip"), + force_input=opts.get("forceInput"), optional=optional) + if t == "INT": + return io.Int.Input(name, default=opts.get("default", 0), min=opts.get("min"), + max=opts.get("max"), step=opts.get("step"), + tooltip=opts.get("tooltip"), optional=optional) + if t == "BOOLEAN": + return io.Boolean.Input(name, default=opts.get("default", False), + label_on=opts.get("label_on"), label_off=opts.get("label_off"), + tooltip=opts.get("tooltip"), optional=optional) + raise ValueError(f"StringEssentials V3: unmapped input type {t!r} for {name}") + + def _build_inputs(v1_cls): + spec = v1_cls.INPUT_TYPES() + inputs = [] + for section, optional in (("required", False), ("optional", True)): + for name, s in spec.get(section, {}).items(): + inputs.append(_to_input(name, s, optional)) + return inputs + + def _build_outputs(v1_cls): + names = getattr(v1_cls, "RETURN_NAMES", None) or v1_cls.RETURN_TYPES + return [_OUT[t].Output(id=n, display_name=n) + for t, n in zip(v1_cls.RETURN_TYPES, names)] + + def make_v3(node_id, display_name, v1_cls): + func = v1_cls.FUNCTION + is_output = getattr(v1_cls, "OUTPUT_NODE", False) + desc = getattr(v1_cls, "DESCRIPTION", "") + has_is_changed = hasattr(v1_cls, "IS_CHANGED") + + class _V3(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id=node_id, + display_name=display_name, + category=v1_cls.CATEGORY, + description=desc, + inputs=_build_inputs(v1_cls), + outputs=_build_outputs(v1_cls), + is_output_node=is_output, + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + result = getattr(v1_cls(), func)(**kwargs) + # OUTPUT_NODE preview returns {"ui": {"text": (...)}, "result": (...)}. + if isinstance(result, dict): + text = result.get("ui", {}).get("text", [""]) + res = result.get("result", ()) + return io.NodeOutput(*res, ui=ui.PreviewText(text[0] if text else "")) + return io.NodeOutput(*result) + + if has_is_changed: + @classmethod + def _fingerprint(cls, **kwargs): + return v1_cls.IS_CHANGED(**kwargs) + _V3.fingerprint_inputs = _fingerprint + + _V3.__name__ = node_id + "V3" + return _V3 + + class StringEssentialsExtension(ComfyExtension): + async def get_node_list(self): + # Imported at call time (after the package finished importing) to + # avoid a circular import with __init__.py. + from . import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + return [make_v3(nid, NODE_DISPLAY_NAME_MAPPINGS.get(nid, nid), cls) + for nid, cls in NODE_CLASS_MAPPINGS.items()] + + async def comfy_entrypoint() -> "StringEssentialsExtension": + return StringEssentialsExtension()