feat: 新增条件节点和多语言脚本支持

refactor(flow): 将Decision节点重命名为Condition节点
feat(flow): 新增多语言脚本执行器(Rhai/JS/Python)
feat(flow): 实现变量映射和执行功能
feat(flow): 添加条件节点执行逻辑
feat(frontend): 为开始/结束节点添加多语言描述
test: 添加yaml条件转换测试
chore: 移除废弃的storage模块
This commit is contained in:
2025-09-19 13:41:52 +08:00
parent 81757eecf5
commit 62789fce42
25 changed files with 1651 additions and 313 deletions

View File

@ -0,0 +1,215 @@
use anyhow::Result;
use serde_json::Value as V;
use tracing::info;
pub(crate) fn eval_condition_json(ctx: &serde_json::Value, cond: &serde_json::Value) -> Result<bool> {
// 新增:若 cond 为数组,按 AND 语义评估(全部为 true 才为 true
if let Some(arr) = cond.as_array() {
let mut all_true = true;
for (idx, item) in arr.iter().enumerate() {
let ok = eval_condition_json(ctx, item)?;
info!(target = "udmin.flow", index = idx, result = %ok, "condition group item (AND)");
if !ok { all_true = false; }
}
info!(target = "udmin.flow", count = arr.len(), result = %all_true, "condition group evaluated (AND)");
return Ok(all_true);
}
// 支持前端 Condition 组件导出的: { left:{type, content}, operator, right? }
let left = cond.get("left").ok_or_else(|| anyhow::anyhow!("missing left"))?;
let op_raw = cond.get("operator").and_then(|v| v.as_str()).unwrap_or("");
let right_raw = cond.get("right");
// 解析弱等于标记:当右值 schema.extra.weak 为 true 时,对字符串比较采用忽略大小写与首尾空白的弱等于
let weak_eq = right_raw
.and_then(|r| r.get("schema"))
.and_then(|s| s.get("extra"))
.and_then(|e| e.get("weak"))
.and_then(|b| b.as_bool())
.unwrap_or(false);
let lval = resolve_value(ctx, left)?;
let rval = match right_raw { Some(v) => Some(resolve_value(ctx, v)?), None => None };
// 归一化操作符:忽略大小写,替换下划线为空格
let op = op_raw.trim().to_lowercase().replace('_', " ");
// 工具函数
fn to_f64(v: &V) -> Option<f64> {
match v {
V::Number(n) => n.as_f64(),
V::String(s) => s.parse::<f64>().ok(),
_ => None,
}
}
fn is_empty_val(v: &V) -> bool {
match v {
V::Null => true,
V::String(s) => s.trim().is_empty(),
V::Array(a) => a.is_empty(),
V::Object(m) => m.is_empty(),
_ => false,
}
}
fn norm_str(s: &str) -> String { s.trim().to_lowercase() }
fn json_equal(a: &V, b: &V, weak: bool) -> bool {
match (a, b) {
// 数字:做宽松比较(字符串转数字)
(V::Number(_), V::Number(_)) | (V::Number(_), V::String(_)) | (V::String(_), V::Number(_)) => {
match (to_f64(a), to_f64(b)) { (Some(x), Some(y)) => x == y, _ => a == b }
}
// 字符串:若 weak 则忽略大小写与首尾空白
(V::String(sa), V::String(sb)) if weak => norm_str(sa) == norm_str(sb),
_ => a == b,
}
}
fn contains(left: &V, right: &V, weak: bool) -> bool {
match (left, right) {
(V::String(s), V::String(t)) => {
if weak { norm_str(s).contains(&norm_str(t)) } else { s.contains(t) }
}
(V::Array(arr), r) => arr.iter().any(|x| json_equal(x, r, weak)),
(V::Object(map), V::String(key)) => {
if weak { map.keys().any(|k| norm_str(k) == norm_str(key)) } else { map.contains_key(key) }
}
_ => false,
}
}
fn in_op(left: &V, right: &V, weak: bool) -> bool {
match right {
V::Array(arr) => arr.iter().any(|x| json_equal(left, x, weak)),
V::Object(map) => match left { V::String(k) => {
if weak { map.keys().any(|kk| norm_str(kk) == norm_str(k)) } else { map.contains_key(k) }
}, _ => false },
V::String(hay) => match left { V::String(needle) => {
if weak { norm_str(hay).contains(&norm_str(needle)) } else { hay.contains(needle) }
}, _ => false },
_ => false,
}
}
fn bool_like(v: &V) -> bool {
match v {
V::Bool(b) => *b,
V::Null => false,
V::Number(n) => n.as_f64().map(|x| x != 0.0).unwrap_or(false),
V::String(s) => {
let s_l = s.trim().to_lowercase();
if s_l == "true" { true } else if s_l == "false" { false } else { !s_l.is_empty() }
}
V::Array(a) => !a.is_empty(),
V::Object(m) => !m.is_empty(),
}
}
let res = match (op.as_str(), &lval, &rval) {
// 等于 / 不等于(适配所有 JSON 类型;数字按 f64 比较,其他走深度相等)
("equal" | "equals" | "==" | "eq", l, Some(r)) => json_equal(l, r, weak_eq),
("not equal" | "!=" | "not equals" | "neq", l, Some(r)) => !json_equal(l, r, weak_eq),
// 数字比较
("greater than" | ">" | "gt", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a > b, _ => false },
("greater than or equal" | ">=" | "gte" | "ge", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a >= b, _ => false },
("less than" | "<" | "lt", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a < b, _ => false },
("less than or equal" | "<=" | "lte" | "le", l, Some(r)) => match (to_f64(l), to_f64(r)) { (Some(a), Some(b)) => a <= b, _ => false },
// 包含 / 不包含(字符串、数组、对象(键)
("contains", l, Some(r)) => contains(l, r, weak_eq),
("not contains", l, Some(r)) => !contains(l, r, weak_eq),
// 成员关系left in right / not in
("in", l, Some(r)) => in_op(l, r, weak_eq),
("not in" | "nin", l, Some(r)) => !in_op(l, r, weak_eq),
// 为空 / 非空字符串、数组、对象、null
("is empty" | "empty" | "isempty", l, _) => is_empty_val(l),
("is not empty" | "not empty" | "notempty", l, _) => !is_empty_val(l),
// 布尔判断(对各类型进行布尔化)
("is true" | "is true?" | "istrue", l, _) => bool_like(l),
("is false" | "isfalse", l, _) => !bool_like(l),
_ => false,
};
// 记录调试日志,便于定位条件为何未命中
let l_dbg = match &lval { V::String(s) => format!("\"{}\"", s), _ => format!("{}", lval) };
let r_dbg = match &rval { Some(V::String(s)) => format!("\"{}\"", s), Some(v) => format!("{}", v), None => "<none>".to_string() };
info!(target = "udmin.flow", op=%op, weak=%weak_eq, left=%l_dbg, right=%r_dbg, result=%res, "condition eval");
Ok(res)
}
pub(crate) fn resolve_value(ctx: &serde_json::Value, v: &serde_json::Value) -> Result<serde_json::Value> {
let t = v.get("type").and_then(|v| v.as_str()).unwrap_or("");
match t {
"constant" => Ok(v.get("content").cloned().unwrap_or(V::Null)),
"ref" => {
// content: [nodeId, field]
if let Some(arr) = v.get("content").and_then(|v| v.as_array()) {
if arr.len() >= 2 {
if let (Some(node), Some(field)) = (arr[0].as_str(), arr[1].as_str()) {
let val = ctx
.get("nodes")
.and_then(|n| n.get(node))
.and_then(|m| m.get(field))
.cloned()
.or_else(|| ctx.get(field).cloned())
.unwrap_or(V::Null);
return Ok(val);
}
}
}
Ok(V::Null)
}
"expression" => {
let expr = v.get("content").and_then(|x| x.as_str()).unwrap_or("");
if expr.trim().is_empty() { return Ok(V::Null); }
Ok(crate::flow::engine::eval_rhai_expr_json(expr, ctx).unwrap_or(V::Null))
}
_ => Ok(V::Null),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cond_eq_const(left: serde_json::Value, right: serde_json::Value) -> serde_json::Value {
json!({
"left": {"type": "constant", "content": left},
"operator": "eq",
"right": {"type": "constant", "content": right}
})
}
#[test]
fn and_group_all_true() {
let ctx = json!({});
let group = json!([
cond_eq_const(json!(100), json!(100)),
json!({
"left": {"type": "constant", "content": 100},
"operator": ">",
"right": {"type": "constant", "content": 10}
})
]);
let ok = eval_condition_json(&ctx, &group).unwrap();
assert!(ok);
}
#[test]
fn and_group_has_false() {
let ctx = json!({});
let group = json!([
cond_eq_const(json!(100), json!(10)), // false
json!({
"left": {"type": "constant", "content": 100},
"operator": ">",
"right": {"type": "constant", "content": 10}
})
]);
let ok = eval_condition_json(&ctx, &group).unwrap();
assert!(!ok);
}
}

View File

@ -1,2 +1,8 @@
pub mod http;
pub mod db;
pub mod db;
// removed: pub mod expr;
pub mod variable;
pub mod script_rhai;
pub mod script_js;
pub mod script_python;
pub mod condition;

View File

@ -0,0 +1,54 @@
use async_trait::async_trait;
use serde_json::Value;
use tracing::{debug, info};
use std::time::Instant;
use crate::flow::task::Executor;
use crate::flow::domain::{NodeDef, NodeId};
fn read_node_script_file(ctx: &Value, node_id: &str, lang_key: &str) -> Option<String> {
if let Some(nodes) = ctx.get("nodes").and_then(|v| v.as_object()) {
if let Some(m) = nodes.get(node_id).and_then(|v| v.get("scripts")).and_then(|v| v.as_object()) {
return m.get(lang_key).and_then(|v| v.as_str()).map(|s| s.to_string());
}
}
None
}
fn truncate_str(s: &str, max: usize) -> String {
let s = s.replace('\n', " ").replace('\r', " ");
if s.len() <= max { s } else { format!("{}", &s[..max]) }
}
#[derive(Default)]
pub struct ScriptJsTask;
#[async_trait]
impl Executor for ScriptJsTask {
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
let start = Instant::now();
// 优先 nodes.<id>.scripts.js 指定的脚本文件路径
if let Some(path) = read_node_script_file(ctx, &node_id.0, "js") {
let preview = truncate_str(&path, 120);
info!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_js task: JavaScript file execution not implemented yet (skipped)");
return Ok(());
}
// 兼容 inline 配置(暂不执行,仅提示)
let inline = ctx.get("script")
.or_else(|| ctx.get("expr"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(code) = inline {
let preview = truncate_str(&code, 200);
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_js task: inline script provided, but execution not implemented");
let _elapsed = start.elapsed().as_millis();
info!(target = "udmin.flow", node=%node_id.0, "script_js task: JavaScript execution not implemented yet (skipped)");
return Ok(());
}
info!(target = "udmin.flow", node=%node_id.0, "script_js task: no script found, skip");
Ok(())
}
}

View File

@ -0,0 +1,54 @@
use async_trait::async_trait;
use serde_json::Value;
use tracing::{debug, info};
use std::time::Instant;
use crate::flow::task::Executor;
use crate::flow::domain::{NodeDef, NodeId};
fn read_node_script_file(ctx: &Value, node_id: &str, lang_key: &str) -> Option<String> {
if let Some(nodes) = ctx.get("nodes").and_then(|v| v.as_object()) {
if let Some(m) = nodes.get(node_id).and_then(|v| v.get("scripts")).and_then(|v| v.as_object()) {
return m.get(lang_key).and_then(|v| v.as_str()).map(|s| s.to_string());
}
}
None
}
fn truncate_str(s: &str, max: usize) -> String {
let s = s.replace('\n', " ").replace('\r', " ");
if s.len() <= max { s } else { format!("{}", &s[..max]) }
}
#[derive(Default)]
pub struct ScriptPythonTask;
#[async_trait]
impl Executor for ScriptPythonTask {
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
let start = Instant::now();
// 优先 nodes.<id>.scripts.python 指定的脚本文件路径
if let Some(path) = read_node_script_file(ctx, &node_id.0, "python") {
let preview = truncate_str(&path, 120);
info!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_python task: Python file execution not implemented yet (skipped)");
return Ok(());
}
// 兼容 inline 配置(暂不执行,仅提示)
let inline = ctx.get("script")
.or_else(|| ctx.get("expr"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(code) = inline {
let preview = truncate_str(&code, 200);
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_python task: inline script provided, but execution not implemented");
let _elapsed = start.elapsed().as_millis();
info!(target = "udmin.flow", node=%node_id.0, "script_python task: Python execution not implemented yet (skipped)");
return Ok(());
}
info!(target = "udmin.flow", node=%node_id.0, "script_python task: no script found, skip");
Ok(())
}
}

View File

@ -0,0 +1,135 @@
use serde_json::Value;
use tracing::{debug, info};
use std::fs;
use std::time::Instant;
use crate::flow::domain::NodeId;
use crate::flow::engine::eval_rhai_expr_json;
use crate::flow::task::Executor;
use crate::flow::domain::NodeDef;
use async_trait::async_trait;
fn truncate_str(s: &str, max: usize) -> String {
let s = s.replace('\n', " ").replace('\r', " ");
if s.len() <= max { s } else { format!("{}", &s[..max]) }
}
fn shallow_diff(before: &Value, after: &Value) -> (Vec<String>, Vec<String>, Vec<String>) {
use std::collections::BTreeSet;
let mut added = Vec::new();
let mut removed = Vec::new();
let mut modified = Vec::new();
let (Some(bm), Some(am)) = (before.as_object(), after.as_object()) else {
if before != after { modified.push("<root>".to_string()); }
return (added, removed, modified);
};
let bkeys: BTreeSet<_> = bm.keys().cloned().collect();
let akeys: BTreeSet<_> = am.keys().cloned().collect();
for k in akeys.difference(&bkeys) { added.push((*k).to_string()); }
for k in bkeys.difference(&akeys) { removed.push((*k).to_string()); }
for k in akeys.intersection(&bkeys) {
let key = (*k).to_string();
if bm.get(&key) != am.get(&key) { modified.push(key); }
}
(added, removed, modified)
}
pub fn exec_rhai_file(node_id: &NodeId, path: &str, ctx: &mut Value) -> anyhow::Result<()> {
let start = Instant::now();
let code = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
info!(target = "udmin.flow", node=%node_id.0, err=%e.to_string(), "script task: failed to read Rhai file");
return Ok(());
}
};
let script = code;
if script.trim().is_empty() {
info!(target = "udmin.flow", node=%node_id.0, "script task: empty Rhai file, skip");
return Ok(());
}
let preview = truncate_str(&script, 200);
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script task: will execute Rhai file");
let before_ctx = ctx.clone();
let wrapped = format!("{{ {} ; ctx }}", script);
let res = eval_rhai_expr_json(&wrapped, ctx);
let dur_ms = start.elapsed().as_millis();
match res {
Some(new_ctx) => {
let (added, removed, modified) = shallow_diff(&before_ctx, &new_ctx);
*ctx = new_ctx;
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, added=%added.len(), removed=%removed.len(), modified=%modified.len(), "script task: Rhai file executed and ctx updated");
if !(added.is_empty() && removed.is_empty() && modified.is_empty()) {
debug!(target = "udmin.flow", node=%node_id.0, ?added, ?removed, ?modified, "script task: ctx shallow diff");
}
}
None => {
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%preview, "script task: Rhai file execution failed, ctx unchanged");
}
}
Ok(())
}
fn read_node_script_file(ctx: &Value, node_id: &str) -> Option<String> {
if let Some(nodes) = ctx.get("nodes").and_then(|v| v.as_object()) {
if let Some(m) = nodes.get(node_id).and_then(|v| v.get("scripts")).and_then(|v| v.as_object()) {
return m.get("rhai").and_then(|v| v.as_str()).map(|s| s.to_string());
}
}
None
}
#[derive(Default)]
pub struct ScriptRhaiTask;
#[async_trait]
impl Executor for ScriptRhaiTask {
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
let start = Instant::now();
// 1) 文件脚本优先nodes.<id>.scripts.rhai -> 直接执行文件
if let Some(path) = read_node_script_file(ctx, &node_id.0) {
return exec_rhai_file(node_id, &path, ctx);
}
// 2) inline 脚本(支持 String 或 { script | expr }
let cfg: Option<String> = ctx.get("nodes")
.and_then(|nodes| nodes.get(&node_id.0))
.and_then(|n| n.get("script").or_else(|| n.get("expr")))
.and_then(|v| match v { Value::String(s) => Some(s.clone()), Value::Object(m) => m.get("script").or_else(|| m.get("expr")).and_then(|x| x.as_str()).map(|s| s.to_string()), _ => None })
.or_else(|| ctx.get("script").and_then(|v| v.as_str()).map(|s| s.to_string()))
.or_else(|| ctx.get("expr").and_then(|v| v.as_str()).map(|s| s.to_string()));
if let Some(script) = cfg {
if script.trim().is_empty() {
info!(target = "udmin.flow", node=%node_id.0, "script_rhai task: empty inline script, skip");
return Ok(());
}
let script_preview = truncate_str(&script, 200);
debug!(target = "udmin.flow", node=%node_id.0, preview=%script_preview, "script_rhai task: will execute Rhai inline script");
let before_ctx = ctx.clone();
let wrapped = format!("{{ {} ; ctx }}", script);
let res = super::super::engine::eval_rhai_expr_json(&wrapped, ctx);
let dur_ms = start.elapsed().as_millis();
match res {
Some(new_ctx) => {
let (added, removed, modified) = shallow_diff(&before_ctx, &new_ctx);
*ctx = new_ctx;
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, added=%added.len(), removed=%removed.len(), modified=%modified.len(), "script_rhai task: inline executed and ctx updated");
if !(added.is_empty() && removed.is_empty() && modified.is_empty()) {
debug!(target = "udmin.flow", node=%node_id.0, ?added, ?removed, ?modified, "script_rhai task: ctx shallow diff");
}
}
None => {
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%script_preview, "script_rhai task: inline execution failed, ctx unchanged");
}
}
return Ok(());
}
info!(target = "udmin.flow", node=%node_id.0, "script_rhai task: no script found, skip");
Ok(())
}
}

