From b272e777012fd2392e5a3de9077d69f38893c6d9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 27 Apr 2026 19:00:36 -0700 Subject: [PATCH 1/2] Add useful attributes to the JSON and Regex nodes --- .../node_graph/document_node_definitions.rs | 10 ++--- node-graph/nodes/graphic/src/graphic.rs | 37 +++++++++++++++++++ node-graph/nodes/text/src/json.rs | 22 +++++++++-- node-graph/nodes/text/src/regex.rs | 25 +++++++++++-- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index ea910dd63c..92354d1224 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -1545,11 +1545,11 @@ fn document_node_definitions() -> HashMap) + // Secondary output: capture groups (Table), each row carries `start`/`end`/`name` attributes from `regex_find` NodeInput::node(NodeId(2), 0), ], nodes: [ - // Node 0: regex_find proto node — returns Vec of [whole_match, ...capture_groups] + // Node 0: regex_find proto node — returns Table of [whole_match, ...capture_groups] DocumentNode { inputs: vec![ NodeInput::import(concrete!(String), 0), @@ -1561,13 +1561,13 @@ fn document_node_definitions() -> HashMap + // Node 2: omit_element at index 0, returns the capture group rows as a Table, preserving each row's start/end/name attributes DocumentNode { inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(0.), false)], implementation: DocumentNodeImplementation::ProtoNode(graphic::omit_element::IDENTIFIER), diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 186595d5d8..8268563d1b 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -74,6 +74,43 @@ pub fn omit_element( } } +/// Returns the bare element (without its row attributes) at the specified index in a table. +/// Use this when downstream nodes want just the inner value rather than a single-row table. +/// If no value exists at that index, the element type's default is returned. +#[node_macro::node(category("General"))] +pub fn extract_element( + _: impl Ctx, + /// The table of data to extract from. + #[implementations( + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table>, + Table, + Table, + )] + table: Table, + /// The index of the item to retrieve, starting from 0 for the first item. Negative indices count backwards from the end of the collection, starting from -1 for the last item. + index: SignedInteger, +) -> T { + let len = table.len(); + let index = index as i32; + let resolved = if index < 0 { + let from_end = (-index) as usize; + if from_end > len { + return T::default(); + } + len - from_end + } else { + index as usize + }; + table.element(resolved).cloned().unwrap_or_default() +} + #[node_macro::node(category("General"))] async fn map( ctx: impl Ctx + CloneVarArgs + ExtractAll, diff --git a/node-graph/nodes/text/src/json.rs b/node-graph/nodes/text/src/json.rs index c1f1a7d3ce..8df1e184e5 100644 --- a/node-graph/nodes/text/src/json.rs +++ b/node-graph/nodes/text/src/json.rs @@ -210,11 +210,13 @@ fn query_json( let mut results = Vec::new(); resolve_all(&value, &segments, !unquote_strings, &mut results); - results.into_iter().next().unwrap_or_default() + results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default() } /// Extracts every matched value from a JSON string using a path expression (see that parameter's description for its syntax). A list of zero or more resultant strings is produced. The `[]` path accessor is used to read more than one value. /// +/// Each row carries a `type` attribute holding the matched value's JSON type (`"string"`, `"number"`, `"bool"`, `"null"`, `"object"`, or `"array"`). +/// /// This is useful in conjunction with the nodes: /// • **Index Elements**: access the `N`th query result. /// • **String to Number**: convert numeric query results to numbers. @@ -246,7 +248,7 @@ fn query_json_all( let mut results = Vec::new(); resolve_all(&value, &segments, !unquote_strings, &mut results); - results.into_iter().map(TableRow::new_from_element).collect() + results.into_iter().map(|(text, ty)| TableRow::new_from_element(text).with_attribute("type", ty.to_string())).collect() } /// A parsed segment of a JSON access path. @@ -402,6 +404,18 @@ fn json_value_to_string(value: &serde_json::Value, quote_strings: bool) -> Strin } } +/// Returns a short JSON-type name (`"string"`, `"number"`, `"bool"`, `"null"`, `"object"`, `"array"`) for a parsed value. +fn json_value_type_name(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::String(_) => "string", + serde_json::Value::Number(_) => "number", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Null => "null", + serde_json::Value::Object(_) => "object", + serde_json::Value::Array(_) => "array", + } +} + /// Navigates a JSON value by one path segment, returning the resulting value (or `None` if the path is invalid). fn json_navigate<'a>(value: &'a serde_json::Value, segment: &JsonPathSegment) -> Option<&'a serde_json::Value> { match segment { @@ -416,7 +430,7 @@ fn json_navigate<'a>(value: &'a serde_json::Value, segment: &JsonPathSegment) -> } /// Recursively resolves a path against a JSON value, fanning out at each `[]` and collecting leaf results. -fn resolve_all(value: &serde_json::Value, segments: &[JsonPathSegment], quote_strings: bool, results: &mut Vec) { +fn resolve_all(value: &serde_json::Value, segments: &[JsonPathSegment], quote_strings: bool, results: &mut Vec<(String, &'static str)>) { // Find the next IterateAll in the remaining segments let Some(iterate_position) = segments.iter().position(|s| matches!(s, JsonPathSegment::IterateAll)) else { // No more [] segments, navigate the rest linearly @@ -425,7 +439,7 @@ fn resolve_all(value: &serde_json::Value, segments: &[JsonPathSegment], quote_st let Some(next) = json_navigate(current, segment) else { return }; current = next; } - results.push(json_value_to_string(current, quote_strings)); + results.push((json_value_to_string(current, quote_strings), json_value_type_name(current))); return; }; diff --git a/node-graph/nodes/text/src/regex.rs b/node-graph/nodes/text/src/regex.rs index 72b3739f83..6122ac79e5 100644 --- a/node-graph/nodes/text/src/regex.rs +++ b/node-graph/nodes/text/src/regex.rs @@ -80,6 +80,9 @@ fn regex_replace( /// Finds a regex match in the string and returns its components. The result is a list where the first element is the whole match (`$0`) and subsequent elements are the capture groups (`$1`, `$2`, etc., if any). /// /// The match index selects which non-overlapping occurrence to return (0 for the first match). Returns an empty list if no match is found at the given index. +/// +/// Each row carries `start` and `end` byte-offset attributes pointing into the original string, plus a `name` attribute holding +/// the capture group's name (empty for unnamed groups, and for index 0 which is the whole match). #[node_macro::node(category(""))] fn regex_find( _: impl Ctx, @@ -111,6 +114,9 @@ fn regex_find( return Table::new(); }; + // Capture group names indexed positionally; index 0 (the whole match) is always None. + let capture_names: Vec> = regex.capture_names().map(|name| name.map(str::to_string)).collect(); + // Collect all matches since we need to support negative indexing let matches: Vec<_> = regex.captures_iter(&string).filter_map(|c| c.ok()).collect(); @@ -131,12 +137,20 @@ fn regex_find( // Index 0 is the whole match, 1+ are capture groups (0..captures.len()) - .map(|i| captures.get(i).map_or(String::new(), |m| m.as_str().to_string())) - .map(TableRow::new_from_element) + .map(|i| { + let captured = captures.get(i); + let text = captured.map_or(String::new(), |m| m.as_str().to_string()); + let start = captured.map_or(0_u64, |m| m.start() as u64); + let end = captured.map_or(0_u64, |m| m.end() as u64); + let name = capture_names.get(i).cloned().flatten().unwrap_or_default(); + TableRow::new_from_element(text).with_attribute("start", start).with_attribute("end", end).with_attribute("name", name) + }) .collect() } /// Finds all non-overlapping matches of a regular expression pattern in the string, returning a list of the matched substrings. +/// +/// Each row carries `start` and `end` byte-offset attributes pointing into the original string. #[node_macro::node(category("Text: Regex"))] fn regex_find_all( _: impl Ctx, @@ -169,8 +183,11 @@ fn regex_find_all( regex .find_iter(&string) .filter_map(|m| m.ok()) - .map(|m| m.as_str().to_string()) - .map(TableRow::new_from_element) + .map(|m| { + TableRow::new_from_element(m.as_str().to_string()) + .with_attribute("start", m.start() as u64) + .with_attribute("end", m.end() as u64) + }) .collect() } From 7b48f90fe3a161b1aa63034b852038544efd50e8 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 28 Apr 2026 14:04:03 -0700 Subject: [PATCH 2/2] Code review fix --- node-graph/nodes/graphic/src/graphic.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 8268563d1b..999df04d5b 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -68,7 +68,7 @@ pub fn omit_element( let index = index as i32; if index < 0 { - collection.omit_index_from_end(-index as usize) + collection.omit_index_from_end(index.unsigned_abs() as usize) } else { collection.omit_index(index as usize) } @@ -100,7 +100,7 @@ pub fn extract_element( let len = table.len(); let index = index as i32; let resolved = if index < 0 { - let from_end = (-index) as usize; + let from_end = index.unsigned_abs() as usize; if from_end > len { return T::default(); }