diff --git a/README.md b/README.md index 54d63e2..489913f 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,58 @@ This means segments don't need to be in order and can have gaps (e.g. 1, 5, 10). Segment lookup diagram showing batch_data selection

+## String & Path Utility Nodes + +Four additional nodes that replace common multi-node chains for string and path operations. + +### Path Join (`utils/path`) + +Joins 1–6 path segments using `os.path.join` with automatic normalization. + +| Input | Type | Notes | +|:---|:---|:---| +| `segment_1` | STRING | Required | +| `segment_2` – `segment_6` | STRING | Optional | + +**Output:** `path` (STRING) + +### String Format (`utils/string`) + +Python format-string templating (`{0}/{1:04d}.{2}`) with up to 8 connected inputs. Handles zero-padding, type conversion, and arbitrary templates in one node. + +| Input | Type | Notes | +|:---|:---|:---| +| `template` | STRING | Format string, e.g. `{0}/{1:04d}` | +| `v0` – `v7` | any | Optional values to substitute | + +**Output:** `string` (STRING) + +### String Extract (`utils/string`) + +Extracts substrings with 3 modes: + +| Mode | Description | +|:---|:---| +| `split_take` | Split on delimiter, take part at index (negative indices supported) | +| `between` | Extract text between two delimiters | +| `filename_parts` | Decompose path into dirname / basename / extension | + +**Outputs:** `result`, `dirname`, `basename`, `extension` (all STRING) + +### String Switch (`utils/string`) + +Boolean-based selection with built-in default values. + +| Input | Type | Notes | +|:---|:---|:---| +| `condition` | BOOLEAN | Required | +| `on_true` / `on_false` | any | Optional connected values | +| `default_true` / `default_false` | STRING | Fallback widget values | + +**Output:** `result` (any) + +When a connected input (`on_true`/`on_false`) is present it takes priority; otherwise the corresponding `default_*` string widget is used. + ## License [Apache 2.0](LICENSE) diff --git a/__init__.py b/__init__.py index 9756331..763f700 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,14 @@ -from .json_loader_dynamic import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from .json_loader_dynamic import ( + NODE_CLASS_MAPPINGS as _json_class_mappings, + NODE_DISPLAY_NAME_MAPPINGS as _json_display_mappings, +) +from .string_utils import ( + NODE_CLASS_MAPPINGS as _string_class_mappings, + NODE_DISPLAY_NAME_MAPPINGS as _string_display_mappings, +) + +NODE_CLASS_MAPPINGS = {**_json_class_mappings, **_string_class_mappings} +NODE_DISPLAY_NAME_MAPPINGS = {**_json_display_mappings, **_string_display_mappings} WEB_DIRECTORY = "./web" diff --git a/pyproject.toml b/pyproject.toml index 770437f..de146c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-json-dynamic" version = "1.0.0" -description = "A ComfyUI node that auto-discovers JSON keys and exposes them as typed output slots" +description = "ComfyUI nodes for dynamic JSON loading and string/path utility operations" license = { file = "LICENSE" } requires-python = ">=3.10" classifiers = ["Operating System :: OS Independent"] diff --git a/string_utils.py b/string_utils.py new file mode 100644 index 0000000..bb3fa67 --- /dev/null +++ b/string_utils.py @@ -0,0 +1,170 @@ +import os + + +class AnyType(str): + """Universal connector type that matches any ComfyUI type.""" + def __ne__(self, __value: object) -> bool: + return False + +any_type = AnyType("*") + + +class JDL_PathJoin: + """Joins 1-6 path segments using os.path.join.""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "segment_1": ("STRING", {"default": "", "multiline": False}), + }, + "optional": { + "segment_2": ("STRING", {"default": "", "multiline": False}), + "segment_3": ("STRING", {"default": "", "multiline": False}), + "segment_4": ("STRING", {"default": "", "multiline": False}), + "segment_5": ("STRING", {"default": "", "multiline": False}), + "segment_6": ("STRING", {"default": "", "multiline": False}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("path",) + FUNCTION = "join_path" + CATEGORY = "utils/path" + + def join_path(self, segment_1, segment_2="", segment_3="", segment_4="", + segment_5="", segment_6=""): + segments = [s for s in [segment_1, segment_2, segment_3, segment_4, + segment_5, segment_6] if s] + if not segments: + return ("",) + return (os.path.normpath(os.path.join(*segments)),) + + +class JDL_StringFormat: + """Python format-string templating with up to 8 inputs.""" + + @classmethod + def INPUT_TYPES(s): + optional = {} + for i in range(8): + optional[f"v{i}"] = (any_type, {"default": ""}) + return { + "required": { + "template": ("STRING", {"default": "{0}/{1}", "multiline": False}), + }, + "optional": optional, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("string",) + FUNCTION = "format_string" + CATEGORY = "utils/string" + + def format_string(self, template, **kwargs): + values = [] + for i in range(8): + values.append(kwargs.get(f"v{i}", "")) + try: + result = template.format(*values) + except (IndexError, KeyError, ValueError) as e: + result = f"[format error: {e}]" + return (result,) + + +class JDL_StringExtract: + """Extracts substrings via split, between-delimiters, or filename decomposition.""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "text": ("STRING", {"default": "", "multiline": False}), + "mode": (["split_take", "between", "filename_parts"],), + }, + "optional": { + "delimiter": ("STRING", {"default": "/", "multiline": False}), + "index": ("INT", {"default": -1, "min": -999, "max": 999}), + "delimiter_2": ("STRING", {"default": "", "multiline": False}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("result", "dirname", "basename", "extension") + FUNCTION = "extract" + CATEGORY = "utils/string" + + def extract(self, text, mode, delimiter="/", index=-1, delimiter_2=""): + dirname = os.path.dirname(text) + full_basename = os.path.basename(text) + name, ext = os.path.splitext(full_basename) + extension = ext.lstrip(".") + + if mode == "split_take": + parts = text.split(delimiter) + try: + result = parts[index] + except IndexError: + result = "" + elif mode == "between": + result = "" + start = text.find(delimiter) + if start != -1: + start += len(delimiter) + if delimiter_2: + end = text.find(delimiter_2, start) + if end != -1: + result = text[start:end] + else: + result = text[start:] + else: + result = text[start:] + else: # filename_parts + result = name + + return (result, dirname, name, extension) + + +class JDL_StringSwitch: + """Boolean-based string selection with built-in defaults.""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "condition": ("BOOLEAN", {"default": True}), + }, + "optional": { + "on_true": (any_type,), + "on_false": (any_type,), + "default_true": ("STRING", {"default": "", "multiline": False}), + "default_false": ("STRING", {"default": "", "multiline": False}), + }, + } + + RETURN_TYPES = (any_type,) + RETURN_NAMES = ("result",) + FUNCTION = "switch" + CATEGORY = "utils/string" + + def switch(self, condition, on_true=None, on_false=None, + default_true="", default_false=""): + if condition: + return (on_true if on_true is not None else default_true,) + else: + return (on_false if on_false is not None else default_false,) + + +NODE_CLASS_MAPPINGS = { + "JDL_PathJoin": JDL_PathJoin, + "JDL_StringFormat": JDL_StringFormat, + "JDL_StringExtract": JDL_StringExtract, + "JDL_StringSwitch": JDL_StringSwitch, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "JDL_PathJoin": "Path Join", + "JDL_StringFormat": "String Format", + "JDL_StringExtract": "String Extract", + "JDL_StringSwitch": "String Switch", +}