View File

@ -0,0 +1,133 @@
use async_trait::async_trait;
use serde_json::Value;
use tracing::info;
use crate::flow::task::Executor;
use crate::flow::domain::{NodeDef, NodeId};
use crate::flow::engine::eval_rhai_expr_json;
#[derive(Default)]
pub struct VariableTask;
fn resolve_assign_value(ctx: &Value, v: &Value) -> Value {
use serde_json::Value as V;
// helper: get by object path
fn get_by_path<'a>(mut cur: &'a V, path: &[&str]) -> Option<&'a V> {
for seg in path {
match cur {
V::Object(map) => {
if let Some(next) = map.get(*seg) { cur = next; } else { return None; }
}
_ => return None,
}
}
Some(cur)
}
let t = v.get("type").and_then(|v| v.as_str()).unwrap_or("");
match t {
"constant" => v.get("content").cloned().unwrap_or(V::Null),
"ref" => {
// frontend IFlowValue ref: content is [nodeId, key1, key2, ...] or [topKey, ...]
let parts: Vec<String> = v
.get("content")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
if parts.is_empty() { return V::Null; }
// Prefer nodes.<nodeId>.* if node id is provided
if parts.len() >= 1 {
let node_id = &parts[0];
let rest: Vec<&str> = parts.iter().skip(1).map(|s| s.as_str()).collect();
// 1) direct: nodes.<nodeId>.<rest...>
let mut path_nodes: Vec<&str> = vec!["nodes", node_id.as_str()];
path_nodes.extend(rest.iter().copied());
if let Some(val) = get_by_path(ctx, &path_nodes) { return val.clone(); }
// 2) HTTP shortcut: nodes.<nodeId>.http_response.<rest...> (e.g., [node, "body"])
let mut path_http: Vec<&str> = vec!["nodes", node_id.as_str(), "http_response"];
path_http.extend(rest.iter().copied());
if let Some(val) = get_by_path(ctx, &path_http) { return val.clone(); }
}
// Fallback: interpret as top-level path: ctx[parts[0]][parts[1]]...
let path_top: Vec<&str> = parts.iter().map(|s| s.as_str()).collect();
if let Some(val) = get_by_path(ctx, &path_top) { return val.clone(); }
// Additional fallback: if looks like [nodeId, ...rest] but nodes.* missing, try top-level with rest only
if parts.len() >= 2 {
let rest_only: Vec<&str> = parts.iter().skip(1).map(|s| s.as_str()).collect();
if let Some(val) = get_by_path(ctx, &rest_only) { return val.clone(); }
}
V::Null
}
"expression" => {
let expr = v.get("content").and_then(|x| x.as_str()).unwrap_or("");
if expr.trim().is_empty() { return V::Null; }
eval_rhai_expr_json(expr, ctx).unwrap_or(V::Null)
}
_ => {
// fallback: if content exists, treat as constant
v.get("content").cloned().unwrap_or(V::Null)
}
}
}
#[async_trait]
impl Executor for VariableTask {
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
// 读取 variable 配置:仅节点级
let node_id_str = &node_id.0;
let cfg = match ctx.get("nodes") {
Some(nodes) => nodes.get(node_id_str).and_then(|n| n.get("variable")).cloned(),
_ => None,
};
let Some(cfg) = cfg else {
info!(target = "udmin.flow", node=%node_id.0, "variable task: no config found, skip");
return Ok(());
};
// 支持 { assign: [...] } 或直接为数组
let assigns: Vec<Value> = match &cfg {
Value::Array(arr) => arr.clone(),
Value::Object(m) => m.get("assign").and_then(|v| v.as_array()).cloned().unwrap_or_default(),
_ => vec![],
};
if assigns.is_empty() {
info!(target = "udmin.flow", node=%node_id.0, "variable task: empty assign list, skip");
// 移除 variable 节点配置,避免出现在最终 ctx
if let Value::Object(map) = ctx { if let Some(Value::Object(nodes)) = map.get_mut("nodes") { nodes.remove(node_id_str); } }
return Ok(());
}
let mut applied = 0usize;
for item in assigns {
let op = item.get("operator").and_then(|v| v.as_str()).unwrap_or("assign");
let left = item.get("left").and_then(|v| v.as_str()).unwrap_or("");
let right = item.get("right").unwrap_or(&Value::Null);
if left.is_empty() { continue; }
let val = resolve_assign_value(ctx, right);
if let Value::Object(map) = ctx {
let exists = map.contains_key(left);
let do_set = match op {
"declare" => !exists,
_ => true,
};
if do_set {
map.insert(left.to_string(), val);
applied += 1;
}
}
}
// 执行完成后,移除 variable 节点,避免出现在最终 ctx
if let Value::Object(map) = ctx {
if let Some(Value::Object(nodes)) = map.get_mut("nodes") {
nodes.remove(node_id_str);
}
}
info!(target = "udmin.flow", node=%node_id.0, count=%applied, "variable task: assigned variables");
Ok(())
}
}