<Feat>: Add XML-based payload archive filename configuration, increase revision to 3.9.3145.103

Cause:

- Payload archive filenames required values from the generated XML payload.
- Date/time values needed filename-safe formatting.
- XML result values such as Passed and Failed needed configurable conversion to P and F.

Solution:

1. Added XML filename tokens
   - Supports any unique non-repeating XML value using {Xml:/path}.
   - Resolves filename values directly from the generated payload.

2. Added user-friendly token configuration
   - Clicking inside a token opens the XML value chooser.
   - Added Time format and Mask transformations.

3. Improved filename generation
   - Validates XML paths and rejects ambiguous repeating values.
   - Sanitizes resolved values for safe filesystem usage.
   - Passes the per-meter payload context from ResultsWriter.

4. Integrated the new dialog
   - Added PayloadFileNameXmlTokenDlg and its Designer file.
   - Updated the TBF project configuration.

5. Increased revision
   - Updated revision to 3.9.3145.103.
This commit is contained in:
2026-09-08 12:52:40 +02:00
parent 41408b30da
commit 57152ae749
7 changed files with 1055 additions and 81 deletions
+2 -2
View File
@@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// Build Number
// Revision
//
[assembly: AssemblyVersion("3.9.3145.102")]
[assembly: AssemblyFileVersion("3.9.3145.102")]
[assembly: AssemblyVersion("3.9.3145.103")]
[assembly: AssemblyFileVersion("3.9.3145.103")]
@@ -995,89 +995,21 @@ namespace TBF.Rig.Output.DB.ResultsWriter
}
/// <summary>
/// Resolves the P/F file-name token from the standard TBF meter result.
/// Resolves the legacy P/F file-name token from the aggregate meter result.
/// </summary>
/// <remarks>
/// The file name is metadata and is not derived by parsing a
/// customer-specific XML path. Existing configurations normally expose
/// the standard result as "Test passed()". Unknown or unavailable values
/// are represented by U so that the payload is never mislabeled as a
/// pass or failure.
/// This keeps the legacy {Result} token independent of test-dependent
/// mappings such as Test passed(). New configurations may instead use an
/// {Xml:...} token to read and optionally format a value directly from the
/// generated payload.
/// </remarks>
private string ResolveMeterResultCode(
WaterMeter wm)
{
if (wm == null ||
resultsWriterCfg.SelectedItems == null)
{
return "U";
}
Results.WMeterRsltItemSpec resultItem =
resultsWriterCfg.SelectedItems
.FirstOrDefault(
item =>
item != null &&
string.Equals(
item.Name,
"Test passed()",
StringComparison.OrdinalIgnoreCase));
if (resultItem == null)
{
resultItem =
resultsWriterCfg.SelectedItems
.FirstOrDefault(
item =>
item != null &&
!IsRepeatDestination(
item.Caption) &&
!string.IsNullOrWhiteSpace(
item.Caption) &&
item.Caption.EndsWith(
"/@STATUS",
StringComparison.OrdinalIgnoreCase));
}
if (resultItem == null)
if (wm == null)
return "U";
string resultValue =
resultItem.Print(
wm);
string normalized =
(resultValue ??
string.Empty)
.Trim()
.ToUpperInvariant();
switch (normalized)
{
case "1":
case "TRUE":
case "YES":
case "PASS":
case "PASSED":
case "P":
return "P";
case "0":
case "FALSE":
case "NO":
case "FAIL":
case "FAILED":
case "F":
return "F";
default:
log.WarnFormat(
"Meter result could not be converted to P/F for WM position {0}. Value='{1}'. Using U.",
wm.WMPosition,
resultValue);
return "U";
}
return wm.PassedFromAllTests() ? "P" : "F";
}
/// <summary>
@@ -7,6 +7,7 @@ using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Interfaces;
namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters
@@ -100,7 +101,8 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters
string outputFileName =
CreatePayloadFileName(
fileNameContext,
false);
false,
payload);
if (cfg.ArchivePayload)
{
@@ -188,7 +190,8 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters
string outputFileName =
CreatePayloadFileName(
fileNameContext,
false);
false,
payload);
DataWriteRequest request =
new DataWriteRequest
@@ -257,7 +260,8 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters
string outputFileName =
CreatePayloadFileName(
fileNameContext,
true);
true,
payload);
string archiveFilePath =
ArchivePayload(
@@ -456,7 +460,8 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters
/// </summary>
private string CreatePayloadFileName(
PayloadFileNameContext context,
bool preview)
bool preview,
string payload)
{
context =
context ??
@@ -503,6 +508,8 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters
"PayloadName",
GetPayloadBaseName());
fileName = ReplaceXmlFileNameTokens(fileName, payload);
if (fileName.IndexOf('{') >= 0 ||
fileName.IndexOf('}') >= 0)
{
@@ -545,6 +552,286 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters
return fileName;
}
/// <summary>
/// Replaces XML filename tokens with values read from the generated payload.
/// </summary>
/// <remarks>
/// Token syntax:
/// {Xml:/BATCH/PANEL/@TIMESTAMP|timeformat:yyyyMMdd_HHmmss}
/// {Xml:/BATCH/PANEL/@STATUS|mask:Passed=P;Failed=F}
///
/// The transformation is optional. Repeating destinations are intentionally
/// rejected because one filename must resolve to exactly one XML value.
/// </remarks>
private string ReplaceXmlFileNameTokens(string template, string payload)
{
const string pattern = @"\{Xml:(?<definition>[^{}]+)\}";
return Regex.Replace(
template,
pattern,
match =>
{
string destinationPath;
string transformation;
ParseXmlFileNameTokenDefinition(
match.Groups["definition"].Value,
out destinationPath,
out transformation);
string value = ResolveXmlFileNameValue(payload, destinationPath);
string formattedValue = ApplyXmlFileNameTransformation(value, transformation);
// Sanitize every resolved XML value before the complete filename
// is passed to Path.GetFileName(). Otherwise slashes contained in
// values such as dates would be interpreted as directory separators.
return CreateSafeFileNamePart(formattedValue);
},
RegexOptions.IgnoreCase);
}
/// <summary>
/// Parses the part following the Xml: prefix.
/// </summary>
/// <remarks>
/// A vertical bar introduces a named transformation. The earlier
/// path:DateTimeFormat syntax remains accepted for backward compatibility.
/// </remarks>
private void ParseXmlFileNameTokenDefinition(
string definition,
out string destinationPath,
out string transformation)
{
string tokenDefinition = (definition ?? string.Empty).Trim();
int transformationSeparator = tokenDefinition.IndexOf('|');
if (transformationSeparator >= 0)
{
destinationPath = tokenDefinition.Substring(0, transformationSeparator).Trim();
transformation = tokenDefinition.Substring(transformationSeparator + 1).Trim();
if (string.IsNullOrWhiteSpace(transformation))
throw new InvalidOperationException("XML filename transformation is empty.");
return;
}
// Backward compatibility with the earlier syntax:
// {Xml:/BATCH/PANEL/@TIMESTAMP:yyyyMMdd_HHmmss}
int finalPathSeparator = tokenDefinition.LastIndexOf('/');
int legacyFormatSeparator = tokenDefinition.IndexOf(':', finalPathSeparator + 1);
if (legacyFormatSeparator >= 0)
{
destinationPath = tokenDefinition.Substring(0, legacyFormatSeparator).Trim();
transformation = "timeformat:" +
tokenDefinition.Substring(legacyFormatSeparator + 1).Trim();
if (string.Equals(transformation, "timeformat:", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("XML filename time format is empty.");
return;
}
destinationPath = tokenDefinition;
transformation = string.Empty;
}
/// <summary>
/// Reads one non-repeating attribute or element value from the generated XML.
/// Element and attribute names are compared by local name so that a customer
/// reference using XML namespaces can still be used without namespace setup.
/// </summary>
private string ResolveXmlFileNameValue(string payload, string destinationPath)
{
if (string.IsNullOrWhiteSpace(destinationPath))
throw new InvalidOperationException("XML filename token does not contain a destination path.");
if (destinationPath.StartsWith("repeat:", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("A repeating XML destination cannot be used in a filename: " + destinationPath);
if (string.IsNullOrWhiteSpace(payload))
throw new InvalidOperationException("The generated XML payload is empty.");
string path = destinationPath.Trim();
string attributeName = null;
bool readText = path.EndsWith("/text()", StringComparison.OrdinalIgnoreCase);
if (readText)
{
path = path.Substring(0, path.Length - "/text()".Length);
}
else
{
int attributeSeparator = path.LastIndexOf("/@", StringComparison.Ordinal);
if (attributeSeparator >= 0)
{
attributeName = path.Substring(attributeSeparator + 2);
path = path.Substring(0, attributeSeparator);
}
}
string[] segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
XDocument document = XDocument.Parse(payload, LoadOptions.PreserveWhitespace);
if (document.Root == null || segments.Length == 0 ||
!string.Equals(document.Root.Name.LocalName, segments[0], StringComparison.Ordinal))
{
throw new InvalidOperationException("XML filename destination was not found: " + destinationPath);
}
XElement element = document.Root;
for (int index = 1; index < segments.Length; index++)
{
XElement matchingChild = null;
foreach (XElement child in element.Elements())
{
if (!string.Equals(child.Name.LocalName, segments[index], StringComparison.Ordinal))
continue;
if (matchingChild != null)
{
throw new InvalidOperationException(
"XML filename destination is not unique. Select a non-repeating destination: " + destinationPath);
}
matchingChild = child;
}
if (matchingChild == null)
throw new InvalidOperationException("XML filename destination was not found: " + destinationPath);
element = matchingChild;
}
if (!string.IsNullOrWhiteSpace(attributeName))
{
XAttribute matchingAttribute = null;
foreach (XAttribute attribute in element.Attributes())
{
if (attribute.IsNamespaceDeclaration ||
!string.Equals(attribute.Name.LocalName, attributeName, StringComparison.Ordinal))
{
continue;
}
matchingAttribute = attribute;
break;
}
if (matchingAttribute == null)
throw new InvalidOperationException("XML filename attribute was not found: " + destinationPath);
return matchingAttribute.Value ?? string.Empty;
}
return element.Value ?? string.Empty;
}
/// <summary>
/// Applies an optional named transformation to an XML value.
/// </summary>
private string ApplyXmlFileNameTransformation(string value, string transformation)
{
string sourceValue = value ?? string.Empty;
if (string.IsNullOrWhiteSpace(transformation))
return sourceValue;
const string timeFormatPrefix = "timeformat:";
if (transformation.StartsWith(timeFormatPrefix, StringComparison.OrdinalIgnoreCase))
{
string dateTimeFormat = transformation.Substring(timeFormatPrefix.Length).Trim();
if (string.IsNullOrWhiteSpace(dateTimeFormat))
throw new InvalidOperationException("XML filename time format is empty.");
return FormatXmlDateTime(sourceValue, dateTimeFormat);
}
const string maskPrefix = "mask:";
if (transformation.StartsWith(maskPrefix, StringComparison.OrdinalIgnoreCase))
{
string mapping = transformation.Substring(maskPrefix.Length).Trim();
if (string.IsNullOrWhiteSpace(mapping))
throw new InvalidOperationException("XML filename mask is empty.");
return ApplyXmlFileNameValueMapping(sourceValue, mapping);
}
// Backward compatibility with configurations created before named
// transformations were introduced.
const string legacyDateTimePrefix = "DateTime:";
if (transformation.StartsWith(legacyDateTimePrefix, StringComparison.OrdinalIgnoreCase))
{
string dateTimeFormat = transformation.Substring(legacyDateTimePrefix.Length).Trim();
if (string.IsNullOrWhiteSpace(dateTimeFormat))
throw new InvalidOperationException("XML filename DateTime format is empty.");
return FormatXmlDateTime(sourceValue, dateTimeFormat);
}
if (transformation.IndexOf('=') >= 0)
return ApplyXmlFileNameValueMapping(sourceValue, transformation);
throw new InvalidOperationException(
"Unsupported XML filename transformation: " + transformation);
}
private string ApplyXmlFileNameValueMapping(string sourceValue, string mapping)
{
string[] entries = mapping.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string entry in entries)
{
int separator = entry.IndexOf('=');
if (separator <= 0)
throw new InvalidOperationException("Invalid XML filename value mapping: " + mapping);
string expectedValue = entry.Substring(0, separator).Trim();
string replacementValue = entry.Substring(separator + 1).Trim();
if (string.Equals(sourceValue.Trim(), expectedValue, StringComparison.OrdinalIgnoreCase))
return replacementValue;
}
return sourceValue;
}
private string FormatXmlDateTime(string value, string format)
{
DateTime parsedValue;
bool parsed = DateTime.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AllowWhiteSpaces,
out parsedValue);
if (!parsed)
{
parsed = DateTime.TryParse(
value,
CultureInfo.CurrentCulture,
DateTimeStyles.AllowWhiteSpaces,
out parsedValue);
}
if (!parsed)
throw new InvalidOperationException("XML filename value is not a valid DateTime: " + value);
try
{
return parsedValue.ToString(format, CultureInfo.InvariantCulture);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Invalid XML filename DateTime format: " + format, ex);
}
}
/// <summary>
/// Replaces the timestamp token and supports an optional .NET format string.
/// </summary>
@@ -0,0 +1,39 @@
namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI
{
partial class PayloadFileNameXmlTokenDlg
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Text = "PayloadFileNameXmlTokenDlg";
}
#endregion
}
}
@@ -0,0 +1,512 @@
///
/// Copyright (c) 2026 Sensus Slovensko a.s.
///
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters;
namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI
{
/// <summary>
/// Selects one non-repeating XML payload destination for a filename token
/// and optionally configures a filename-safe value transformation.
/// </summary>
internal sealed partial class PayloadFileNameXmlTokenDlg : Form
{
private const string NoTransformation = "None";
private const string TimeFormatTransformation = "Time format";
private const string MaskTransformation = "Mask";
private readonly string templatePath;
private readonly string currentDestinationPath;
private readonly string currentTransformation;
private readonly TreeView xmlTreeView;
private readonly TextBox selectedPathTextBox;
private readonly ComboBox transformationComboBox;
private readonly ComboBox timeFormatComboBox;
private readonly Label timeFormatHintLabel;
private readonly Label maskHintLabel;
private readonly DataGridView maskGrid;
private readonly Button okButton;
public PayloadFileNameXmlTokenDlg(
string templatePath,
string currentDestinationPath,
string currentTransformation)
{
this.templatePath = templatePath;
this.currentDestinationPath = currentDestinationPath;
this.currentTransformation = currentTransformation;
Text = "Select XML value for payload filename";
StartPosition = FormStartPosition.CenterParent;
MinimumSize = new Size(760, 720);
Size = new Size(900, 760);
Label templateLabel = new Label
{
AutoSize = true,
Location = new Point(12, 16),
Text = "XML reference:"
};
TextBox templateTextBox = new TextBox
{
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
Location = new Point(120, 12),
ReadOnly = true,
Size = new Size(750, 20),
Text = templatePath ?? string.Empty
};
xmlTreeView = new TreeView
{
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
HideSelection = false,
Location = new Point(12, 44),
ShowNodeToolTips = true,
Size = new Size(858, 400)
};
Label selectedPathLabel = new Label
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
AutoSize = true,
Location = new Point(12, 458),
Text = "Selected XML path:"
};
selectedPathTextBox = new TextBox
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
Location = new Point(120, 454),
ReadOnly = true,
Size = new Size(750, 20)
};
Label transformationLabel = new Label
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
AutoSize = true,
Location = new Point(12, 492),
Text = "Transformation:"
};
transformationComboBox = new ComboBox
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
DropDownStyle = ComboBoxStyle.DropDownList,
Location = new Point(120, 488),
Size = new Size(220, 21)
};
transformationComboBox.Items.AddRange(new object[]
{
NoTransformation,
TimeFormatTransformation,
MaskTransformation
});
timeFormatComboBox = new ComboBox
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
DropDownStyle = ComboBoxStyle.DropDownList,
Location = new Point(120, 523),
Size = new Size(300, 21)
};
timeFormatComboBox.Items.AddRange(new object[]
{
"yyyyMMdd_HHmmss",
"yyyy-MM-dd_HH-mm-ss",
"yyyyMMdd-HHmmss",
"ddMMyyyy_HHmmss",
"yyyyMMdd_HHmm",
"yyyyMMdd"
});
timeFormatHintLabel = new Label
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
AutoSize = true,
ForeColor = SystemColors.GrayText,
Location = new Point(120, 550),
Text = "Only filename-safe formats are offered. Characters /, \\ and : are not allowed."
};
maskHintLabel = new Label
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
AutoSize = true,
ForeColor = SystemColors.GrayText,
Location = new Point(12, 523),
Text = "Value mask:"
};
maskGrid = new DataGridView
{
AllowUserToAddRows = true,
AllowUserToDeleteRows = true,
Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize,
Location = new Point(120, 523),
RowHeadersVisible = false,
Size = new Size(750, 130)
};
maskGrid.Columns.Add("SourceValue", "XML value");
maskGrid.Columns.Add("TargetValue", "Filename value");
okButton = new Button
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Right,
Enabled = false,
Location = new Point(714, 674),
Size = new Size(75, 25),
Text = "OK"
};
Button cancelButton = new Button
{
Anchor = AnchorStyles.Bottom | AnchorStyles.Right,
DialogResult = DialogResult.Cancel,
Location = new Point(795, 674),
Size = new Size(75, 25),
Text = "Cancel"
};
Controls.Add(templateLabel);
Controls.Add(templateTextBox);
Controls.Add(xmlTreeView);
Controls.Add(selectedPathLabel);
Controls.Add(selectedPathTextBox);
Controls.Add(transformationLabel);
Controls.Add(transformationComboBox);
Controls.Add(timeFormatComboBox);
Controls.Add(timeFormatHintLabel);
Controls.Add(maskHintLabel);
Controls.Add(maskGrid);
Controls.Add(okButton);
Controls.Add(cancelButton);
AcceptButton = okButton;
CancelButton = cancelButton;
Load += PayloadFileNameXmlTokenDlg_Load;
xmlTreeView.AfterSelect += xmlTreeView_AfterSelect;
xmlTreeView.NodeMouseDoubleClick += xmlTreeView_NodeMouseDoubleClick;
transformationComboBox.SelectedIndexChanged += transformationComboBox_SelectedIndexChanged;
okButton.Click += okButton_Click;
}
public string SelectedDestinationPath { get; private set; }
public string Transformation
{
get
{
string selectedTransformation = transformationComboBox.SelectedItem as string;
if (string.Equals(selectedTransformation, TimeFormatTransformation, StringComparison.Ordinal))
{
string format = timeFormatComboBox.SelectedItem as string;
return string.IsNullOrWhiteSpace(format)
? string.Empty
: "timeformat:" + format;
}
if (string.Equals(selectedTransformation, MaskTransformation, StringComparison.Ordinal))
{
List<string> entries = new List<string>();
foreach (DataGridViewRow row in maskGrid.Rows)
{
if (row.IsNewRow)
continue;
string sourceValue = Convert.ToString(row.Cells[0].Value).Trim();
string targetValue = Convert.ToString(row.Cells[1].Value).Trim();
if (sourceValue.Length == 0 && targetValue.Length == 0)
continue;
entries.Add(sourceValue + "=" + targetValue);
}
return entries.Count == 0
? string.Empty
: "mask:" + string.Join(";", entries.ToArray());
}
return string.Empty;
}
}
private void PayloadFileNameXmlTokenDlg_Load(object sender, EventArgs e)
{
try
{
InitializeTransformation(currentTransformation);
PayloadTemplateInspectionResult inspection = new PayloadTemplateInspector().Inspect(templatePath);
if (inspection.Root == null)
return;
TreeNode root = CreateTreeNode(inspection.Root);
xmlTreeView.Nodes.Add(root);
root.Expand();
TreeNode currentNode = FindTreeNode(xmlTreeView.Nodes, currentDestinationPath);
if (currentNode != null)
{
ExpandParents(currentNode);
xmlTreeView.SelectedNode = currentNode;
currentNode.EnsureVisible();
}
}
catch (Exception ex)
{
MessageBox.Show(
this,
"Failed to analyze the XML reference." + Environment.NewLine + Environment.NewLine + ex.Message,
"XML filename value",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
private void InitializeTransformation(string transformation)
{
string definition = (transformation ?? string.Empty).Trim();
if (definition.StartsWith("timeformat:", StringComparison.OrdinalIgnoreCase))
{
SelectTimeFormat(definition.Substring("timeformat:".Length).Trim());
return;
}
if (definition.StartsWith("DateTime:", StringComparison.OrdinalIgnoreCase))
{
SelectTimeFormat(definition.Substring("DateTime:".Length).Trim());
return;
}
if (definition.StartsWith("mask:", StringComparison.OrdinalIgnoreCase))
{
SelectMask(definition.Substring("mask:".Length).Trim());
return;
}
if (definition.IndexOf('=') >= 0)
{
SelectMask(definition);
return;
}
if (definition.Length > 0)
{
SelectTimeFormat(definition);
return;
}
transformationComboBox.SelectedItem = NoTransformation;
}
private void SelectTimeFormat(string format)
{
transformationComboBox.SelectedItem = TimeFormatTransformation;
int formatIndex = timeFormatComboBox.FindStringExact(format);
timeFormatComboBox.SelectedIndex = formatIndex >= 0 ? formatIndex : 0;
}
private void SelectMask(string mapping)
{
transformationComboBox.SelectedItem = MaskTransformation;
string[] entries = mapping.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string entry in entries)
{
int separator = entry.IndexOf('=');
if (separator <= 0)
continue;
maskGrid.Rows.Add(
entry.Substring(0, separator).Trim(),
entry.Substring(separator + 1).Trim());
}
}
private void transformationComboBox_SelectedIndexChanged(object sender, EventArgs e)
{
string selectedTransformation = transformationComboBox.SelectedItem as string;
bool showTimeFormat = string.Equals(
selectedTransformation,
TimeFormatTransformation,
StringComparison.Ordinal);
bool showMask = string.Equals(
selectedTransformation,
MaskTransformation,
StringComparison.Ordinal);
timeFormatComboBox.Visible = showTimeFormat;
timeFormatHintLabel.Visible = showTimeFormat;
maskHintLabel.Visible = showMask;
maskGrid.Visible = showMask;
if (showTimeFormat && timeFormatComboBox.SelectedIndex < 0)
timeFormatComboBox.SelectedIndex = 0;
}
private TreeNode CreateTreeNode(PayloadTemplateNode payloadNode)
{
TreeNode treeNode = new TreeNode(payloadNode.DisplayText ?? payloadNode.Name ?? string.Empty)
{
Tag = payloadNode
};
if (payloadNode.IsSelectable && payloadNode.DestinationKind == PayloadDestinationKind.RepeatingPrototype)
{
treeNode.ForeColor = SystemColors.GrayText;
treeNode.ToolTipText = "Repeating XML values cannot be used in a single filename.";
}
foreach (PayloadTemplateNode child in payloadNode.Children)
treeNode.Nodes.Add(CreateTreeNode(child));
return treeNode;
}
private void xmlTreeView_AfterSelect(object sender, TreeViewEventArgs e)
{
PayloadTemplateNode payloadNode = e.Node != null ? e.Node.Tag as PayloadTemplateNode : null;
bool selectable = payloadNode != null &&
payloadNode.IsSelectable &&
payloadNode.DestinationKind == PayloadDestinationKind.SingleValue;
selectedPathTextBox.Text = selectable ? payloadNode.DestinationPath : string.Empty;
okButton.Enabled = selectable;
}
private void xmlTreeView_NodeMouseDoubleClick(object sender, TreeNodeMouseClickEventArgs e)
{
if (e.Node != null)
xmlTreeView.SelectedNode = e.Node;
}
private void okButton_Click(object sender, EventArgs e)
{
AcceptSelection();
}
private void AcceptSelection()
{
maskGrid.EndEdit();
PayloadTemplateNode payloadNode = xmlTreeView.SelectedNode != null
? xmlTreeView.SelectedNode.Tag as PayloadTemplateNode
: null;
if (payloadNode == null ||
!payloadNode.IsSelectable ||
payloadNode.DestinationKind != PayloadDestinationKind.SingleValue)
{
return;
}
if (!ValidateSelectedTransformation())
return;
SelectedDestinationPath = payloadNode.DestinationPath;
DialogResult = DialogResult.OK;
Close();
}
private bool ValidateSelectedTransformation()
{
string selectedTransformation = transformationComboBox.SelectedItem as string;
if (!string.Equals(selectedTransformation, MaskTransformation, StringComparison.Ordinal))
return true;
int validRowCount = 0;
foreach (DataGridViewRow row in maskGrid.Rows)
{
if (row.IsNewRow)
continue;
string sourceValue = Convert.ToString(row.Cells[0].Value).Trim();
string targetValue = Convert.ToString(row.Cells[1].Value).Trim();
if (sourceValue.Length == 0 && targetValue.Length == 0)
continue;
if (sourceValue.Length == 0)
{
ShowMaskError("Enter the XML value to be replaced.");
return false;
}
if (sourceValue.IndexOfAny(new[] { '=', ';' }) >= 0 || targetValue.IndexOf(';') >= 0)
{
ShowMaskError("Mask values cannot contain semicolons. The XML value also cannot contain '='.");
return false;
}
validRowCount++;
}
if (validRowCount == 0)
{
ShowMaskError("Add at least one XML value and filename value pair.");
return false;
}
return true;
}
private void ShowMaskError(string message)
{
MessageBox.Show(
this,
message,
"XML filename mask",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
private TreeNode FindTreeNode(TreeNodeCollection nodes, string destinationPath)
{
if (string.IsNullOrWhiteSpace(destinationPath))
return null;
foreach (TreeNode node in nodes)
{
PayloadTemplateNode payloadNode = node.Tag as PayloadTemplateNode;
if (payloadNode != null &&
string.Equals(payloadNode.DestinationPath, destinationPath, StringComparison.Ordinal))
{
return node;
}
TreeNode child = FindTreeNode(node.Nodes, destinationPath);
if (child != null)
return child;
}
return null;
}
private void ExpandParents(TreeNode node)
{
TreeNode parent = node.Parent;
while (parent != null)
{
parent.Expand();
parent = parent.Parent;
}
}
}
}
@@ -9,6 +9,7 @@ using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;
using TBF.Rig.Generic;
using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Formatters;
using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Interfaces;
using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Writers;
using static TBF.Rig.Output.DataStorage.UniDataStorageWriter.Types;
@@ -121,6 +122,7 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI
browsePayloadTemplateButton.Click += browsePayloadTemplateButton_Click;
browsePayloadArchiveButton.Click += browsePayloadArchiveButton_Click;
archivePayloadCheckBox.CheckedChanged += archivePayloadCheckBox_CheckedChanged;
payloadFileNameTemplateTextBox.MouseClick += payloadFileNameTemplateTextBox_MouseClick;
//
// Diagnostic XML payloads can be multiline. Keep the compact
@@ -1858,6 +1860,202 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI
}
}
/// <summary>
/// Opens the XML destination chooser when the user clicks inside any
/// filename token enclosed in braces.
/// </summary>
private void payloadFileNameTemplateTextBox_MouseClick(object sender, MouseEventArgs e)
{
string template = payloadFileNameTemplateTextBox.Text ?? string.Empty;
int tokenStart;
int tokenEnd;
string tokenContent;
if (!TryGetFileNameTokenAtCaret(
template,
payloadFileNameTemplateTextBox.SelectionStart,
out tokenStart,
out tokenEnd,
out tokenContent))
{
return;
}
bool isResultToken = string.Equals(tokenContent, "Result", StringComparison.OrdinalIgnoreCase);
bool isXmlToken = tokenContent.StartsWith("Xml:", StringComparison.OrdinalIgnoreCase);
string referencePath = (payloadTemplatePathTextBox.Text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(referencePath))
{
MessageBox.Show(
this,
"Select the XML payload reference before configuring an XML filename token.",
"XML filename value",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return;
}
string currentPath = string.Empty;
string currentTransformation = isResultToken
? "mask:Passed=P;Failed=F"
: string.Empty;
if (isXmlToken)
ParseXmlFileNameToken(tokenContent, out currentPath, out currentTransformation);
using (PayloadFileNameXmlTokenDlg dialog =
new PayloadFileNameXmlTokenDlg(referencePath, currentPath, currentTransformation))
{
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
string transformationError;
if (!TryValidateFileNameTransformation(dialog.Transformation, out transformationError))
{
MessageBox.Show(
this,
transformationError,
"XML filename value",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
string replacementToken = "{Xml:" + dialog.SelectedDestinationPath;
if (!string.IsNullOrWhiteSpace(dialog.Transformation))
replacementToken += "|" + dialog.Transformation;
replacementToken += "}";
payloadFileNameTemplateTextBox.Text =
template.Remove(tokenStart, tokenEnd - tokenStart + 1).Insert(tokenStart, replacementToken);
payloadFileNameTemplateTextBox.SelectionStart = tokenStart + replacementToken.Length;
}
}
private bool TryGetFileNameTokenAtCaret(
string text,
int caretPosition,
out int tokenStart,
out int tokenEnd,
out string tokenContent)
{
tokenStart = -1;
tokenEnd = -1;
tokenContent = string.Empty;
if (string.IsNullOrEmpty(text))
return false;
int searchPosition = Math.Min(Math.Max(caretPosition, 0), text.Length - 1);
tokenStart = text.LastIndexOf('{', searchPosition);
tokenEnd = text.IndexOf('}', searchPosition);
if (tokenStart < 0 || tokenEnd <= tokenStart || caretPosition <= tokenStart || caretPosition > tokenEnd)
return false;
int nestedClosingBrace = text.LastIndexOf('}', Math.Min(caretPosition, text.Length - 1));
int nestedOpeningBrace = text.IndexOf('{', tokenStart + 1, tokenEnd - tokenStart - 1);
if (nestedClosingBrace > tokenStart || nestedOpeningBrace >= 0)
return false;
tokenContent = text.Substring(tokenStart + 1, tokenEnd - tokenStart - 1).Trim();
return true;
}
private void ParseXmlFileNameToken(string tokenContent, out string destinationPath, out string transformation)
{
string definition = tokenContent.Substring("Xml:".Length);
int transformationSeparator = definition.IndexOf('|');
if (transformationSeparator >= 0)
{
destinationPath = definition.Substring(0, transformationSeparator).Trim();
transformation = definition.Substring(transformationSeparator + 1).Trim();
return;
}
// Load the earlier path:DateTimeFormat syntax and let the dialog
// save it back as |timeformat:DateTimeFormat.
int finalPathSeparator = definition.LastIndexOf('/');
int formatSeparator = definition.IndexOf(':', finalPathSeparator + 1);
if (formatSeparator >= 0)
{
destinationPath = definition.Substring(0, formatSeparator).Trim();
transformation = "timeformat:" + definition.Substring(formatSeparator + 1).Trim();
return;
}
destinationPath = definition.Trim();
transformation = string.Empty;
}
private bool TryValidateFileNameTransformation(string transformation, out string error)
{
error = string.Empty;
if (string.IsNullOrWhiteSpace(transformation))
return true;
const string timeFormatPrefix = "timeformat:";
if (transformation.StartsWith(timeFormatPrefix, StringComparison.OrdinalIgnoreCase))
{
string format = transformation.Substring(timeFormatPrefix.Length).Trim();
if (string.IsNullOrWhiteSpace(format))
{
error = "Time format is empty. For example: timeformat:yyyyMMdd_HHmmss";
return false;
}
if (format.IndexOfAny(new[] { '/', '\\', ':' }) >= 0)
{
error = "Time format contains a character that is not allowed in a filename: /, \\ or :.";
return false;
}
try
{
DateTime.Now.ToString(format, CultureInfo.InvariantCulture);
return true;
}
catch (FormatException)
{
error = "Invalid DateTime format. For example: yyyyMMdd_HHmmss";
return false;
}
}
const string maskPrefix = "mask:";
if (!transformation.StartsWith(maskPrefix, StringComparison.OrdinalIgnoreCase))
{
error = "Unsupported transformation. Select None, Time format or Mask.";
return false;
}
string mapping = transformation.Substring(maskPrefix.Length).Trim();
if (string.IsNullOrWhiteSpace(mapping))
{
error = "Mask is empty. Add at least one XML value and filename value pair.";
return false;
}
string[] entries = mapping.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string entry in entries)
{
int separator = entry.IndexOf('=');
if (separator <= 0)
{
error = "Invalid mask. Use Source=Target pairs, for example mask:Passed=P;Failed=F.";
return false;
}
}
return true;
}
/// <summary>
/// Opens a folder browser used to select the payload archive directory.
/// </summary>
+6
View File
@@ -1247,6 +1247,12 @@
<Compile Include="Rig\Output\DataStorage\UniDataStorageWriter\Interfaces\DataWriteRequest.cs" />
<Compile Include="Rig\Output\DataStorage\UniDataStorageWriter\Interfaces\IDataStorageWriter.cs" />
<Compile Include="Rig\Output\DataStorage\UniDataStorageWriter\Types.cs" />
<Compile Include="Rig\Output\DataStorage\UniDataStorageWriter\UI\PayloadFileNameXmlTokenDlg.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Rig\Output\DataStorage\UniDataStorageWriter\UI\PayloadFileNameXmlTokenDlg.Designer.cs">
<DependentUpon>PayloadFileNameXmlTokenDlg.cs</DependentUpon>
</Compile>
<Compile Include="Rig\Output\DataStorage\UniDataStorageWriter\UI\XmlPayloadViewerDlg.cs">
<SubType>Form</SubType>
</Compile>