From a210a0d1f283236660ec90a47e91daec643768b4 Mon Sep 17 00:00:00 2001 From: Marek Frniak Date: Tue, 21 Jul 2026 11:13:43 +0200 Subject: [PATCH] Upgrade (UniDataStorageWriter): improve writer configuration UX and CSV handling - added technology-specific default configuration for new writers - auto-fill default SQL/XLSX write templates - disabled write templates for CSV technology - added technology-specific data source hints - changed test parameter format to Column=Value and Where=Value;Set=Value - improved parameter validation to prevent invalid input crashes - added automatic CSV header creation and header validation - aligned CSV writer behaviour with XLSX writer - Increase revision number --- DeviceTest/app.config | 16 + GenericTest/app.config | 16 + LabelPrinting/App.config | 16 + ResetBatchNr/App.config | 16 + ResultsBrowser/app.config | 16 + S640TestApp/App.config | 20 + TBF/Properties/AssemblyInfo.cs | 4 +- .../Interfaces/IDataStorageWriter.cs | 7 +- .../DataStorage/UniDataStorageWriter/Types.cs | 1 + .../UniDataStorageWriter/UI/WriterCfgCtrl.cs | 423 ++++++- .../UI/WriterCfgCtrl.designer.cs | 1 - .../UniDataStorageWriter/Writer.cs | 6 + .../Writers/CsvWriter .cs | 229 +++- .../Writers/XlsxWriter.cs | 1032 +++++++++++++++++ TBF/TBF.csproj | 39 +- TBF/app.config | 16 + TBF/packages.config | 13 +- TBFTests/app.config | 12 + ToFirstMonitor/App.config | 16 + ToSecondMonitor/App.config | 16 + 20 files changed, 1831 insertions(+), 84 deletions(-) create mode 100644 TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/XlsxWriter.cs diff --git a/DeviceTest/app.config b/DeviceTest/app.config index 4c9e351d5..b3dc11eca 100644 --- a/DeviceTest/app.config +++ b/DeviceTest/app.config @@ -7,6 +7,22 @@ + + + + + + + + + + + + + + + + diff --git a/GenericTest/app.config b/GenericTest/app.config index 5e7f0894f..48447540a 100644 --- a/GenericTest/app.config +++ b/GenericTest/app.config @@ -7,6 +7,22 @@ + + + + + + + + + + + + + + + + diff --git a/LabelPrinting/App.config b/LabelPrinting/App.config index 1fa696586..be64bd998 100644 --- a/LabelPrinting/App.config +++ b/LabelPrinting/App.config @@ -9,6 +9,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ResetBatchNr/App.config b/ResetBatchNr/App.config index 1fa696586..be64bd998 100644 --- a/ResetBatchNr/App.config +++ b/ResetBatchNr/App.config @@ -9,6 +9,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ResultsBrowser/app.config b/ResultsBrowser/app.config index 6a3539d7e..6b44297a6 100644 --- a/ResultsBrowser/app.config +++ b/ResultsBrowser/app.config @@ -21,6 +21,22 @@ + + + + + + + + + + + + + + + + diff --git a/S640TestApp/App.config b/S640TestApp/App.config index 1fa696586..dd47cf82c 100644 --- a/S640TestApp/App.config +++ b/S640TestApp/App.config @@ -9,6 +9,26 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TBF/Properties/AssemblyInfo.cs b/TBF/Properties/AssemblyInfo.cs index 5c45b63fb..7d803eb22 100644 --- a/TBF/Properties/AssemblyInfo.cs +++ b/TBF/Properties/AssemblyInfo.cs @@ -32,5 +32,5 @@ using System.Runtime.InteropServices; // Build Number // Revision // -[assembly: AssemblyVersion("3.9.3134.106")] -[assembly: AssemblyFileVersion("3.9.3134.106")] +[assembly: AssemblyVersion("3.9.3134.107")] +[assembly: AssemblyFileVersion("3.9.3134.107")] diff --git a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Interfaces/IDataStorageWriter.cs b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Interfaces/IDataStorageWriter.cs index 40e733e9f..5746073f5 100644 --- a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Interfaces/IDataStorageWriter.cs +++ b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Interfaces/IDataStorageWriter.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Diagnostic; +using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Diagnostic; using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Interfaces; namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter diff --git a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Types.cs b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Types.cs index cebb0ae79..d1a5137d5 100644 --- a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Types.cs +++ b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Types.cs @@ -31,6 +31,7 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter public const string Csv = ".csv"; public const string Xls = ".xls"; + public const string Xlsx = ".xlsx"; public const string Json = ".json"; } } diff --git a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.cs b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.cs index f7d16cd30..07f7c8131 100644 --- a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.cs +++ b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.cs @@ -1,12 +1,13 @@ - using Common; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using TBF.Rig.Generic; - using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Interfaces; - using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Writers; - using static TBF.Rig.Output.DataStorage.UniDataStorageWriter.Types; +using Common; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using TBF.Rig.Generic; +using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Interfaces; +using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Writers; +using static TBF.Rig.Output.DataStorage.UniDataStorageWriter.Types; +using System.Runtime.InteropServices; namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI { @@ -25,6 +26,32 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI private WriterCfg config; + private bool isUnlocked; + + private const string CsvTemplateHint = + "Write template is not used."; + + private const string DefaultDatabaseInsertTemplate = + "Insert|INSERT INTO dbo.table ({0}) VALUES ({1})"; + + private const string DefaultDatabaseUpdateTemplate = + "Update|UPDATE dbo.table SET {2} = {3} WHERE {0} = {1}"; + + private const string DefaultXlsxInsertTemplate = + "Insert|INSERT INTO Sheet1 ({0}) VALUES ({1})"; + + private const string DefaultXlsxUpdateTemplate = + "Update|UPDATE Sheet1 SET {2} = {3} WHERE {0} = {1}"; + + private const int EmSetCueBanner = 0x1501; + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SendMessage( + IntPtr hWnd, + int msg, + IntPtr wParam, + string lParam); + public bool ShowMore { get { return false; } @@ -55,6 +82,8 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI buttonUpdateTemplate.Click += buttonUpdateTemplate_Click; buttonRemoveTemplate.Click += buttonRemoveTemplate_Click; writeTemplatesListBox.SelectedIndexChanged += writeTemplatesListBox_SelectedIndexChanged; + + technologyTypeComboBox.SelectedIndexChanged += technologyTypeComboBox_SelectedIndexChanged; } private void WriterCfgCtrl_Load(object sender, EventArgs e) @@ -70,6 +99,8 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI if (config != null) Redraw(); + + ApplyTechnologyConfiguration(true); } public void Closing() @@ -118,29 +149,136 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI RefreshTemplatesListBox(); UpdateInputHint(); RefreshWriteParamsListBox(); + + ApplyTechnologyConfiguration(true); } public void Unlock() { + isUnlocked = true; + nameTextBox.Enabled = true; dataStorageTypeComboBox.Enabled = true; dataSourceTextBox.Enabled = true; + sourceTestResultTextBox.Enabled = true; sourceTestResultTextBox.ReadOnly = true; + writeTestResultTextBox.Enabled = true; writeTestResultTextBox.ReadOnly = true; + writeParamValueTextBox.Enabled = true; listBoxWriteParams.Enabled = true; buttonAddParam.Enabled = true; buttonRemoveParam.Enabled = true; writeModeComboBox.Enabled = true; technologyTypeComboBox.Enabled = true; - writeTemplatesListBox.Enabled = true; - templateEditTextBox.Enabled = true; - buttonAddTemplate.Enabled = true; - buttonUpdateTemplate.Enabled = true; - buttonRemoveTemplate.Enabled = true; - examples1Button.Enabled = true; + + ApplyTechnologyConfiguration(false); + } + + /// + /// Updates source hints, default templates and template control availability + /// according to the selected technology. + /// + /// + /// to create default templates when the template list + /// is empty; otherwise, . + /// + private void ApplyTechnologyConfiguration(bool initializeDefaults) + { + string technologyType = + (technologyTypeComboBox.Text ?? string.Empty).Trim(); + + SetDataSourceHint(GetDataSourceHint(technologyType)); + + bool templatesSupported = + technologyType == TechnologyTypes.MicrosoftSql || + technologyType == TechnologyTypes.Xlsx || + technologyType == TechnologyTypes.Xls; + + if (technologyType == TechnologyTypes.Csv) + { + writeTemplatesListBox.Items.Clear(); + + templateEditTextBox.Text = CsvTemplateHint; + + SetTemplateControlsEnabled(false); + return; + } + + if (initializeDefaults && + templatesSupported && + writeTemplatesListBox.Items.Count == 0) + { + AddDefaultTemplates(technologyType); + } + + if (!templatesSupported) + { + writeTemplatesListBox.Items.Clear(); + templateEditTextBox.Clear(); + } + else if (templateEditTextBox.Text == CsvTemplateHint) + { + templateEditTextBox.Clear(); + } + + SetTemplateControlsEnabled( + templatesSupported && isUnlocked); + } + + /// + /// Adds default write templates for the selected technology. + /// + /// + /// Selected writer technology type. + /// + private void AddDefaultTemplates(string technologyType) + { + writeTemplatesListBox.Items.Clear(); + + switch (technologyType) + { + case TechnologyTypes.MicrosoftSql: + writeTemplatesListBox.Items.Add( + DefaultDatabaseInsertTemplate); + + writeTemplatesListBox.Items.Add( + DefaultDatabaseUpdateTemplate); + break; + + case TechnologyTypes.Xlsx: + case TechnologyTypes.Xls: + writeTemplatesListBox.Items.Add( + DefaultXlsxInsertTemplate); + + writeTemplatesListBox.Items.Add( + DefaultXlsxUpdateTemplate); + break; + } + + if (writeTemplatesListBox.Items.Count > 0) + writeTemplatesListBox.SelectedIndex = 0; + } + + /// + /// Enables or disables controls used for write template configuration. + /// + /// + /// to enable template editing; otherwise, + /// . + /// + private void SetTemplateControlsEnabled(bool enabled) + { + writeTemplatesListBox.Enabled = enabled; + templateEditTextBox.Enabled = enabled; + + buttonAddTemplate.Enabled = enabled; + buttonUpdateTemplate.Enabled = enabled; + buttonRemoveTemplate.Enabled = enabled; + + examples1Button.Enabled = enabled; } public CfgUpdateFlags VerifyCfg(ref string message) @@ -214,14 +352,9 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI tmpCfg.TechnologyType = (technologyTypeComboBox.Text ?? string.Empty).Trim(); tmpCfg.DataSource = dataSourceTextBox.Text; - if (config != null && config.WriteTemplates != null) - { - tmpCfg.WriteTemplates = new List(config.WriteTemplates); - } - else - { - tmpCfg.WriteTemplates = new List(); - } + tmpCfg.WriteTemplates = writeTemplatesListBox.Items + .Cast() + .ToList(); string selectedTemplate = tmpCfg.GetTemplate(mode); tmpCfg.QueryTemplate = selectedTemplate; @@ -267,6 +400,9 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI case TechnologyTypes.Csv: return new CsvWriter(cfg); + case TechnologyTypes.Xlsx: + return new XlsxWriter(cfg); + case TechnologyTypes.Json: throw new NotSupportedException("JSON writer is not supported yet."); @@ -362,18 +498,39 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI return request; } + /// + /// Parses an insert parameter in the format Column=Value. + /// + /// + /// Text containing the column name and value. + /// + /// + /// Parsed insert write item. + /// + /// + /// Thrown when the input format is invalid. + /// private InsertWriteItem ParseInsertItem(string text) { if (string.IsNullOrWhiteSpace(text)) throw new InvalidOperationException("Insert item is empty."); string[] parts = text - .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Split(new[] { '=' }, 2, StringSplitOptions.None) .Select(p => p.Trim()) .ToArray(); if (parts.Length != 2) - throw new InvalidOperationException("Insert format must be: Column;Value"); + throw new InvalidOperationException( + "Insert format must be: Column=Value"); + + if (string.IsNullOrWhiteSpace(parts[0])) + throw new InvalidOperationException( + "Insert column name cannot be empty."); + + if (string.IsNullOrWhiteSpace(parts[1])) + throw new InvalidOperationException( + "Insert value cannot be empty."); return new InsertWriteItem { @@ -382,25 +539,75 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI }; } + /// + /// Parses an update parameter in the format + /// WhereName=WhereValue;SetName=SetValue. + /// + /// + /// Text containing the update condition and target value. + /// + /// + /// Parsed update write item. + /// + /// + /// Thrown when the input format is invalid. + /// private UpdateWriteItem ParseUpdateItem(string text) { if (string.IsNullOrWhiteSpace(text)) throw new InvalidOperationException("Update item is empty."); - string[] parts = text - .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + string[] pairs = text + .Split(new[] { ';' }, StringSplitOptions.None) .Select(p => p.Trim()) .ToArray(); - if (parts.Length != 4) - throw new InvalidOperationException("Update format must be: WhereName;WhereValue;SetName;SetValue"); + if (pairs.Length != 2) + { + throw new InvalidOperationException( + "Update format must be: " + + "WhereName=WhereValue;SetName=SetValue"); + } + + string[] whereParts = pairs[0] + .Split(new[] { '=' }, 2, StringSplitOptions.None) + .Select(p => p.Trim()) + .ToArray(); + + string[] setParts = pairs[1] + .Split(new[] { '=' }, 2, StringSplitOptions.None) + .Select(p => p.Trim()) + .ToArray(); + + if (whereParts.Length != 2 || setParts.Length != 2) + { + throw new InvalidOperationException( + "Update format must be: " + + "WhereName=WhereValue;SetName=SetValue"); + } + + if (string.IsNullOrWhiteSpace(whereParts[0])) + throw new InvalidOperationException( + "WHERE column name cannot be empty."); + + if (string.IsNullOrWhiteSpace(whereParts[1])) + throw new InvalidOperationException( + "WHERE value cannot be empty."); + + if (string.IsNullOrWhiteSpace(setParts[0])) + throw new InvalidOperationException( + "SET column name cannot be empty."); + + if (string.IsNullOrWhiteSpace(setParts[1])) + throw new InvalidOperationException( + "SET value cannot be empty."); return new UpdateWriteItem { - WhereParameterName = parts[0], - WhereValue = parts[1], - SetParameterName = parts[2], - SetValue = parts[3] + WhereParameterName = whereParts[0], + WhereValue = whereParts[1], + SetParameterName = setParts[0], + SetValue = setParts[1] }; } @@ -413,18 +620,33 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI if (string.IsNullOrWhiteSpace(text)) return; - WriteMode mode; - if (!Enum.TryParse(writeModeComboBox.Text, true, out mode)) - mode = WriteMode.Insert; + try + { + WriteMode mode; - if (mode == WriteMode.Insert) - ParseInsertItem(text); - else if (mode == WriteMode.Update) - ParseUpdateItem(text); + if (!Enum.TryParse(writeModeComboBox.Text, true, out mode)) + mode = WriteMode.Insert; - batchWriteLines.Add(text); - writeParamValueTextBox.Clear(); - RefreshWriteParamsListBox(); + if (mode == WriteMode.Insert) + ParseInsertItem(text); + else if (mode == WriteMode.Update) + ParseUpdateItem(text); + + batchWriteLines.Add(text); + + writeParamValueTextBox.Clear(); + RefreshWriteParamsListBox(); + } + catch (InvalidOperationException ex) + { + writeTestResultTextBox.Text = + "Invalid write parameter:" + + Environment.NewLine + + ex.Message; + + writeParamValueTextBox.Focus(); + writeParamValueTextBox.SelectAll(); + } } private void buttonRemoveParam_Click(object sender, EventArgs e) @@ -452,13 +674,16 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI { if (writeModeComboBox.Text == WriteMode.Insert.ToString()) { - labelParamValue.Text = "Insert: Column;Value"; - writeParamValueTextBox.Text = "Column;Value"; + labelParamValue.Text = "Insert: Column=Value"; + writeParamValueTextBox.Text = "Column=Value"; } else { - labelParamValue.Text = "Update: WhereName;WhereValue;SetName;SetValue"; - writeParamValueTextBox.Text = "WhereName;WhereValue;SetName;SetValue"; + labelParamValue.Text = + "Update: WhereName=WhereValue;SetName=SetValue"; + + writeParamValueTextBox.Text = + "WhereName=WhereValue;SetName=SetValue"; } } @@ -539,37 +764,64 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI private void UpdateTechnologyTypeUi() { - string storageType = (dataStorageTypeComboBox.Text ?? string.Empty).Trim(); + string previousTechnology = + (technologyTypeComboBox.Text ?? string.Empty).Trim(); + + string storageType = + (dataStorageTypeComboBox.Text ?? string.Empty).Trim(); technologyTypeComboBox.Items.Clear(); - technologyTypeComboBox.Text = string.Empty; switch (storageType) { case StorageTypes.LocalDatabase: case StorageTypes.RemoteDatabase: technologyTypeLabel.Text = "Database type:"; - technologyTypeComboBox.Items.Add(TechnologyTypes.MicrosoftSql); - technologyTypeComboBox.Items.Add(TechnologyTypes.MySqlMariaDb); - technologyTypeComboBox.Items.Add(TechnologyTypes.SQLite); + + technologyTypeComboBox.Items.Add( + TechnologyTypes.MicrosoftSql); + + technologyTypeComboBox.Items.Add( + TechnologyTypes.MySqlMariaDb); + + technologyTypeComboBox.Items.Add( + TechnologyTypes.SQLite); break; case StorageTypes.LocalFile: case StorageTypes.RemoteFile: technologyTypeLabel.Text = "File type:"; - technologyTypeComboBox.Items.Add(TechnologyTypes.Csv); - technologyTypeComboBox.Items.Add(TechnologyTypes.Xls); - technologyTypeComboBox.Items.Add(TechnologyTypes.Json); - break; - case StorageTypes.RestApi: - technologyTypeLabel.Text = "Technology type:"; + technologyTypeComboBox.Items.Add( + TechnologyTypes.Csv); + + technologyTypeComboBox.Items.Add( + TechnologyTypes.Xlsx); + + technologyTypeComboBox.Items.Add( + TechnologyTypes.Xls); + + technologyTypeComboBox.Items.Add( + TechnologyTypes.Json); break; default: technologyTypeLabel.Text = "Technology type:"; break; } + + if (!string.IsNullOrWhiteSpace(previousTechnology) && + technologyTypeComboBox.Items.Contains(previousTechnology)) + { + technologyTypeComboBox.SelectedItem = + previousTechnology; + } + else if (technologyTypeComboBox.Items.Count > 0) + { + technologyTypeComboBox.SelectedIndex = 0; + } + + ApplyTechnologyConfiguration(true); } private void writeModeComboBox_SelectedIndexChanged(object sender, EventArgs e) @@ -599,6 +851,11 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI templateEditTextBox.Text = writeTemplatesListBox.SelectedItem.ToString(); } + private void technologyTypeComboBox_SelectedIndexChanged(object sender, EventArgs e) + { + ApplyTechnologyConfiguration(true); + } + private void buttonAddTemplate_Click(object sender, EventArgs e) { string text = (templateEditTextBox.Text ?? string.Empty).Trim(); @@ -652,5 +909,55 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI writeTemplatesListBox.SelectedIndex = newIndex; } } + + /// + /// Returns the data source hint for the selected technology. + /// + /// + /// Selected technology type. + /// + /// + /// Data source input hint. + /// + private string GetDataSourceHint(string technologyType) + { + switch (technologyType) + { + case TechnologyTypes.Csv: + return "Insert the .csv file path"; + + case TechnologyTypes.Xlsx: + return "Insert the .xlsx file path"; + + case TechnologyTypes.Xls: + return "Insert the .xls file path"; + + case TechnologyTypes.MicrosoftSql: + case TechnologyTypes.MySqlMariaDb: + case TechnologyTypes.SQLite: + return "Insert database connection string"; + + default: + return "Insert data storage source"; + } + } + + /// + /// Sets the placeholder text displayed by the data source textbox. + /// + /// + /// Placeholder text. + /// + private void SetDataSourceHint(string hint) + { + if (!dataSourceTextBox.IsHandleCreated) + return; + + SendMessage( + dataSourceTextBox.Handle, + EmSetCueBanner, + new IntPtr(1), + hint ?? string.Empty); + } } } \ No newline at end of file diff --git a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.designer.cs b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.designer.cs index ed9bbded4..4556d06de 100644 --- a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.designer.cs +++ b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/UI/WriterCfgCtrl.designer.cs @@ -202,7 +202,6 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.UI this.writeDataByParamAndTemplateButton.TabIndex = 17; this.writeDataByParamAndTemplateButton.Text = "Write data by param and template"; this.writeDataByParamAndTemplateButton.UseVisualStyleBackColor = true; - this.writeDataByParamAndTemplateButton.Click += new System.EventHandler(this.writeDataByParamAndTemplateButton_Click); // // groupBox3 // diff --git a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writer.cs b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writer.cs index 759bcf15d..e01d0deef 100644 --- a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writer.cs +++ b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writer.cs @@ -105,6 +105,12 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter case TechnologyTypes.Csv: return new CsvWriter(cfg); + case TechnologyTypes.Xlsx: + return new XlsxWriter(cfg); + + case TechnologyTypes.Xls: + return new XlsxWriter(cfg); + case TechnologyTypes.Json: return new JsonWriter(cfg); diff --git a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/CsvWriter .cs b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/CsvWriter .cs index abaf7333e..f1a55ddb0 100644 --- a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/CsvWriter .cs +++ b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/CsvWriter .cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -99,30 +100,160 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Writers } /// - /// Insert mode: - /// request.InsertItems define a single CSV row. - /// Example: - /// SerialNumber=SN001 - /// Result=PASS - /// Produces: - /// SN001;PASS - /// - /// Current implementation writes only values, not header names. + /// Appends one data row to the CSV file. /// + /// + /// Request containing the insert items. + /// + /// + /// A diagnostic result describing the inserted row. + /// + /// + /// + /// When the CSV file is empty or contains only empty lines, the first row is + /// automatically created from the insert item column names. + /// + /// + /// When the CSV file already contains a header, values are ordered according + /// to the existing header. + /// + /// private WriterDiagnosticResult ExecuteInsert(DataWriteRequest request) { - if (request.InsertItems == null || request.InsertItems.Count == 0) + if (request.InsertItems == null || + request.InsertItems.Count == 0) + { return Fail("No insert items provided."); + } - string delimiter = ";"; + if (request.InsertItems.Any(i => i == null)) + return Fail("Insert items contain a null item."); - string[] values = request.InsertItems - .Select(i => EscapeCsvValue(i != null ? i.Value : null)) + if (request.InsertItems.Any( + i => string.IsNullOrWhiteSpace(i.ColumnName))) + { + return Fail("Insert item contains an empty column name."); + } + + string[] duplicateColumns = request.InsertItems + .GroupBy( + i => i.ColumnName, + StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToArray(); + + if (duplicateColumns.Length > 0) + { + return Fail( + "Insert items contain duplicate columns: " + + string.Join(", ", duplicateColumns)); + } + + const string delimiter = ";"; + + Dictionary valuesByColumn = + request.InsertItems.ToDictionary( + item => item.ColumnName, + item => item.Value, + StringComparer.OrdinalIgnoreCase); + + string firstNonEmptyLine = null; + + if (File.Exists(cfg.DataSource)) + { + firstNonEmptyLine = File + .ReadLines(cfg.DataSource, Encoding.UTF8) + .FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)); + } + + string[] headerColumns; + + if (string.IsNullOrWhiteSpace(firstNonEmptyLine)) + { + headerColumns = request.InsertItems + .Select(item => item.ColumnName) + .ToArray(); + + string headerLine = string.Join( + delimiter, + headerColumns.Select(EscapeCsvValue)); + + // The file is empty or contains only blank lines. + // Rewrite it so that the header is always the first row. + File.WriteAllText( + cfg.DataSource, + headerLine + Environment.NewLine, + Encoding.UTF8); + } + else + { + headerColumns = ParseCsvLine( + firstNonEmptyLine, + delimiter[0]) + .Select(value => value.Trim()) + .ToArray(); + + if (headerColumns.Length == 0 || + headerColumns.All(string.IsNullOrWhiteSpace)) + { + return Fail("CSV file does not contain a valid header."); + } + + if (headerColumns.Any(string.IsNullOrWhiteSpace)) + { + return Fail( + "CSV header contains an empty column name."); + } + + string[] duplicateHeaderColumns = headerColumns + .GroupBy( + name => name, + StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToArray(); + + if (duplicateHeaderColumns.Length > 0) + { + return Fail( + "CSV header contains duplicate columns: " + + string.Join(", ", duplicateHeaderColumns)); + } + } + + string[] missingColumns = request.InsertItems + .Select(item => item.ColumnName) + .Where(name => !headerColumns.Contains( + name, + StringComparer.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (missingColumns.Length > 0) + { + return Fail( + "CSV header does not contain columns: " + + string.Join(", ", missingColumns)); + } + + string[] values = headerColumns + .Select(columnName => + { + string value; + + return valuesByColumn.TryGetValue(columnName, out value) + ? EscapeCsvValue(value) + : string.Empty; + }) .ToArray(); string line = string.Join(delimiter, values); - File.AppendAllText(cfg.DataSource, line + Environment.NewLine, Encoding.UTF8); + File.AppendAllText( + cfg.DataSource, + line + Environment.NewLine, + Encoding.UTF8); return new WriterDiagnosticResult { @@ -132,6 +263,76 @@ namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Writers }; } + /// + /// Parses a single CSV line while respecting quoted values. + /// + /// + /// CSV line to parse. + /// + /// + /// Character separating individual fields. + /// + /// + /// Parsed CSV field values. + /// + /// + /// Thrown when the line contains an unterminated quoted value. + /// + private string[] ParseCsvLine(string line, char delimiter) + { + if (line == null) + return new string[0]; + + List values = new List(); + StringBuilder currentValue = new StringBuilder(); + + bool insideQuotes = false; + + for (int index = 0; index < line.Length; index++) + { + char character = line[index]; + + if (character == '"') + { + bool escapedQuote = + insideQuotes && + index + 1 < line.Length && + line[index + 1] == '"'; + + if (escapedQuote) + { + currentValue.Append('"'); + index++; + } + else + { + insideQuotes = !insideQuotes; + } + + continue; + } + + if (character == delimiter && !insideQuotes) + { + values.Add(currentValue.ToString()); + currentValue.Clear(); + continue; + } + + currentValue.Append(character); + } + + if (insideQuotes) + { + throw new InvalidOperationException( + "CSV line contains an unterminated quoted value."); + } + + values.Add(currentValue.ToString()); + + return values.ToArray(); + } + /// /// Update mode: /// CSV has no natural SQL-style row update, so current implementation writes diff --git a/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/XlsxWriter.cs b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/XlsxWriter.cs new file mode 100644 index 000000000..a82e21fc8 --- /dev/null +++ b/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/XlsxWriter.cs @@ -0,0 +1,1032 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using ClosedXML.Excel; +using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Diagnostic; +using TBF.Rig.Output.DataStorage.UniDataStorageWriter.Interfaces; +using static TBF.Rig.Output.DataStorage.UniDataStorageWriter.Types; + +namespace TBF.Rig.Output.DataStorage.UniDataStorageWriter.Writers +{ + /// + /// Provides data writing support for Microsoft Excel Open XML files. + /// + /// + /// + /// The writer supports files using the .xlsx extension. + /// Microsoft Excel does not need to be installed on the target computer. + /// + /// + /// The first row of the first worksheet is used as the column header row. + /// Insert operations append a new row to the worksheet. Update operations + /// locate rows by a specified column value and update the requested cell. + /// + /// + public class XlsxWriter : IDataStorageWriter + { + private const string DefaultWorksheetName = "Data"; + + private readonly WriterCfg cfg; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Writer configuration containing the path of the target Excel file. + /// + /// + /// Thrown when is . + /// + public XlsxWriter(WriterCfg cfg) + { + this.cfg = cfg ?? throw new ArgumentNullException(nameof(cfg)); + } + + /// + /// Gets the storage types, technology types and write modes supported + /// by this writer. + /// + /// + /// The capabilities of the XLSX writer. + /// + public WriterCapabilities Capabilities + { + get + { + WriterCapabilities caps = new WriterCapabilities(); + + caps.SupportedStorageTypes.Add(StorageTypes.LocalFile); + caps.SupportedStorageTypes.Add(StorageTypes.RemoteFile); + + caps.SupportedTechnologyTypes.Add(TechnologyTypes.Xlsx); + + caps.SupportedWriteModes.Add(WriteMode.Insert); + caps.SupportedWriteModes.Add(WriteMode.Update); + + return caps; + } + } + + /// + /// Validates the configured XLSX source. + /// + /// + /// to validate the configured path without creating + /// missing directories or files; otherwise, . + /// + /// + /// A diagnostic result describing whether the XLSX source is valid + /// and accessible. + /// + /// + /// When is , + /// a missing directory and XLSX file are created automatically. + /// + public WriterDiagnosticResult TestSource(bool validateOnly) + { + try + { + WriterDiagnosticResult pathValidation = ValidateDataSource(); + if (!pathValidation.Success) + return pathValidation; + + string path = cfg.DataSource; + string directory = Path.GetDirectoryName(path); + + if (!string.IsNullOrWhiteSpace(directory) && + !Directory.Exists(directory)) + { + if (validateOnly) + return Fail("Directory does not exist: " + directory); + + Directory.CreateDirectory(directory); + } + + if (!File.Exists(path)) + { + if (validateOnly) + return Ok("XLSX file does not exist but the path is valid."); + + CreateEmptyWorkbook(path); + + return Ok("XLSX source was created successfully."); + } + + using (XLWorkbook workbook = new XLWorkbook(path)) + { + if (!workbook.Worksheets.Any()) + return Fail("XLSX file does not contain any worksheet."); + + IXLWorksheet worksheet = workbook.Worksheet(1); + + if (worksheet == null) + return Fail("The first XLSX worksheet could not be opened."); + } + + return Ok("XLSX source is ready."); + } + catch (IOException ex) + { + return Fail( + "XLSX source cannot be accessed. The file may be opened " + + "or locked by another process: " + ex.Message); + } + catch (UnauthorizedAccessException ex) + { + return Fail( + "Access to the XLSX source was denied: " + ex.Message); + } + catch (Exception ex) + { + return Fail("XLSX source test failed: " + ex.Message); + } + } + + /// + /// Writes data to the configured XLSX file. + /// + /// + /// Request containing the write mode and the data to be written. + /// + /// + /// A diagnostic result describing the outcome of the write operation. + /// + /// + /// Thrown when is . + /// + public WriterDiagnosticResult WriteData(DataWriteRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + WriterDiagnosticResult sourceTest = TestSource(false); + if (!sourceTest.Success) + return sourceTest; + + try + { + switch (request.Mode) + { + case WriteMode.Insert: + return ExecuteInsert(request); + + case WriteMode.Update: + return ExecuteUpdate(request); + + default: + return Fail( + "XLSX mode is not supported: " + request.Mode); + } + } + catch (IOException ex) + { + return Fail( + "XLSX file cannot be written. The file may be opened " + + "or locked by another process: " + ex.Message); + } + catch (UnauthorizedAccessException ex) + { + return Fail( + "Access to the XLSX file was denied: " + ex.Message); + } + catch (Exception ex) + { + return Fail("XLSX write failed: " + ex.Message); + } + } + + /// + /// Appends one data row to the first worksheet. + /// + /// + /// Request containing the insert items. + /// + /// + /// A diagnostic result describing the inserted row. + /// + /// + /// + /// When the worksheet does not yet contain headers, the first row is + /// automatically created from the insert item column names. + /// + /// + /// When headers already exist, every insert item column must be present + /// in the header row. + /// + /// + private WriterDiagnosticResult ExecuteInsert(DataWriteRequest request) + { + if (request.InsertItems == null || + request.InsertItems.Count == 0) + { + return Fail("No insert items provided."); + } + + if (request.InsertItems.Any(i => i == null)) + return Fail("Insert items contain a null item."); + + if (request.InsertItems.Any( + i => string.IsNullOrWhiteSpace(i.ColumnName))) + { + return Fail("Insert item contains an empty column name."); + } + + string[] duplicateColumns = request.InsertItems + .GroupBy( + i => i.ColumnName, + StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToArray(); + + if (duplicateColumns.Length > 0) + { + return Fail( + "Insert items contain duplicate columns: " + + string.Join(", ", duplicateColumns)); + } + + using (XLWorkbook workbook = OpenWorkbook()) + { + string template = cfg.GetTemplate(request.Mode); + string worksheetName = ParseWorksheetName(template); + + IXLWorksheet worksheet = GetOrCreateWorksheet( + workbook, + worksheetName); + + Dictionary columns = + GetOrCreateHeaderColumns( + worksheet, + request.InsertItems.Select(i => i.ColumnName)); + + string[] missingColumns = request.InsertItems + .Select(i => i.ColumnName) + .Where(name => !columns.ContainsKey(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (missingColumns.Length > 0) + { + return Fail( + "XLSX worksheet does not contain columns: " + + string.Join(", ", missingColumns)); + } + + int targetRowNumber = GetNextDataRowNumber(worksheet); + IXLRow targetRow = worksheet.Row(targetRowNumber); + + foreach (InsertWriteItem item in request.InsertItems) + { + int columnNumber = columns[item.ColumnName]; + + SetCellValue( + targetRow.Cell(columnNumber), + item.Value); + } + + workbook.Save(); + + string diagnostic = BuildInsertDiagnostic( + worksheet.Name, + targetRowNumber, + request.InsertItems); + + return new WriterDiagnosticResult + { + Success = true, + Message = "XLSX insert OK. Written 1 row.", + ExecutedTemplate = diagnostic + }; + } + } + + /// + /// Updates cells in rows matching the specified conditions. + /// + /// + /// Request containing the update items. + /// + /// + /// A diagnostic result containing the number of updated rows. + /// + /// + /// Each update item is processed separately. All rows whose value in + /// equals + /// are updated. + /// String comparison is case-sensitive. + /// + private WriterDiagnosticResult ExecuteUpdate(DataWriteRequest request) + { + if (request.UpdateItems == null || + request.UpdateItems.Count == 0) + { + return Fail("No update items provided."); + } + + if (request.UpdateItems.Any(i => i == null)) + return Fail("Update items contain a null item."); + + using (XLWorkbook workbook = OpenWorkbook()) + { + string template = cfg.GetTemplate(request.Mode); + string worksheetName = ParseWorksheetName(template); + + IXLWorksheet worksheet = GetOrCreateWorksheet( + workbook, + worksheetName); + + Dictionary columns = + GetHeaderColumns(worksheet); + + if (columns.Count == 0) + return Fail("XLSX worksheet does not contain a header row."); + + int totalUpdatedRows = 0; + StringBuilder diagnostics = new StringBuilder(); + + foreach (UpdateWriteItem item in request.UpdateItems) + { + WriterDiagnosticResult validation = + ValidateUpdateItem(item, columns); + + if (!validation.Success) + return validation; + + int whereColumnNumber = + columns[item.WhereParameterName]; + + int setColumnNumber = + columns[item.SetParameterName]; + + int updatedRows = UpdateMatchingRows( + worksheet, + whereColumnNumber, + item.WhereValue, + setColumnNumber, + item.SetValue); + + totalUpdatedRows += updatedRows; + + diagnostics.AppendLine( + BuildUpdateDiagnostic( + worksheet.Name, + item, + updatedRows)); + } + + workbook.Save(); + + return new WriterDiagnosticResult + { + Success = true, + Message = + "XLSX update OK. Rows: " + totalUpdatedRows, + ExecutedTemplate = + diagnostics.ToString().TrimEnd() + }; + } + } + + /// + /// Validates the configured data source path and file extension. + /// + /// + /// A successful result when the data source is valid; otherwise, + /// a failed diagnostic result. + /// + private WriterDiagnosticResult ValidateDataSource() + { + if (string.IsNullOrWhiteSpace(cfg.DataSource)) + return Fail("XLSX file path is not defined."); + + string extension = Path.GetExtension(cfg.DataSource); + + if (!string.Equals( + extension, + ".xlsx", + StringComparison.OrdinalIgnoreCase)) + { + return Fail( + "Invalid file extension. Expected .xlsx."); + } + + return Ok("XLSX data source is valid."); + } + + /// + /// Opens the configured workbook. + /// + /// + /// An opened . + /// + private XLWorkbook OpenWorkbook() + { + return new XLWorkbook(cfg.DataSource); + } + + /// + /// Creates an empty workbook containing the default worksheet. + /// + /// + /// Destination path of the workbook. + /// + private void CreateEmptyWorkbook(string path) + { + using (XLWorkbook workbook = new XLWorkbook()) + { + workbook.Worksheets.Add(DefaultWorksheetName); + workbook.SaveAs(path); + } + } + + /// + /// Gets the first worksheet from the specified workbook. + /// + /// + /// Workbook containing the worksheet. + /// + /// + /// The first worksheet in the workbook. + /// + /// + /// Thrown when the workbook does not contain a worksheet. + /// + private IXLWorksheet GetFirstWorksheet(XLWorkbook workbook) + { + if (workbook == null) + throw new ArgumentNullException(nameof(workbook)); + + IXLWorksheet worksheet = + workbook.Worksheets.FirstOrDefault(); + + if (worksheet == null) + { + throw new InvalidOperationException( + "XLSX workbook does not contain any worksheet."); + } + + return worksheet; + } + + /// + /// Reads header names from the first worksheet row. + /// + /// + /// Worksheet containing the header row. + /// + /// + /// A case-insensitive dictionary mapping column names to their + /// one-based worksheet column numbers. + /// + private Dictionary GetHeaderColumns( + IXLWorksheet worksheet) + { + Dictionary columns = + new Dictionary( + StringComparer.OrdinalIgnoreCase); + + IXLRow headerRow = worksheet.Row(1); + IXLCell lastUsedCell = headerRow.LastCellUsed(); + + if (lastUsedCell == null) + return columns; + + int lastColumnNumber = lastUsedCell.Address.ColumnNumber; + + for (int columnNumber = 1; + columnNumber <= lastColumnNumber; + columnNumber++) + { + string columnName = headerRow + .Cell(columnNumber) + .GetFormattedString() + .Trim(); + + if (string.IsNullOrWhiteSpace(columnName)) + continue; + + if (columns.ContainsKey(columnName)) + { + throw new InvalidOperationException( + "XLSX header contains duplicate column: " + + columnName); + } + + columns.Add(columnName, columnNumber); + } + + return columns; + } + + /// + /// Reads existing worksheet headers or creates them when the worksheet + /// is empty. + /// + /// + /// Worksheet containing the header row. + /// + /// + /// Column names required by the insert operation. + /// + /// + /// A dictionary mapping column names to worksheet column numbers. + /// + private Dictionary GetOrCreateHeaderColumns( + IXLWorksheet worksheet, + IEnumerable requiredColumnNames) + { + Dictionary columns = + GetHeaderColumns(worksheet); + + if (columns.Count > 0) + return columns; + + string[] columnNames = requiredColumnNames + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + for (int index = 0; index < columnNames.Length; index++) + { + int columnNumber = index + 1; + + worksheet.Cell(1, columnNumber).Value = + columnNames[index]; + + columns.Add( + columnNames[index], + columnNumber); + } + + return columns; + } + + /// + /// Determines the next available data row number. + /// + /// + /// Worksheet into which data will be inserted. + /// + /// + /// The one-based row number following the last used row. + /// + private int GetNextDataRowNumber(IXLWorksheet worksheet) + { + IXLRow lastUsedRow = worksheet.LastRowUsed(); + + if (lastUsedRow == null) + return 2; + + return Math.Max(lastUsedRow.RowNumber() + 1, 2); + } + + /// + /// Validates a single update item. + /// + /// + /// Update item to validate. + /// + /// + /// Available worksheet columns. + /// + /// + /// A successful result when the update item is valid; otherwise, + /// a failed diagnostic result. + /// + private WriterDiagnosticResult ValidateUpdateItem( + UpdateWriteItem item, + IDictionary columns) + { + if (string.IsNullOrWhiteSpace(item.WhereParameterName)) + return Fail("Update WHERE column name is empty."); + + if (string.IsNullOrWhiteSpace(item.SetParameterName)) + return Fail("Update SET column name is empty."); + + if (!columns.ContainsKey(item.WhereParameterName)) + { + return Fail( + "XLSX worksheet does not contain WHERE column: " + + item.WhereParameterName); + } + + if (!columns.ContainsKey(item.SetParameterName)) + { + return Fail( + "XLSX worksheet does not contain SET column: " + + item.SetParameterName); + } + + return Ok("Update item is valid."); + } + + /// + /// Updates all worksheet rows matching the specified value. + /// + /// + /// Worksheet containing the data. + /// + /// + /// One-based column number used to locate matching rows. + /// + /// + /// Value that must match the current cell value. + /// + /// + /// One-based column number of the cell to update. + /// + /// + /// New value assigned to the target cell. + /// + /// + /// The number of updated rows. + /// + private int UpdateMatchingRows( + IXLWorksheet worksheet, + int whereColumnNumber, + string whereValue, + int setColumnNumber, + string setValue) + { + IXLRow lastUsedRow = worksheet.LastRowUsed(); + + if (lastUsedRow == null || + lastUsedRow.RowNumber() < 2) + { + return 0; + } + + int updatedRows = 0; + int lastRowNumber = lastUsedRow.RowNumber(); + + for (int rowNumber = 2; + rowNumber <= lastRowNumber; + rowNumber++) + { + IXLCell whereCell = + worksheet.Cell(rowNumber, whereColumnNumber); + + string currentValue = + GetCellComparisonValue(whereCell); + + if (!string.Equals( + currentValue, + whereValue ?? string.Empty, + StringComparison.Ordinal)) + { + continue; + } + + IXLCell setCell = + worksheet.Cell(rowNumber, setColumnNumber); + + SetCellValue(setCell, setValue); + updatedRows++; + } + + return updatedRows; + } + + /// + /// Converts an XLSX cell value to a string used for update comparison. + /// + /// + /// Cell whose value is converted. + /// + /// + /// The formatted cell value, or an empty string for an empty cell. + /// + private string GetCellComparisonValue(IXLCell cell) + { + if (cell == null || cell.IsEmpty()) + return string.Empty; + + return cell.GetFormattedString(); + } + + /// + /// Assigns a request value to an XLSX cell. + /// + /// + /// Target worksheet cell. + /// + /// + /// Value to assign. A value clears the cell. + /// + private void SetCellValue(IXLCell cell, string value) + { + if (value == null) + { + cell.Clear(XLClearOptions.Contents); + return; + } + + cell.Value = value; + } + + /// + /// Builds a diagnostic description of an insert operation. + /// + /// + /// Name of the target worksheet. + /// + /// + /// Row number written by the operation. + /// + /// + /// Inserted values. + /// + /// + /// A human-readable insert diagnostic string. + /// + private string BuildInsertDiagnostic( + string worksheetName, + int rowNumber, + IEnumerable items) + { + string values = string.Join( + "; ", + items.Select( + item => + item.ColumnName + + "=" + + ToDiagnosticValue(item.Value))); + + return string.Format( + "INSERT [{0}] ROW {1}: {2}", + worksheetName, + rowNumber, + values); + } + + /// + /// Builds a diagnostic description of an update operation. + /// + /// + /// Name of the target worksheet. + /// + /// + /// Executed update item. + /// + /// + /// Number of rows updated by the operation. + /// + /// + /// A human-readable update diagnostic string. + /// + private string BuildUpdateDiagnostic( + string worksheetName, + UpdateWriteItem item, + int updatedRows) + { + return string.Format( + "UPDATE [{0}] SET {1}={2} WHERE {3}={4}; Rows={5}", + worksheetName, + item.SetParameterName, + ToDiagnosticValue(item.SetValue), + item.WhereParameterName, + ToDiagnosticValue(item.WhereValue), + updatedRows); + } + + /// + /// Converts a value to a diagnostic representation. + /// + /// + /// Value to represent. + /// + /// + /// The escaped diagnostic value. + /// + private string ToDiagnosticValue(string value) + { + if (value == null) + return "NULL"; + + return "'" + value.Replace("'", "''") + "'"; + } + + /// + /// Creates a successful diagnostic result. + /// + /// + /// Diagnostic message. + /// + /// + /// A successful writer diagnostic result. + /// + private WriterDiagnosticResult Ok(string message) + { + return new WriterDiagnosticResult + { + Success = true, + Message = message + }; + } + + /// + /// Creates a failed diagnostic result. + /// + /// + /// Diagnostic error message. + /// + /// + /// A failed writer diagnostic result. + /// + private WriterDiagnosticResult Fail(string message) + { + return new WriterDiagnosticResult + { + Success = false, + Message = message + }; + } + + /// + /// Resolves the worksheet name from the configured XLSX write template. + /// + /// + /// Template containing either a worksheet definition in the format + /// Sheet=WorksheetName or an SQL-like insert/update command. + /// + /// + /// The worksheet name resolved from the template. + /// + /// + /// Thrown when the template is empty or the worksheet name cannot be resolved. + /// + private string ParseWorksheetName(string template) + { + if (string.IsNullOrWhiteSpace(template)) + throw new InvalidOperationException( + "XLSX write template is empty."); + + string value = template.Trim(); + + const string sheetPrefix = "Sheet="; + + if (value.StartsWith( + sheetPrefix, + StringComparison.OrdinalIgnoreCase)) + { + return ValidateAndNormalizeWorksheetName( + value.Substring(sheetPrefix.Length)); + } + + const string insertPrefix = "INSERT INTO "; + + if (value.StartsWith( + insertPrefix, + StringComparison.OrdinalIgnoreCase)) + { + string remaining = value + .Substring(insertPrefix.Length) + .Trim(); + + int endIndex = remaining.IndexOfAny( + new[] { ' ', '(' }); + + string worksheetName = endIndex >= 0 + ? remaining.Substring(0, endIndex) + : remaining; + + return ValidateAndNormalizeWorksheetName( + worksheetName); + } + + const string updatePrefix = "UPDATE "; + + if (value.StartsWith( + updatePrefix, + StringComparison.OrdinalIgnoreCase)) + { + string remaining = value + .Substring(updatePrefix.Length) + .Trim(); + + int endIndex = remaining.IndexOfAny( + new[] { ' ', '(' }); + + string worksheetName = endIndex >= 0 + ? remaining.Substring(0, endIndex) + : remaining; + + return ValidateAndNormalizeWorksheetName( + worksheetName); + } + + throw new InvalidOperationException( + "XLSX worksheet name could not be resolved from template: " + + template); + } + + /// + /// Normalizes and validates an XLSX worksheet name. + /// + /// + /// Worksheet name to normalize and validate. + /// + /// + /// A valid worksheet name. + /// + /// + /// Thrown when the worksheet name is empty, too long, + /// or contains an invalid character. + /// + private string ValidateAndNormalizeWorksheetName( + string worksheetName) + { + string result = (worksheetName ?? string.Empty).Trim(); + + if (result.Length >= 2) + { + bool squareBrackets = + result[0] == '[' && + result[result.Length - 1] == ']'; + + bool singleQuotes = + result[0] == '\'' && + result[result.Length - 1] == '\''; + + bool doubleQuotes = + result[0] == '"' && + result[result.Length - 1] == '"'; + + if (squareBrackets || singleQuotes || doubleQuotes) + { + result = result.Substring( + 1, + result.Length - 2); + } + } + + result = result.Trim(); + + if (string.IsNullOrWhiteSpace(result)) + { + throw new InvalidOperationException( + "XLSX worksheet name is empty."); + } + + if (result.Length > 31) + { + throw new InvalidOperationException( + "XLSX worksheet name cannot contain more than 31 characters."); + } + + char[] invalidCharacters = + { + '\\', + '/', + '?', + '*', + '[', + ']', + ':' + }; + + if (result.IndexOfAny(invalidCharacters) >= 0) + { + throw new InvalidOperationException( + "XLSX worksheet name contains an invalid character: " + + result); + } + + return result; + } + + /// + /// Gets an existing worksheet or creates a new worksheet with the specified name. + /// + /// + /// Workbook containing the worksheet. + /// + /// + /// Name of the worksheet. + /// + /// + /// The existing or newly created worksheet. + /// + private IXLWorksheet GetOrCreateWorksheet( + XLWorkbook workbook, + string worksheetName) + { + if (workbook == null) + throw new ArgumentNullException(nameof(workbook)); + + IXLWorksheet worksheet; + + if (workbook.Worksheets.TryGetWorksheet( + worksheetName, + out worksheet)) + { + return worksheet; + } + + return workbook.Worksheets.Add(worksheetName); + } + } +} diff --git a/TBF/TBF.csproj b/TBF/TBF.csproj index a6e3bcf01..ea0d7da2f 100644 --- a/TBF/TBF.csproj +++ b/TBF/TBF.csproj @@ -108,6 +108,21 @@ ..\packages\Castle.Core.5.1.1\lib\net462\Castle.Core.dll + + ..\packages\ClosedXML.0.105.0\lib\netstandard2.0\ClosedXML.dll + + + ..\packages\ClosedXML.Parser.2.0.0\lib\netstandard2.0\ClosedXML.Parser.dll + + + ..\packages\DocumentFormat.OpenXml.3.1.1\lib\net46\DocumentFormat.OpenXml.dll + + + ..\packages\DocumentFormat.OpenXml.Framework.3.1.1\lib\net46\DocumentFormat.OpenXml.Framework.dll + + + ..\packages\ExcelNumberFormat.1.1.0\lib\net20\ExcelNumberFormat.dll + ..\packages\FluentNHibernate.2.0.3.0\lib\net40\FluentNHibernate.dll @@ -120,6 +135,9 @@ ..\packages\Common\Logic.ProductionToProductMapper.dll + + ..\packages\Microsoft.Bcl.HashCode.1.1.1\lib\net461\Microsoft.Bcl.HashCode.dll + ..\packages\MySql.Data.6.6.5\lib\net40\MySql.Data.dll @@ -141,10 +159,19 @@ ..\packages\Oracle.ManagedDataAccess.19.11.0\lib\net40\Oracle.ManagedDataAccess.dll True + + ..\packages\RBush.Signed.4.0.0\lib\net47\RBush.dll + ..\packages\Renci.SshNet\Renci.SshNet.dll + + ..\packages\SixLabors.Fonts.1.0.0\lib\netstandard2.0\SixLabors.Fonts.dll + + + ..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll + @@ -157,13 +184,19 @@ ..\packages\System.Drawing.Common.9.0.5\lib\net462\System.Drawing.Common.dll + + ..\packages\System.Memory.4.5.5\lib\net461\System.Memory.dll + ..\packages\System.Net.Sockets.4.3.0\lib\net46\System.Net.Sockets.dll - - ..\packages\System.Runtime.CompilerServices.Unsafe.4.5.3\lib\net461\System.Runtime.CompilerServices.Unsafe.dll + + ..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + + + ..\packages\System.Runtime.CompilerServices.Unsafe.4.7.0\lib\netstandard2.0\System.Runtime.CompilerServices.Unsafe.dll @@ -173,6 +206,7 @@ + ..\packages\Common\Xylem.Common.CommonCore.dll @@ -1223,6 +1257,7 @@ WriterCfgCtrl.cs + UserControl diff --git a/TBF/app.config b/TBF/app.config index 64516adb1..a5310bf9b 100644 --- a/TBF/app.config +++ b/TBF/app.config @@ -83,6 +83,22 @@ + + + + + + + + + + + + + + + + diff --git a/TBF/packages.config b/TBF/packages.config index 609408d25..478c4224c 100644 --- a/TBF/packages.config +++ b/TBF/packages.config @@ -1,18 +1,29 @@  + + + + + + + + + + - + + \ No newline at end of file diff --git a/TBFTests/app.config b/TBFTests/app.config index 79f5b8a14..84e18af51 100644 --- a/TBFTests/app.config +++ b/TBFTests/app.config @@ -14,6 +14,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ToFirstMonitor/App.config b/ToFirstMonitor/App.config index dd580728f..8d668f958 100644 --- a/ToFirstMonitor/App.config +++ b/ToFirstMonitor/App.config @@ -9,6 +9,22 @@ + + + + + + + + + + + + + + + + diff --git a/ToSecondMonitor/App.config b/ToSecondMonitor/App.config index dd580728f..8d668f958 100644 --- a/ToSecondMonitor/App.config +++ b/ToSecondMonitor/App.config @@ -9,6 +9,22 @@ + + + + + + + + + + + + + + + +