467 lines
16 KiB
C#
467 lines
16 KiB
C#
#pragma warning disable Serilog004 // Constant MessageTemplate verifier
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.CommandLine;
|
|
using System.CommandLine.Invocation;
|
|
using System.CommandLine.IO;
|
|
using System.Data;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
|
|
using ConsoleTableExt;
|
|
|
|
using CsvHelper;
|
|
using CsvHelper.Configuration;
|
|
|
|
using FluentResults;
|
|
|
|
using GlobExpressions;
|
|
|
|
using MfGames.ToolBuilder.Extensions;
|
|
|
|
using Newtonsoft.Json;
|
|
|
|
using Serilog;
|
|
|
|
namespace MfGames.ToolBuilder.Tables
|
|
{
|
|
/// <summary>
|
|
/// Contains various utility functions for working with commands that display
|
|
/// tables.
|
|
/// </summary>
|
|
public class TableToolService
|
|
{
|
|
private readonly Command command;
|
|
|
|
private readonly ILogger logger;
|
|
|
|
private readonly Option<bool> noAlignTableOption;
|
|
|
|
private readonly Option<string> tableColumnOption;
|
|
|
|
private readonly Option<string> tableFormatOption;
|
|
|
|
private DataTable? table;
|
|
|
|
/// <summary>
|
|
/// Creates a new table CLI service with specific commands.
|
|
/// </summary>
|
|
/// <param name="logger">The logger for requests.</param>
|
|
/// <param name="command">The command to add the options to.</param>
|
|
/// <param name="table">The optional table to columns the parameters.</param>
|
|
/// <param name="defaultColumns">A list of columns to display if none are set.</param>
|
|
public TableToolService(
|
|
ILogger logger,
|
|
Command command,
|
|
DataTable? table = null,
|
|
IList<string>? defaultColumns = null)
|
|
{
|
|
// Set member variables.
|
|
this.logger = logger;
|
|
this.command = command;
|
|
this.table = table;
|
|
this.DefaultColumns = defaultColumns;
|
|
|
|
// If we have a table, then we can show the list of known columns.
|
|
string? tableColumnsDescription =
|
|
"The columns to display in the output.";
|
|
|
|
string? tableFormatDescription = string.Format(
|
|
"The fuzzy format of the table, one of: {0}.",
|
|
string.Join(", ", Enum.GetNames<TableFormatType>()));
|
|
|
|
if (table != null)
|
|
{
|
|
tableColumnsDescription = string.Format(
|
|
"The columns to display in the output, \"*\" or comma-separated list of: {0}.",
|
|
string.Join(
|
|
", ",
|
|
table.Columns.OfType<DataColumn>()
|
|
.Select(x => x.ColumnName)));
|
|
|
|
if (defaultColumns != null)
|
|
{
|
|
tableColumnsDescription += " [default: "
|
|
+ string.Join(",", defaultColumns)
|
|
+ "]";
|
|
}
|
|
}
|
|
|
|
// Create the parameters for the table.
|
|
this.tableColumnOption = new Option<string>(
|
|
"--table-columns",
|
|
tableColumnsDescription)
|
|
{
|
|
ArgumentHelpName = "column[,column...]",
|
|
};
|
|
|
|
this.noAlignTableOption = new Option<bool>(
|
|
"--no-align-table-columns",
|
|
() => false,
|
|
"If set, don't right-align numerical columns.");
|
|
|
|
this.tableFormatOption = new Option<string>(
|
|
"--table-format",
|
|
() => nameof(TableFormatType.Default),
|
|
tableFormatDescription)
|
|
{
|
|
ArgumentHelpName = "format",
|
|
};
|
|
|
|
// Add the options into the command.
|
|
command.AddOption(this.tableFormatOption);
|
|
command.AddOption(this.noAlignTableOption);
|
|
command.AddOption(this.tableColumnOption);
|
|
}
|
|
|
|
public delegate TableToolService Factory(
|
|
Command command,
|
|
DataTable? table = null,
|
|
IList<string>? defaultColumns = null);
|
|
|
|
public IList<string>? DefaultColumns { get; set; }
|
|
|
|
public IList<string> GetVisibleColumnNames(InvocationContext context)
|
|
{
|
|
// Do a little sanity checking on member variables.
|
|
if (this.table == null)
|
|
{
|
|
throw new NullReferenceException(
|
|
"The table field has not been defined.");
|
|
}
|
|
|
|
// Figure out the columns that the user wants to see. If they have
|
|
// not specified any, then we use the defaults.
|
|
List<string> defaultColumns =
|
|
this.DefaultColumns?.ToList() ?? new List<string>();
|
|
|
|
var optionColumns = context.ParseResult
|
|
.GetValueListForOption(this.tableColumnOption)
|
|
.ToList();
|
|
|
|
List<string> tableColumns = optionColumns.Count == 0
|
|
? defaultColumns
|
|
: optionColumns;
|
|
|
|
// Expand the columns out to handle a simplified globbing where "*"
|
|
// means include all columns.
|
|
var allColumns = this.table.Columns
|
|
.Cast<DataColumn>()
|
|
.Select(x => x.ColumnName)
|
|
.ToList();
|
|
|
|
List<string> expandedNames = tableColumns
|
|
.SelectMany(x => IsMatch(allColumns, x))
|
|
.ToList();
|
|
|
|
// Do a fuzzy matching of names. This allows for a case-insensitive
|
|
// search while also allowing for prefixes (so "d" works for
|
|
// "Debug").
|
|
List<Result<string>> fuzzyResults = expandedNames
|
|
.ConvertAll(x => x.GetFuzzy(allColumns));
|
|
|
|
ToolException.ThrowIf(fuzzyResults);
|
|
|
|
var fuzzyValues = fuzzyResults
|
|
.Select(x => x.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
return fuzzyValues;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines if the column is going to be shown to the user.
|
|
/// </summary>
|
|
/// <param name="context">The context of the request.</param>
|
|
/// <param name="columnName">The column to query.</param>
|
|
/// <returns>True if the column is visible, otherwise false.</returns>
|
|
public bool IsVisible(InvocationContext context, string columnName)
|
|
{
|
|
if (this.table == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cannot use IsVisible without a sample table given.");
|
|
}
|
|
|
|
IList<string> visibleColumns = this.GetVisibleColumnNames(context);
|
|
|
|
bool visible = visibleColumns
|
|
.Any(
|
|
x => x.Equals(
|
|
columnName,
|
|
StringComparison.InvariantCultureIgnoreCase));
|
|
|
|
return visible;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders out the table using the requested formats.
|
|
/// </summary>
|
|
/// <param name="context">The context of the command.</param>
|
|
/// <param name="updatedTable">The table to use if not the initial one..</param>
|
|
public void Write(
|
|
InvocationContext context,
|
|
DataTable? updatedTable = null)
|
|
{
|
|
// Get the table and make sure we have it.
|
|
this.table = updatedTable ?? this.table;
|
|
|
|
if (this.table == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cannot write out a table if not given in the constructor, updated via property, or passed into the write method.");
|
|
}
|
|
|
|
// Adjust, hide, and reorder columns.
|
|
if (!this.AdjustColumns(context))
|
|
{
|
|
throw new ToolException(
|
|
"Cannot adjust the columns of the resulting data");
|
|
}
|
|
|
|
// Pass the resulting data to the formatting code.
|
|
TableFormatType tableFormat = this.GetTableFormat(context);
|
|
|
|
switch (tableFormat)
|
|
{
|
|
case TableFormatType.Default:
|
|
case TableFormatType.Markdown:
|
|
case TableFormatType.Alternative:
|
|
case TableFormatType.Minimal:
|
|
this.WriteTable(context, tableFormat);
|
|
|
|
break;
|
|
|
|
case TableFormatType.Json:
|
|
this.WriteJson(context);
|
|
|
|
break;
|
|
|
|
case TableFormatType.List:
|
|
this.WriteList(context);
|
|
|
|
break;
|
|
|
|
case TableFormatType.Csv:
|
|
this.WriteCsv(context);
|
|
|
|
break;
|
|
|
|
default:
|
|
throw new ArgumentOutOfRangeException();
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> IsMatch(
|
|
IEnumerable<string> columns,
|
|
string input)
|
|
{
|
|
var glob = new Glob(input, GlobOptions.CaseInsensitive);
|
|
var matches = columns
|
|
.Where(x => glob.IsMatch(x))
|
|
.ToList();
|
|
|
|
if (matches.Count > 0)
|
|
{
|
|
return matches;
|
|
}
|
|
|
|
return new[] { input };
|
|
}
|
|
|
|
private bool AdjustColumns(InvocationContext context)
|
|
{
|
|
// Figure out if we need to change the columns.
|
|
IList<string> columnNames = this.GetVisibleColumnNames(context);
|
|
|
|
if (columnNames.Count == 0)
|
|
{
|
|
this.logger.Error("There were no columns to display");
|
|
|
|
return false;
|
|
}
|
|
|
|
// Remove excessive columns.
|
|
IEnumerable<DataColumn> columnsToRemove = this.table!.Columns
|
|
.OfType<DataColumn>()
|
|
.Where(x => !columnNames.Contains(x.ColumnName))
|
|
.ToList();
|
|
|
|
foreach (DataColumn column in columnsToRemove)
|
|
{
|
|
this.table.Columns.Remove(column);
|
|
}
|
|
|
|
// Reorder the columns to match requested order.
|
|
for (int order = 0; order < columnNames.Count; order++)
|
|
{
|
|
this.table.Columns[columnNames[order]]!.SetOrdinal(order);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private TableFormatType GetTableFormat(InvocationContext context)
|
|
{
|
|
string? tableFormat =
|
|
context.ParseResult.GetValueForOption(this.tableFormatOption)
|
|
?? nameof(TableFormatType.Minimal);
|
|
|
|
if (!tableFormat.TryParseEnumFuzzy(out TableFormatType value))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Unknown table format '"
|
|
+ tableFormat
|
|
+ "'. Must be one of: "
|
|
+ string.Join(", ", Enum.GetNames<TableFormatType>()));
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes out the contents of the table as CSV.
|
|
/// </summary>
|
|
private void WriteCsv(InvocationContext context)
|
|
{
|
|
var configuration =
|
|
new CsvConfiguration(CultureInfo.CurrentCulture);
|
|
var stringWriter = new StringWriter();
|
|
var csv = new CsvWriter(stringWriter, configuration);
|
|
|
|
foreach (DataColumn column in
|
|
this.table!.Columns.OfType<DataColumn>())
|
|
{
|
|
csv.WriteField(column.ColumnName);
|
|
}
|
|
|
|
csv.NextRecord();
|
|
|
|
foreach (DataRow row in this.table.AsEnumerable())
|
|
{
|
|
foreach (DataColumn column in this.table.Columns
|
|
.OfType<DataColumn>())
|
|
{
|
|
csv.WriteField(Convert.ToString(row[column]));
|
|
}
|
|
|
|
csv.NextRecord();
|
|
}
|
|
|
|
context.Console.Out.Write(stringWriter.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes out the contents of the table as JSON.
|
|
/// </summary>
|
|
private void WriteJson(InvocationContext context)
|
|
{
|
|
context.Console.Out.WriteLine(
|
|
JsonConvert.SerializeObject(this.table));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes out the contents of the table in a PowerShell-like list.
|
|
/// </summary>
|
|
private void WriteList(InvocationContext context)
|
|
{
|
|
int labelWidth = this.table!.Columns
|
|
.OfType<DataColumn>()
|
|
.Max(x => x.ColumnName.Length);
|
|
|
|
bool first = true;
|
|
|
|
foreach (DataRow row in this.table.AsEnumerable())
|
|
{
|
|
if (first)
|
|
{
|
|
first = false;
|
|
}
|
|
else
|
|
{
|
|
context.Console.Out.WriteLine();
|
|
}
|
|
|
|
for (int i = 0; i < this.table.Columns.Count; i++)
|
|
{
|
|
context.Console.Out.WriteLine(
|
|
string.Format(
|
|
"{0} : {1}",
|
|
this.table.Columns[i]
|
|
.ColumnName.PadRight(labelWidth),
|
|
row[i]));
|
|
}
|
|
}
|
|
}
|
|
|
|
private void WriteTable(
|
|
InvocationContext context,
|
|
TableFormatType tableFormat)
|
|
{
|
|
// Build the table from options.
|
|
ConsoleTableBuilder builder = ConsoleTableBuilder.From(this.table);
|
|
|
|
// Figure out formatting.
|
|
if (tableFormat == TableFormatType.Default)
|
|
{
|
|
// Default is mostly like ConsoleTableExt but there is a separator
|
|
// between the headers because it makes it harder to parse.
|
|
builder
|
|
.WithPaddingLeft(string.Empty)
|
|
.WithPaddingRight(string.Empty)
|
|
.WithCharMapDefinition(
|
|
new Dictionary<CharMapPositions, char>
|
|
{
|
|
{ CharMapPositions.DividerY, ' ' },
|
|
{ CharMapPositions.BottomCenter, ' ' },
|
|
})
|
|
.WithHeaderCharMapDefinition(
|
|
new Dictionary<HeaderCharMapPositions, char>
|
|
{
|
|
{ HeaderCharMapPositions.BottomCenter, '+' },
|
|
{ HeaderCharMapPositions.Divider, ' ' },
|
|
{ HeaderCharMapPositions.BorderBottom, '-' },
|
|
});
|
|
}
|
|
else
|
|
{
|
|
builder.WithFormat((ConsoleTableBuilderFormat)tableFormat);
|
|
}
|
|
|
|
// We default to aligning numerical columns to the right.
|
|
bool noAlign =
|
|
context.ParseResult.GetValueForOption(this.noAlignTableOption);
|
|
|
|
if (!noAlign)
|
|
{
|
|
builder.WithTextAlignment(
|
|
this.table!.Columns
|
|
.OfType<DataColumn>()
|
|
.Select(
|
|
(column, index) => (Index: index,
|
|
Numeric: column.DataType.Name switch
|
|
{
|
|
nameof(Byte) => true,
|
|
nameof(Int16) => true,
|
|
nameof(Int32) => true,
|
|
nameof(Int64) => true,
|
|
nameof(Decimal) => true,
|
|
_ => false,
|
|
}))
|
|
.Where(x => x.Numeric)
|
|
.ToDictionary(x => x.Index, x => TextAligntment.Right));
|
|
}
|
|
|
|
// Write out the results.
|
|
string rendered = builder.Export()
|
|
.ToString()
|
|
.TrimEnd(' ', '\n', '\r', '\0');
|
|
|
|
context.Console.Out.WriteLine(rendered);
|
|
}
|
|
}
|
|
}
|