Add string/path utility nodes (PathJoin, StringFormat, StringExtract, StringSwitch)
Some checks failed
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled

Four new nodes that collapse common multi-node chains for path construction,
string templating, substring extraction, and boolean selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 11:06:32 +01:00
parent 3319c78de0
commit 1e3e30d8f1
4 changed files with 234 additions and 2 deletions

View File

@@ -112,6 +112,58 @@ This means segments don't need to be in order and can have gaps (e.g. 1, 5, 10).
<img src="assets/segment-lookup.svg" alt="Segment lookup diagram showing batch_data selection" />
</p>
## String & Path Utility Nodes
Four additional nodes that replace common multi-node chains for string and path operations.
### Path Join (`utils/path`)
Joins 16 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)

View File

@@ -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"

View File

@@ -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"]

170
string_utils.py Normal file
View File

@@ -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",
}