diff --git a/Examples/Nodify.Calculator/ApplicationViewModel.cs b/Examples/Nodify.Calculator/ApplicationViewModel.cs index c6cd4bd..4d45578 100644 --- a/Examples/Nodify.Calculator/ApplicationViewModel.cs +++ b/Examples/Nodify.Calculator/ApplicationViewModel.cs @@ -39,45 +39,26 @@ namespace Nodify.Calculator }); SaveFileCommand = new DelegateCommand(() => { - var firstEditor = Editors.First(); - var allNodes = firstEditor.Calculator.Operations; - - SaveGraphModel svm = new SaveGraphModel(); - foreach (var item in allNodes) + try { - SaveNodes svn = new SaveNodes() - { - Location = item.Location, - }; - svm.Nodes.Add(svn); + var firstEditor = Editors.First(); + GraphSerializer.Save(firstEditor.Calculator); } - - - var jsonEditors = JsonConvert.SerializeObject(svm, new JsonSerializerSettings + catch (Exception ex) { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore - }); - //var jsonEditors = JsonConvert.SerializeObject(Editors); - File.WriteAllText("SaveFile.AEXN", jsonEditors); + MessageBox.Show($"Failed to save: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } }); OpenFileCommand = new DelegateCommand(() => { - if (File.Exists("SaveFile.AEXN")) + try { - var allText = File.ReadAllText("SaveFile.AEXN"); - var edts = JsonConvert.DeserializeObject(allText); var firstEditor = Editors.First(); - var nodesToAdd = edts.Nodes; - foreach (var item in nodesToAdd) - { - firstEditor.Calculator.Operations.Add(new() - { - Location = item.Location, - NodeId = "H", - Title = "HHA" - }); - } - + GraphSerializer.Load(firstEditor.Calculator, firstEditor.Calculator.OperationsMenu); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to load: {ex.Message}", "Load Error", MessageBoxButton.OK, MessageBoxImage.Error); } }); Editors.WhenAdded((editor) => @@ -98,6 +79,14 @@ namespace Nodify.Calculator { Name = $"Editor {Editors.Count + 1}" }); + + // Auto-load saved graph from project DB + try + { + var firstEditor = Editors.First(); + GraphSerializer.Load(firstEditor.Calculator, firstEditor.Calculator.OperationsMenu); + } + catch { } } private void OnOpenInnerCalculator(EditorViewModel parentEditor, CalculatorViewModel calculator) diff --git a/Examples/Nodify.Calculator/CalculatorViewModel.cs b/Examples/Nodify.Calculator/CalculatorViewModel.cs index 187ae49..808b882 100644 --- a/Examples/Nodify.Calculator/CalculatorViewModel.cs +++ b/Examples/Nodify.Calculator/CalculatorViewModel.cs @@ -7,6 +7,8 @@ namespace Nodify.Calculator { public class CalculatorViewModel : ObservableObject { + public bool IsLoading { get; set; } + public CalculatorViewModel() { CreateConnectionCommand = new DelegateCommand( @@ -27,13 +29,13 @@ namespace Nodify.Calculator c.Output.ValueObservers.Add(c.Input); // Dynamic Split node: populate outputs when a model connects to the Square input - HandleSplitNodeConnected(c); + if (!IsLoading) HandleSplitNodeConnected(c); // Dynamic Copy node: adapt connectors when something connects - HandleCopyNodeConnected(c); + if (!IsLoading) HandleCopyNodeConnected(c); // Dynamic Take node: adapt connectors when a list connects - HandleTakeNodeConnected(c); + if (!IsLoading) HandleTakeNodeConnected(c); }) .WhenRemoved(c => { @@ -65,7 +67,7 @@ namespace Nodify.Calculator Operations.WhenAdded(x => { x.Input.WhenRemoved(RemoveConnection); - x.NodeId = (Operations.Count + 1).ToString(); + if (!IsLoading) x.NodeId = (Operations.Count + 1).ToString(); Debug.WriteLine($"Currently adding the node with node id : {x.NodeId} , Title : {x.Title}"); if (x is CalculatorInputOperationViewModel ci) { diff --git a/Examples/Nodify.Calculator/GraphSerializer.cs b/Examples/Nodify.Calculator/GraphSerializer.cs new file mode 100644 index 0000000..be33d9a --- /dev/null +++ b/Examples/Nodify.Calculator/GraphSerializer.cs @@ -0,0 +1,420 @@ +using Nodify.Calculator.Models; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Windows; + +namespace Nodify.Calculator +{ + public static class GraphSerializer + { + public static void Save(CalculatorViewModel calculator) + { + var graph = new SaveGraphModel(); + var operations = calculator.Operations; + var connections = calculator.Connections; + + // Ensure all nodes have IDs + int idCounter = 1; + foreach (var op in operations) + { + if (string.IsNullOrEmpty(op.NodeId)) + op.NodeId = $"node_{idCounter++}"; + } + + // Serialize nodes + foreach (var op in operations) + { + var nodeData = new NodeData + { + NodeId = op.NodeId, + Title = op.Title ?? string.Empty, + LocationX = op.Location.X, + LocationY = op.Location.Y + }; + + switch (op) + { + case TakeOperationViewModel take: + nodeData.NodeType = "System"; + nodeData.SystemOp = take.SystemOperationType.ToString(); + nodeData.NthIndex = take.NthIndex; + nodeData.IsRandom = take.IsRandom; + break; + case FunctionOperationViewModel func: + nodeData.NodeType = "Function"; + nodeData.FunctionName = func.FunctionName; + foreach (var p in func.InputParameters) + nodeData.FunctionInputs.Add(new FunctionParamData { Name = p.Name, Type = p.Type }); + foreach (var p in func.OutputParameters) + nodeData.FunctionOutputs.Add(new FunctionParamData { Name = p.Name, Type = p.Type }); + break; + case SystemOperationViewModel sys: + nodeData.NodeType = "System"; + nodeData.SystemOp = sys.SystemOperationType.ToString(); + // For GET_SET nodes, derive ClassName from title + if (sys.SystemOperationType == SystemOperations.GET_SET) + { + var title = sys.Title ?? ""; + if (title.StartsWith("GET ")) nodeData.ClassName = title.Substring(4).Trim(); + else if (title.StartsWith("SET ")) nodeData.ClassName = title.Substring(4).Trim(); + } + break; + case APIOperationViewModel api: + nodeData.NodeType = "API"; + nodeData.OPType = api.OperationType; + nodeData.ResponseModelClassName = api.ResponseModelClassName; + break; + case ExpandoOperationViewModel: + nodeData.NodeType = "Expando"; + break; + case CalculatorOperationViewModel: + nodeData.NodeType = "Calculator"; + break; + default: + nodeData.NodeType = "Normal"; + break; + } + + // Save connector metadata for all nodes + foreach (var c in op.Input) + nodeData.InputConnectors.Add(SerializeConnector(c)); + foreach (var c in op.Output) + nodeData.OutputConnectors.Add(SerializeConnector(c)); + + graph.Nodes.Add(nodeData); + } + + // Serialize connections + foreach (var conn in connections) + { + var inputOp = conn.Input?.Operation; + var outputOp = conn.Output?.Operation; + if (inputOp == null || outputOp == null) continue; + + var inputIndex = inputOp.Input.IndexOf(conn.Input); + var outputIndex = outputOp.Output.IndexOf(conn.Output); + + if (inputIndex < 0 || outputIndex < 0) continue; + + graph.Connections.Add(new ConnectionData + { + TargetNodeId = inputOp.NodeId, + TargetConnectorIndex = inputIndex, + SourceNodeId = outputOp.NodeId, + SourceConnectorIndex = outputIndex + }); + } + + // Save to LiteDB + using var db = new LiteDbHelper("Graph"); + db.DeleteMany(_ => true); + db.Insert(graph); + } + + public static void Load(CalculatorViewModel calculator, OperationsMenuViewModel opsMenu) + { + SaveGraphModel graph; + try + { + using var db = new LiteDbHelper("Graph"); + graph = db.FindAll().FirstOrDefault(); + } + catch + { + return; + } + + if (graph == null || graph.Nodes.Count == 0) return; + + // Clear existing + calculator.Connections.Clear(); + calculator.Operations.Clear(); + + calculator.IsLoading = true; + try + { + + var nodeMap = new Dictionary(); + + // Recreate nodes + foreach (var nd in graph.Nodes) + { + OperationViewModel op = null; + try + { + op = RecreateNode(nd, opsMenu); + } + catch + { + continue; + } + + if (op == null) continue; + + op.NodeId = nd.NodeId; + op.Location = new System.Windows.Point(nd.LocationX, nd.LocationY); + nodeMap[nd.NodeId] = op; + calculator.Operations.Add(op); + } + + // Recreate connections + foreach (var cd in graph.Connections) + { + if (!nodeMap.TryGetValue(cd.SourceNodeId, out var sourceOp)) continue; + if (!nodeMap.TryGetValue(cd.TargetNodeId, out var targetOp)) continue; + if (cd.SourceConnectorIndex >= sourceOp.Output.Count) continue; + if (cd.TargetConnectorIndex >= targetOp.Input.Count) continue; + + var sourceConn = sourceOp.Output[cd.SourceConnectorIndex]; + var targetConn = targetOp.Input[cd.TargetConnectorIndex]; + + calculator.Connections.Add(new ConnectionViewModel + { + Input = targetConn, + Output = sourceConn + }); + } + } + finally + { + calculator.IsLoading = false; + } + } + + private static OperationViewModel RecreateNode(NodeData nd, OperationsMenuViewModel opsMenu) + { + switch (nd.NodeType) + { + case "API": + { + // Find matching swagger operation or create from saved data + var info = new OperationInfoViewModel + { + Title = nd.Title, + OPType = nd.OPType?.ToLower() ?? "get", + Type = OperationType.API, + ResponseModelClassName = nd.ResponseModelClassName ?? string.Empty + }; + // Restore inputs from saved connector data (skip flow triangle at index 0) + foreach (var ic in nd.InputConnectors) + { + if (ic.Shape != "Triangle") + info.Input.Add(ic.Title); + } + info.Output.Add(""); + return OperationFactory.GetOperation(info); + } + + case "System": + { + if (Enum.TryParse(nd.SystemOp, out var sysOp)) + { + var info = new OperationInfoViewModel + { + Title = nd.Title, + Type = OperationType.System, + sysOp = sysOp + }; + + if (sysOp == SystemOperations.GET_SET && !string.IsNullOrEmpty(nd.ClassName)) + { + info.IsModelNode = true; + info.ClassName = nd.ClassName; + } + + // Set up inputs/outputs based on system operation type + switch (sysOp) + { + case SystemOperations.BEGIN: + info.Output.Add(""); // flow output only + break; + case SystemOperations.END: + info.Input.Add(""); // flow input only + break; + case SystemOperations.TAKE: + info.Input.Add("List"); + break; + case SystemOperations.COPY: + info.Input.Add(""); + break; + case SystemOperations.SPLIT: + info.Input.Add(""); + break; + case SystemOperations.IF: + info.Output.Add(""); + info.IsFlowNode = true; + break; + default: + info.Output.Add(""); + info.IsFlowNode = true; + break; + } + + var op = OperationFactory.GetOperation(info); + + // Restore Take properties + if (op is TakeOperationViewModel takeOp) + { + takeOp.NthIndex = nd.NthIndex; + takeOp.IsRandom = nd.IsRandom; + } + + // Restore dynamic connectors for SPLIT/COPY/TAKE from saved data + RestoreDynamicConnectors(op, nd); + + return op; + } + return null; + } + + case "Function": + { + var info = new OperationInfoViewModel + { + Title = nd.FunctionName, + Type = OperationType.System, + sysOp = SystemOperations.FUNCTION, + IsFunction = true + }; + foreach (var inp in nd.FunctionInputs) + info.FunctionInputs.Add(new FunctionParameterInfo { Name = inp.Name, Type = inp.Type }); + foreach (var outp in nd.FunctionOutputs) + info.FunctionOutputs.Add(new FunctionParameterInfo { Name = outp.Name, Type = outp.Type }); + info.Output.Add(""); + return OperationFactory.GetOperation(info); + } + + case "Expando": + { + var info = new OperationInfoViewModel + { + Title = nd.Title, + Type = OperationType.Expando + }; + // Restore input count from saved connectors + foreach (var ic in nd.InputConnectors) + info.Input.Add(ic.Title); + info.Output.Add(""); + return OperationFactory.GetOperation(info); + } + + default: + { + var info = new OperationInfoViewModel + { + Title = nd.Title, + Type = OperationType.Normal + }; + foreach (var ic in nd.InputConnectors) + info.Input.Add(ic.Title); + info.Output.Add(""); + return OperationFactory.GetOperation(info); + } + } + } + + private static void RestoreDynamicConnectors(OperationViewModel op, NodeData nd) + { + // For SPLIT nodes: if saved outputs have more than just the flow triangle, + // restore the dynamic property outputs + if (op is SystemOperationViewModel sysVm) + { + if (sysVm.SystemOperationType == SystemOperations.SPLIT) + { + // Remove default non-triangle outputs, then add saved ones + var defaultNonTriangle = op.Output.Where(o => o.Shape != ConnectorShape.Triangle).ToList(); + foreach (var d in defaultNonTriangle) op.Output.Remove(d); + + foreach (var sc in nd.OutputConnectors) + { + if (sc.Shape == "Triangle") continue; + op.Output.Add(DeserializeConnector(sc, false)); + } + // Update title from saved + sysVm.Title = nd.Title; + } + else if (sysVm.SystemOperationType == SystemOperations.COPY) + { + // Restore adapted connectors + if (nd.InputConnectors.Count > 0 && nd.InputConnectors[0].Shape != "Circle") + { + // Input was adapted — restore + var savedInput = nd.InputConnectors[0]; + if (op.Input.Count > 0) + { + var inp = op.Input[0]; + inp.Shape = ParseShape(savedInput.Shape); + inp.ConnectorColor = Color.FromArgb(savedInput.ColorArgb); + inp.DataType = savedInput.DataType; + } + } + + // Restore output connectors + var existingOutputs = op.Output.ToList(); + foreach (var eo in existingOutputs) op.Output.Remove(eo); + foreach (var sc in nd.OutputConnectors) + { + op.Output.Add(DeserializeConnector(sc, false)); + } + } + else if (sysVm.SystemOperationType == SystemOperations.TAKE) + { + // Restore adapted list input + foreach (var inp in op.Input) + { + if (!inp.IsTakeListConnector) continue; + var savedInp = nd.InputConnectors.FirstOrDefault(c => c.IsTakeListConnector); + if (savedInp != null) + { + inp.Shape = ParseShape(savedInp.Shape); + inp.ConnectorColor = Color.FromArgb(savedInp.ColorArgb); + inp.DataType = savedInp.DataType; + } + } + // Restore output + var existingOutputs = op.Output.ToList(); + foreach (var eo in existingOutputs) op.Output.Remove(eo); + foreach (var sc in nd.OutputConnectors) + { + op.Output.Add(DeserializeConnector(sc, false)); + } + } + } + } + + private static ConnectorData SerializeConnector(ConnectorViewModel c) + { + return new ConnectorData + { + Title = c.Title ?? string.Empty, + Shape = c.Shape.ToString(), + ColorArgb = c.RawColor.ToArgb(), + DataType = c.DataType ?? string.Empty, + IsCopyConnector = c.IsCopyConnector, + IsTakeListConnector = c.IsTakeListConnector + }; + } + + private static ConnectorViewModel DeserializeConnector(ConnectorData cd, bool isInput) + { + return new ConnectorViewModel + { + Title = cd.Title, + Shape = ParseShape(cd.Shape), + ConnectorColor = Color.FromArgb(cd.ColorArgb), + DataType = cd.DataType, + IsCopyConnector = cd.IsCopyConnector, + IsTakeListConnector = cd.IsTakeListConnector, + IsInput = isInput + }; + } + + private static ConnectorShape ParseShape(string shape) + { + return Enum.TryParse(shape, out var s) ? s : ConnectorShape.Circle; + } + } +} diff --git a/Examples/Nodify.Calculator/MainWindow.xaml.cs b/Examples/Nodify.Calculator/MainWindow.xaml.cs index 12d226f..9a9b391 100644 --- a/Examples/Nodify.Calculator/MainWindow.xaml.cs +++ b/Examples/Nodify.Calculator/MainWindow.xaml.cs @@ -1,4 +1,6 @@ using Nodify.Interactivity; +using System.ComponentModel; +using System.Linq; using System.Windows; using System.Windows.Input; @@ -16,6 +18,39 @@ namespace Nodify.Calculator typeof(UIElement), Keyboard.PreviewGotKeyboardFocusEvent, (KeyboardFocusChangedEventHandler)OnPreviewGotKeyboardFocus); + + Closing += MainWindow_Closing; + } + + private void MainWindow_Closing(object sender, CancelEventArgs e) + { + var result = MessageBox.Show( + "Do you want to save the project before closing?", + "Save Project", + MessageBoxButton.YesNoCancel, + MessageBoxImage.Question); + + if (result == MessageBoxResult.Cancel) + { + e.Cancel = true; + return; + } + + if (result == MessageBoxResult.Yes) + { + try + { + if (DataContext is ApplicationViewModel appVm && appVm.Editors.Count > 0) + { + var firstEditor = appVm.Editors.First(); + GraphSerializer.Save(firstEditor.Calculator); + } + } + catch (System.Exception ex) + { + MessageBox.Show($"Failed to save: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } } private void OnPreviewGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) diff --git a/Examples/Nodify.Calculator/Models/SaveGraphModel.cs b/Examples/Nodify.Calculator/Models/SaveGraphModel.cs index d2c7b15..294d142 100644 --- a/Examples/Nodify.Calculator/Models/SaveGraphModel.cs +++ b/Examples/Nodify.Calculator/Models/SaveGraphModel.cs @@ -1,20 +1,75 @@ -using Microsoft.CodeAnalysis; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Collections.Generic; using System.Windows; namespace Nodify.Calculator.Models { public class SaveGraphModel { - public string Name { get; set; } - - public List Nodes { get; set; } = new List(); + public int Id { get; set; } = 1; + public List Nodes { get; set; } = new List(); + public List Connections { get; set; } = new List(); } + public class NodeData + { + /// Unique ID matching OperationViewModel.NodeId + public string NodeId { get; set; } = string.Empty; + + /// Discriminator: "API", "System", "Expando", "Calculator", "Function" + public string NodeType { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + public double LocationX { get; set; } + public double LocationY { get; set; } + + // API node properties + public string OPType { get; set; } = string.Empty; + public string ResponseModelClassName { get; set; } = string.Empty; + + // System node properties + public string SystemOp { get; set; } = string.Empty; + public string ClassName { get; set; } = string.Empty; + + // Take node properties + public int NthIndex { get; set; } + public bool IsRandom { get; set; } + + // Function node properties + public string FunctionName { get; set; } = string.Empty; + public List FunctionInputs { get; set; } = new List(); + public List FunctionOutputs { get; set; } = new List(); + + /// Saved input connector metadata (for dynamic nodes like SPLIT/COPY/TAKE) + public List InputConnectors { get; set; } = new List(); + /// Saved output connector metadata + public List OutputConnectors { get; set; } = new List(); + } + + public class ConnectorData + { + public string Title { get; set; } = string.Empty; + public string Shape { get; set; } = "Circle"; + public int ColorArgb { get; set; } + public string DataType { get; set; } = string.Empty; + public bool IsCopyConnector { get; set; } + public bool IsTakeListConnector { get; set; } + } + + public class ConnectionData + { + public string SourceNodeId { get; set; } = string.Empty; + public int SourceConnectorIndex { get; set; } + public string TargetNodeId { get; set; } = string.Empty; + public int TargetConnectorIndex { get; set; } + } + + public class FunctionParamData + { + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + } + + // Keep legacy for compatibility public class SaveNodes { public Point Location { get; set; }