434 lines
15 KiB
C#
434 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Left-side tree view control that displays parsed XSD schema nodes and provides actions
|
|
/// such as copy-as-TSV, validate against XML, and export sample XML.
|
|
/// </summary>
|
|
public partial class LeftTreeView : UserControl
|
|
{
|
|
public LeftTreeView()
|
|
{
|
|
InitializeComponent();
|
|
}
|
|
|
|
private async void OnCopyAsTsvClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
var node = (sender as MenuItem)?.DataContext as SchemaNode;
|
|
if (node == null)
|
|
node =
|
|
(sender as Control)?.GetVisualAncestors().OfType<Control>().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)
|
|
{
|
|
var node = (sender as MenuItem)?.DataContext as SchemaNode;
|
|
if (node == null)
|
|
node = (sender as Control)?.GetVisualAncestors().OfType<Control>().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<FilePickerFileType>
|
|
{
|
|
new FilePickerFileType("XML files") { Patterns = new List<string> { "*.xml" }, MimeTypes = new List<string>{"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();
|
|
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<string?> TryGetLocalPathAsync(IStorageFile file)
|
|
{
|
|
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")
|
|
}
|
|
};
|
|
|
|
if (dialog.Content is Grid grid)
|
|
{
|
|
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 };
|
|
|
|
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);
|
|
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<string, SortedSet<string>>(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<string>(StringComparer.Ordinal);
|
|
dict[name] = set;
|
|
}
|
|
|
|
set.Add(value);
|
|
}
|
|
|
|
if (dict.Count == 0) return string.Empty;
|
|
|
|
var parts = new List<string>(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<Control>().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<FilePickerFileType>
|
|
{
|
|
new FilePickerFileType("XML file") { Patterns = new List<string> { "*.xml" }, MimeTypes = new List<string>{"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 string SanitizeXmlLocalName(string? name, string fallback)
|
|
{
|
|
var candidate = string.IsNullOrWhiteSpace(name) ? fallback : name!;
|
|
return XmlConvert.EncodeLocalName(candidate);
|
|
}
|
|
|
|
private static void WriteElementRecursive(XmlWriter writer, SchemaNode node)
|
|
{
|
|
var localName = SanitizeXmlLocalName(node.Name, "Element");
|
|
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 = SanitizeXmlLocalName(attr.Name, "attr");
|
|
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}";
|
|
}
|
|
|
|
} |