tbf/TBF/Rig/Output/DataStorage/UniDataStorageWriter/Writers/XlsxWriter.cs

1033 lines
34 KiB
C#
Raw Normal View History

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
{
/// <summary>
/// Provides data writing support for Microsoft Excel Open XML files.
/// </summary>
/// <remarks>
/// <para>
/// The writer supports files using the <c>.xlsx</c> extension.
/// Microsoft Excel does not need to be installed on the target computer.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public class XlsxWriter : IDataStorageWriter
{
private const string DefaultWorksheetName = "Data";
private readonly WriterCfg cfg;
/// <summary>
/// Initializes a new instance of the <see cref="XlsxWriter"/> class.
/// </summary>
/// <param name="cfg">
/// Writer configuration containing the path of the target Excel file.
/// </param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="cfg"/> is <see langword="null"/>.
/// </exception>
public XlsxWriter(WriterCfg cfg)
{
this.cfg = cfg ?? throw new ArgumentNullException(nameof(cfg));
}
/// <summary>
/// Gets the storage types, technology types and write modes supported
/// by this writer.
/// </summary>
/// <value>
/// The capabilities of the XLSX writer.
/// </value>
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;
}
}
/// <summary>
/// Validates the configured XLSX source.
/// </summary>
/// <param name="validateOnly">
/// <see langword="true"/> to validate the configured path without creating
/// missing directories or files; otherwise, <see langword="false"/>.
/// </param>
/// <returns>
/// A diagnostic result describing whether the XLSX source is valid
/// and accessible.
/// </returns>
/// <remarks>
/// When <paramref name="validateOnly"/> is <see langword="false"/>,
/// a missing directory and XLSX file are created automatically.
/// </remarks>
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);
}
}
/// <summary>
/// Writes data to the configured XLSX file.
/// </summary>
/// <param name="request">
/// Request containing the write mode and the data to be written.
/// </param>
/// <returns>
/// A diagnostic result describing the outcome of the write operation.
/// </returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="request"/> is <see langword="null"/>.
/// </exception>
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);
}
}
/// <summary>
/// Appends one data row to the first worksheet.
/// </summary>
/// <param name="request">
/// Request containing the insert items.
/// </param>
/// <returns>
/// A diagnostic result describing the inserted row.
/// </returns>
/// <remarks>
/// <para>
/// When the worksheet does not yet contain headers, the first row is
/// automatically created from the insert item column names.
/// </para>
/// <para>
/// When headers already exist, every insert item column must be present
/// in the header row.
/// </para>
/// </remarks>
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<string, int> 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
};
}
}
/// <summary>
/// Updates cells in rows matching the specified conditions.
/// </summary>
/// <param name="request">
/// Request containing the update items.
/// </param>
/// <returns>
/// A diagnostic result containing the number of updated rows.
/// </returns>
/// <remarks>
/// Each update item is processed separately. All rows whose value in
/// <see cref="UpdateWriteItem.WhereParameterName"/> equals
/// <see cref="UpdateWriteItem.WhereValue"/> are updated.
/// String comparison is case-sensitive.
/// </remarks>
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<string, int> 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()
};
}
}
/// <summary>
/// Validates the configured data source path and file extension.
/// </summary>
/// <returns>
/// A successful result when the data source is valid; otherwise,
/// a failed diagnostic result.
/// </returns>
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.");
}
/// <summary>
/// Opens the configured workbook.
/// </summary>
/// <returns>
/// An opened <see cref="XLWorkbook"/>.
/// </returns>
private XLWorkbook OpenWorkbook()
{
return new XLWorkbook(cfg.DataSource);
}
/// <summary>
/// Creates an empty workbook containing the default worksheet.
/// </summary>
/// <param name="path">
/// Destination path of the workbook.
/// </param>
private void CreateEmptyWorkbook(string path)
{
using (XLWorkbook workbook = new XLWorkbook())
{
workbook.Worksheets.Add(DefaultWorksheetName);
workbook.SaveAs(path);
}
}
/// <summary>
/// Gets the first worksheet from the specified workbook.
/// </summary>
/// <param name="workbook">
/// Workbook containing the worksheet.
/// </param>
/// <returns>
/// The first worksheet in the workbook.
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the workbook does not contain a worksheet.
/// </exception>
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;
}
/// <summary>
/// Reads header names from the first worksheet row.
/// </summary>
/// <param name="worksheet">
/// Worksheet containing the header row.
/// </param>
/// <returns>
/// A case-insensitive dictionary mapping column names to their
/// one-based worksheet column numbers.
/// </returns>
private Dictionary<string, int> GetHeaderColumns(
IXLWorksheet worksheet)
{
Dictionary<string, int> columns =
new Dictionary<string, int>(
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;
}
/// <summary>
/// Reads existing worksheet headers or creates them when the worksheet
/// is empty.
/// </summary>
/// <param name="worksheet">
/// Worksheet containing the header row.
/// </param>
/// <param name="requiredColumnNames">
/// Column names required by the insert operation.
/// </param>
/// <returns>
/// A dictionary mapping column names to worksheet column numbers.
/// </returns>
private Dictionary<string, int> GetOrCreateHeaderColumns(
IXLWorksheet worksheet,
IEnumerable<string> requiredColumnNames)
{
Dictionary<string, int> 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;
}
/// <summary>
/// Determines the next available data row number.
/// </summary>
/// <param name="worksheet">
/// Worksheet into which data will be inserted.
/// </param>
/// <returns>
/// The one-based row number following the last used row.
/// </returns>
private int GetNextDataRowNumber(IXLWorksheet worksheet)
{
IXLRow lastUsedRow = worksheet.LastRowUsed();
if (lastUsedRow == null)
return 2;
return Math.Max(lastUsedRow.RowNumber() + 1, 2);
}
/// <summary>
/// Validates a single update item.
/// </summary>
/// <param name="item">
/// Update item to validate.
/// </param>
/// <param name="columns">
/// Available worksheet columns.
/// </param>
/// <returns>
/// A successful result when the update item is valid; otherwise,
/// a failed diagnostic result.
/// </returns>
private WriterDiagnosticResult ValidateUpdateItem(
UpdateWriteItem item,
IDictionary<string, int> 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.");
}
/// <summary>
/// Updates all worksheet rows matching the specified value.
/// </summary>
/// <param name="worksheet">
/// Worksheet containing the data.
/// </param>
/// <param name="whereColumnNumber">
/// One-based column number used to locate matching rows.
/// </param>
/// <param name="whereValue">
/// Value that must match the current cell value.
/// </param>
/// <param name="setColumnNumber">
/// One-based column number of the cell to update.
/// </param>
/// <param name="setValue">
/// New value assigned to the target cell.
/// </param>
/// <returns>
/// The number of updated rows.
/// </returns>
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;
}
/// <summary>
/// Converts an XLSX cell value to a string used for update comparison.
/// </summary>
/// <param name="cell">
/// Cell whose value is converted.
/// </param>
/// <returns>
/// The formatted cell value, or an empty string for an empty cell.
/// </returns>
private string GetCellComparisonValue(IXLCell cell)
{
if (cell == null || cell.IsEmpty())
return string.Empty;
return cell.GetFormattedString();
}
/// <summary>
/// Assigns a request value to an XLSX cell.
/// </summary>
/// <param name="cell">
/// Target worksheet cell.
/// </param>
/// <param name="value">
/// Value to assign. A <see langword="null"/> value clears the cell.
/// </param>
private void SetCellValue(IXLCell cell, string value)
{
if (value == null)
{
cell.Clear(XLClearOptions.Contents);
return;
}
cell.Value = value;
}
/// <summary>
/// Builds a diagnostic description of an insert operation.
/// </summary>
/// <param name="worksheetName">
/// Name of the target worksheet.
/// </param>
/// <param name="rowNumber">
/// Row number written by the operation.
/// </param>
/// <param name="items">
/// Inserted values.
/// </param>
/// <returns>
/// A human-readable insert diagnostic string.
/// </returns>
private string BuildInsertDiagnostic(
string worksheetName,
int rowNumber,
IEnumerable<InsertWriteItem> items)
{
string values = string.Join(
"; ",
items.Select(
item =>
item.ColumnName +
"=" +
ToDiagnosticValue(item.Value)));
return string.Format(
"INSERT [{0}] ROW {1}: {2}",
worksheetName,
rowNumber,
values);
}
/// <summary>
/// Builds a diagnostic description of an update operation.
/// </summary>
/// <param name="worksheetName">
/// Name of the target worksheet.
/// </param>
/// <param name="item">
/// Executed update item.
/// </param>
/// <param name="updatedRows">
/// Number of rows updated by the operation.
/// </param>
/// <returns>
/// A human-readable update diagnostic string.
/// </returns>
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);
}
/// <summary>
/// Converts a value to a diagnostic representation.
/// </summary>
/// <param name="value">
/// Value to represent.
/// </param>
/// <returns>
/// The escaped diagnostic value.
/// </returns>
private string ToDiagnosticValue(string value)
{
if (value == null)
return "NULL";
return "'" + value.Replace("'", "''") + "'";
}
/// <summary>
/// Creates a successful diagnostic result.
/// </summary>
/// <param name="message">
/// Diagnostic message.
/// </param>
/// <returns>
/// A successful writer diagnostic result.
/// </returns>
private WriterDiagnosticResult Ok(string message)
{
return new WriterDiagnosticResult
{
Success = true,
Message = message
};
}
/// <summary>
/// Creates a failed diagnostic result.
/// </summary>
/// <param name="message">
/// Diagnostic error message.
/// </param>
/// <returns>
/// A failed writer diagnostic result.
/// </returns>
private WriterDiagnosticResult Fail(string message)
{
return new WriterDiagnosticResult
{
Success = false,
Message = message
};
}
/// <summary>
/// Resolves the worksheet name from the configured XLSX write template.
/// </summary>
/// <param name="template">
/// Template containing either a worksheet definition in the format
/// <c>Sheet=WorksheetName</c> or an SQL-like insert/update command.
/// </param>
/// <returns>
/// The worksheet name resolved from the template.
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the template is empty or the worksheet name cannot be resolved.
/// </exception>
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);
}
/// <summary>
/// Normalizes and validates an XLSX worksheet name.
/// </summary>
/// <param name="worksheetName">
/// Worksheet name to normalize and validate.
/// </param>
/// <returns>
/// A valid worksheet name.
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the worksheet name is empty, too long,
/// or contains an invalid character.
/// </exception>
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;
}
/// <summary>
/// Gets an existing worksheet or creates a new worksheet with the specified name.
/// </summary>
/// <param name="workbook">
/// Workbook containing the worksheet.
/// </param>
/// <param name="worksheetName">
/// Name of the worksheet.
/// </param>
/// <returns>
/// The existing or newly created worksheet.
/// </returns>
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);
}
}
}