using System.Globalization; using System.Text; using System.Threading.Tasks; using System.Xml; using System.IO; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Platform.Storage; using Avalonia.VisualTree; using XSDVisualiser.Core; using XSDVisualiser.Desktop.ViewModels; namespace XSDVisualiser.Desktop.Views; public partial class LeftTreeView : UserControl { public LeftTreeView() { InitializeComponent(); } private async void OnCopyAsTsvClick(object? sender, RoutedEventArgs e) { // Determine the node from the menu item's DataContext var node = (sender as MenuItem)?.DataContext as SchemaNode; if (node == null) node = (sender as Control)?.GetVisualAncestors().OfType().FirstOrDefault()?.DataContext as SchemaNode; if (node == null) return; var tsv = BuildTsv(node); var topLevel = TopLevel.GetTopLevel(this); if (topLevel?.Clipboard != null) await topLevel.Clipboard.SetTextAsync(tsv); } private async void OnValidateAgainstXmlClick(object? sender, RoutedEventArgs e) { // Resolve node from context var node = (sender as MenuItem)?.DataContext as SchemaNode; if (node == null) node = (sender as Control)?.GetVisualAncestors().OfType().FirstOrDefault()?.DataContext as SchemaNode; if (node == null) return; var vm = DataContext as MainWindowViewModel; if (vm == null || string.IsNullOrEmpty(vm.CurrentFilePath)) { await ShowTextDialogAsync("Validate XML", "No XSD file is currently loaded. Please open an XSD file first."); return; } var top = TopLevel.GetTopLevel(this); var sp = top?.StorageProvider; if (sp == null) return; var options = new FilePickerOpenOptions { Title = "Select XML file to validate", AllowMultiple = false, FileTypeFilter = new List { new FilePickerFileType("XML files") { Patterns = new List { "*.xml" }, MimeTypes = new List{"application/xml","text/xml"} }, FilePickerFileTypes.All } }; var files = await sp.OpenFilePickerAsync(options); var file = files?.FirstOrDefault(); if (file == null) return; var xmlPath = await TryGetLocalPathAsync(file); if (string.IsNullOrEmpty(xmlPath)) { await ShowTextDialogAsync("Validate XML", "The selected file is not accessible via a local path. Please choose a local XML file."); return; } if (string.IsNullOrEmpty(node.Name)) { await ShowTextDialogAsync("Validate XML", "Selected schema node has no name and cannot be validated."); return; } var result = XmlValidator.ValidateAgainstElement(vm.CurrentFilePath!, node.Name!, node.Namespace, xmlPath!); var summary = BuildValidationSummary(node, xmlPath!, result); await ShowTextDialogAsync("Validation Result", summary); } private static string BuildTsv(SchemaNode root) { var sb = new StringBuilder(); // Header sb.AppendLine( "Depth\tPath\tName\tNamespace\tTypeName\tBuiltInType\tMinOccurs\tMaxOccurs\tContentModel\tConstraints\tIsNillable"); var initialPath = root.Name ?? string.Empty; AppendNode(sb, root, 0, initialPath); return sb.ToString(); } private static void AppendNode(StringBuilder sb, SchemaNode node, int depth, string path) { string San(string? s) { if (string.IsNullOrEmpty(s)) return string.Empty; return s.Replace("\t", " ").Replace("\r", " ").Replace("\n", " "); } var min = node.Cardinality?.Min.ToString(CultureInfo.InvariantCulture) ?? string.Empty; string max; if (node.Cardinality?.MaxIsUnbounded == true) max = "unbounded"; else max = node.Cardinality != null ? node.Cardinality.Max.ToString(CultureInfo.InvariantCulture) : string.Empty; var line = string.Join("\t", new[] { depth.ToString(CultureInfo.InvariantCulture), San(path), San(node.Name), San(node.Namespace), San(node.TypeName), San(node.BuiltInType), min, max, San(node.ContentModel), San(FormatConstraints(node)), node.IsNillable ? "true" : "false" }); sb.AppendLine(line); if (node.Children != null) foreach (var child in node.Children) { var nextPathSegment = child.Name ?? string.Empty; var childPath = string.IsNullOrEmpty(San(path)) ? nextPathSegment : $"{path}/{nextPathSegment}"; AppendNode(sb, child, depth + 1, childPath); } } private static async Task TryGetLocalPathAsync(IStorageFile file) { // Avalonia provides TryGetLocalPath extension for local files; otherwise, we can't access it via System.IO API. return file.TryGetLocalPath(); } private static string BuildValidationSummary(SchemaNode node, string xmlPath, XmlValidationResult result) { var sb = new StringBuilder(); sb.AppendLine($"Element: {{{node.Namespace}}}{node.Name}"); sb.AppendLine($"XML: {xmlPath}"); sb.AppendLine($"Valid: {(result.IsValid ? "Yes" : "No")}"); var errors = result.Errors.ToList(); var warnings = result.Warnings.ToList(); sb.AppendLine($"Errors: {errors.Count}, Warnings: {warnings.Count}"); sb.AppendLine(); foreach (var issue in result.Issues) { var prefix = issue.Severity == System.Xml.Schema.XmlSeverityType.Error ? "Error" : "Warning"; var loc = (issue.LineNumber > 0 || issue.LinePosition > 0) ? $" (Line {issue.LineNumber}, Pos {issue.LinePosition})" : string.Empty; sb.AppendLine($"- {prefix}{loc}: {issue.Message}"); } return sb.ToString(); } private async Task ShowTextDialogAsync(string title, string text) { var dialog = new Window { Title = title, Width = 700, Height = 500, CanResize = true, Content = new Grid { RowDefinitions = new RowDefinitions("*,Auto") } }; // Compose content if (dialog.Content is Grid grid) { // Use a read-only TextBox so users can select text easily var textBox = new TextBox { Text = text, IsReadOnly = true, AcceptsReturn = true, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Margin = new Thickness(12) }; var scroller = new ScrollViewer { Content = textBox }; // Copy button var copyButton = new Button { Content = "Copy", MinWidth = 80, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(8) }; copyButton.Click += async (_, _) => { var topLevel = TopLevel.GetTopLevel(this); if (topLevel?.Clipboard != null) { await topLevel.Clipboard.SetTextAsync(text); // Provide lightweight feedback by changing content briefly if (copyButton.Content is string) copyButton.Content = "Copied"; await Task.Delay(1000); if (copyButton.Content is string) copyButton.Content = "Copy"; } }; var closeButton = new Button { Content = "Close", MinWidth = 80, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(8) }; closeButton.Click += (_, _) => dialog.Close(); var buttonsPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; buttonsPanel.Children.Add(copyButton); buttonsPanel.Children.Add(closeButton); Grid.SetRow(buttonsPanel, 1); grid.Children.Add(scroller); grid.Children.Add(buttonsPanel); } var top = TopLevel.GetTopLevel(this); if (top is Window owner) await dialog.ShowDialog(owner); else dialog.Show(); } private static string FormatConstraints(SchemaNode node) { var cons = node.Constraints?.Constraints; if (cons == null || cons.Count == 0) return string.Empty; var dict = new SortedDictionary>(StringComparer.Ordinal); foreach (var c in cons) { var name = c.Name; var value = c.Value; if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(value)) continue; if (!dict.TryGetValue(name, out var set)) { set = new SortedSet(StringComparer.Ordinal); dict[name] = set; } set.Add(value); } if (dict.Count == 0) return string.Empty; var parts = new List(dict.Count); foreach (var kvp in dict) { var joinedValues = string.Join(",", kvp.Value); parts.Add($"{kvp.Key}={joinedValues}"); } return string.Join(";", parts); } private async void OnExportXmlClick(object? sender, RoutedEventArgs e) { // Determine the node from the menu item's DataContext var node = (sender as MenuItem)?.DataContext as SchemaNode ?? (sender as Control)?.GetVisualAncestors().OfType().FirstOrDefault()?.DataContext as SchemaNode; if (node == null) return; var top = TopLevel.GetTopLevel(this); var sp = top?.StorageProvider; if (sp == null) return; var suggestedName = string.IsNullOrWhiteSpace(node.Name) ? "schema-node.xml" : $"{node.Name}.xml"; var saveOptions = new FilePickerSaveOptions { Title = "Export subtree as XML", SuggestedFileName = suggestedName, DefaultExtension = "xml", ShowOverwritePrompt = true, FileTypeChoices = new List { new FilePickerFileType("XML file") { Patterns = new List { "*.xml" }, MimeTypes = new List{"application/xml","text/xml"} }, FilePickerFileTypes.All } }; var dest = await sp.SaveFilePickerAsync(saveOptions); if (dest == null) return; var localPath = dest.TryGetLocalPath(); if (string.IsNullOrEmpty(localPath)) { await ShowTextDialogAsync("Export XML", "The selected destination is not accessible as a local path."); return; } try { await WriteXmlToFileAsync(node, localPath!); await ShowTextDialogAsync("Export XML", $"Exported subtree to:\n{localPath}"); } catch (Exception ex) { await ShowTextDialogAsync("Export XML", $"Failed to export XML.\n\n{ex.Message}"); } } private static string BuildXmlString(SchemaNode root) { var settings = new XmlWriterSettings { Indent = true, OmitXmlDeclaration = false, Encoding = Encoding.UTF8, NewLineOnAttributes = false, Async = false }; var sb = new StringBuilder(); using (var writer = XmlWriter.Create(sb, settings)) { writer.WriteStartDocument(); WriteElementRecursive(writer, root); writer.WriteEndDocument(); } return sb.ToString(); } private static async Task WriteXmlToFileAsync(SchemaNode root, string path) { var settings = new XmlWriterSettings { Indent = true, OmitXmlDeclaration = false, Encoding = new UTF8Encoding(false), NewLineOnAttributes = false, Async = true }; await using var fs = File.Create(path); await using var writer = XmlWriter.Create(fs, settings); await writer.WriteStartDocumentAsync(); WriteElementRecursive(writer, root); await writer.WriteEndDocumentAsync(); await writer.FlushAsync(); } private static void WriteElementRecursive(XmlWriter writer, SchemaNode node) { var localName = string.IsNullOrWhiteSpace(node.Name) ? "Element" : node.Name!; if (!string.IsNullOrEmpty(node.Namespace)) writer.WriteStartElement(localName, node.Namespace); else writer.WriteStartElement(localName); // Write attributes if any (use type + constraints as placeholder value) foreach (var attr in node.Attributes) { var attrName = string.IsNullOrWhiteSpace(attr.Name) ? "attr" : attr.Name!; var value = BuildTypeAndConstraintText(attr.BuiltInType ?? attr.TypeName, attr.Constraints); if (!string.IsNullOrEmpty(attr.Namespace)) writer.WriteAttributeString(attrName, attr.Namespace, value); else writer.WriteAttributeString(attrName, value); } var hasChildren = node.Children is { Count: > 0 }; if (hasChildren) { foreach (var child in node.Children) { WriteElementRecursive(writer, child); } } else { // leaf node: place datatype and constraints in text content var text = BuildTypeAndConstraintText(node.BuiltInType ?? node.TypeName, node.Constraints); if (!string.IsNullOrWhiteSpace(text)) writer.WriteString(text); } writer.WriteEndElement(); } private static string BuildTypeAndConstraintText(string? type, ConstraintSet? constraintSet) { var cons = constraintSet?.Constraints; var typePart = string.IsNullOrWhiteSpace(type) ? string.Empty : type.Trim(); var constraintsPart = string.Empty; if (cons is { Count: > 0 }) { var tmpNode = new SchemaNode { Constraints = constraintSet }; constraintsPart = FormatConstraints(tmpNode); } if (string.IsNullOrEmpty(typePart) && string.IsNullOrEmpty(constraintsPart)) return string.Empty; if (string.IsNullOrEmpty(constraintsPart)) return typePart; return string.IsNullOrEmpty(typePart) ? constraintsPart : $"{typePart} {constraintsPart}"; } }