Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
c40f490
Created LocalDateTools
william-gross Jan 23, 2025
e1b1640
Updated ReSharper settings
william-gross Feb 18, 2025
947b409
Created ICalendarTools
william-gross Feb 18, 2025
e627de0
ParsedLine explicit implementation
william-gross Feb 25, 2025
d4d96a6
Removed obsolete CsvLineParser constructor
william-gross Feb 25, 2025
1ed3606
Simplified TextBasedTabularDataParser.Parse
william-gross Feb 25, 2025
2b21177
Made TextBasedParsedLine.lineNumber immutable
william-gross Feb 25, 2025
3caaa51
Improved TextBasedParsedLine.fields encapsulation
william-gross Feb 25, 2025
6207aa8
Made columnHeadersToIndexes immutable
william-gross Feb 25, 2025
04c8715
Cleaned up CsvLineParser
william-gross Feb 25, 2025
0bde5bf
Cleaned up FixedWidthParser
william-gross Feb 25, 2025
82f6906
Cleaned up TextBasedTabularDataParser
william-gross Feb 25, 2025
c3321b4
Moved hasHeaderRow setting into TabularDataParser
william-gross Feb 25, 2025
4c9db69
Implemented TabularDataParser required columns
william-gross Feb 26, 2025
daa7351
Annotated StringTools.IsNullOrWhiteSpace
william-gross Feb 26, 2025
25bb445
Implemented required columns in ExcelParser
william-gross Feb 26, 2025
b97c404
Backported ExcelParser improvements
william-gross Feb 26, 2025
143d464
Fixed tests
william-gross Feb 26, 2025
de6ca59
Formatted TabularDataParser
william-gross Feb 26, 2025
e85251f
Renamed ValidationResult to ValidationError
william-gross Mar 18, 2025
d7bbafa
Restructured underlying validation methods
william-gross Mar 19, 2025
a2cf592
Renamed validation method input parameters
william-gross Mar 19, 2025
73df1e3
Overhauled Validator.isEmpty to handle trimming
william-gross Mar 20, 2025
6edc544
Re-introduced ValidationMethod delegate
william-gross Mar 20, 2025
fa56464
Overhauled ExecuteValidation to return a result
william-gross Mar 20, 2025
2f148c2
Updated public validation methods to return result
william-gross Mar 20, 2025
b3947f7
Updated Validator usages elsewhere in TEWL
william-gross Mar 20, 2025
8764a02
Named usages of ExecuteValidation emptyValue param
william-gross Mar 20, 2025
1448204
Removed result storage from ValidationErrorHandler
william-gross Mar 20, 2025
68b95d3
Removed ValidationPackage
william-gross Mar 20, 2025
aeb765a
Removed ValidationError.NoError.
william-gross Mar 20, 2025
afe056f
Removed ErrorCondition.NoError.
william-gross Mar 20, 2025
d89ce21
Renamed ErrorCondition to ValidationErrorType
william-gross Mar 20, 2025
fa444f7
Formatted ValidationErrorHandler
william-gross Mar 20, 2025
6d24a08
Made ExecuteValidation handler parameter nullable
william-gross Mar 20, 2025
dbb5511
Made ValidationErrorHandler parameters nullable
william-gross Mar 20, 2025
fe70e4c
Documented ExecuteValidation handler parameter
william-gross Mar 20, 2025
4428eb1
Moved text validations to their own file
william-gross Mar 21, 2025
197cfab
Moved phone validations to their own file
william-gross Mar 21, 2025
2537e6f
Moved numeric validations to their own file
william-gross Mar 21, 2025
1625544
Moved boolean validations to their own file
william-gross Mar 21, 2025
f50778e
Moved ZIP code validations to their own file
william-gross Mar 21, 2025
195de52
Moved date/time validations to their own file
william-gross Mar 21, 2025
3167f84
Cleaned up Validator
william-gross Mar 21, 2025
c2f4cba
Converted Error class to a nested record
william-gross Mar 21, 2025
8987d50
Renamed GetNumber to GetNumericString
william-gross Mar 24, 2025
809172a
Disambiguated ValidationError classes
william-gross Mar 26, 2025
e2b28f1
Fixed doc typo in ValidationResult
william-gross Mar 26, 2025
49c823b
Reversed param order of LineProcessingMethod
william-gross May 31, 2025
e67a6c9
Added UTF-8 BOM in GetTextWriterForWrite
william-gross May 31, 2025
7962fe7
Added includeBom param to GetTextWriterForWrite
william-gross Jun 2, 2025
9ca2cec
Created DurationTools with ToSecondsPhrase method
william-gross Jul 1, 2025
437ff1b
Moved in RateLimiter from EWL
william-gross Jul 1, 2025
9291b3d
Created PatternString and deprecated IsLike
william-gross Jul 28, 2025
14212e3
Moved in TrustedHtmlString from EWL
william-gross Jul 28, 2025
61e048b
Trivial: Formatted ReflectionTools
william-gross Jul 29, 2025
3158bd8
Trivial: Formatted EnumTools
william-gross Jul 29, 2025
48a8a4b
Fixed nullability warnings in ReflectionTools
william-gross Jul 29, 2025
78f6017
Added type constraints to EnumTools methods
william-gross Jul 29, 2025
a08182e
Simplified EnumTools.ToEnglish
william-gross Jul 29, 2025
dfc8aac
Added IsDefined check in ToEnum
william-gross Jul 29, 2025
bff9ad7
Expanded LocalDateTools with new methods
william-gross Jul 30, 2025
9923025
Added LocalDateTools.WeekBeginDate
william-gross Jul 30, 2025
ee03ed7
Added LocalDateTools.ToMonthYearString
william-gross Jul 30, 2025
1d34af8
Added ExecuteWithMachineExclusiveAccess
william-gross Aug 26, 2025
c45b2f6
Merge branch 'validatorImprovements' into enduracode-main
william-gross Sep 19, 2025
fad2e43
Merge branch 'localDateTools' into enduracode-main
william-gross Sep 19, 2025
1856b0c
Merge branch 'getTextWriterForWrite-Utf8WithBom' into enduracode-main
william-gross Sep 19, 2025
195a35e
Merge branch 'durationTools' into enduracode-main
william-gross Sep 19, 2025
eb7fdb5
Merge branch 'rateLimiter' into enduracode-main
william-gross Sep 19, 2025
c30a206
Merge branch 'patternString' into enduracode-main
william-gross Sep 19, 2025
44ddfb7
Merge branch 'trustedHtmlString' into enduracode-main
william-gross Sep 19, 2025
6050a51
Merge branch 'enumToolsCleanup' into enduracode-main
william-gross Sep 19, 2025
1b10957
Merge branch 'synchronizationTools' into enduracode-main
william-gross Sep 19, 2025
b5c4c76
Moved ToNewUnderlyingValue in from EWL
william-gross Oct 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions EWL ReSharper Settings.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@
<s:String x:Key="/Default/Environment/InlayHints/VBParameterNameHintsOptions/ShowParameterNameHints/@EntryValue">Never</s:String>
<s:Boolean x:Key="/Default/Environment/ParameterNameHintsOptions/ShowParameterNameHints/@EntryValue">False</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SearchAndNavigation/MergeOccurences/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EPsiFeatures_002EVisualStudio_002EBackend_002EDaemon_002EHighlightingSettingsMigrator/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002ECSharp_002EParameterNameHints_002ECSharpParameterNameHintsOptionsMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002EVB_002EParameterNameHints_002EVBParameterNameHintsOptionsMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpAttributeForSingleLineMethodUpgrade/@EntryIndexedValue">True</s:Boolean>
Expand Down Expand Up @@ -1311,6 +1312,7 @@
<s:Boolean x:Key="/Default/Housekeeping/VsActionManager/KeyboardShortcutToVsCommand/=Up/Commands/=_003Cdata_0020id_003D_0022_0028_007B1496a755_002D94de_002D11d0_002D8c3f_002D00c04fc2aae2_007D_003A11_0029_0022_0020shortcut_003D_0022Up_0022_0020_002F_003E/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Housekeeping/VsActionManager/KeyboardShortcutToVsCommand/=Up/Commands/=_003Cdata_0020id_003D_0022_0028_007B1496a755_002D94de_002D11d0_002D8c3f_002D00c04fc2aae2_007D_003A1227_0029_0022_0020shortcut_003D_0022Up_0022_0020_002F_003E/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Housekeeping/VsActionManager/KeyboardShortcutToVsCommand/=Up/Commands/=_003Cdata_0020id_003D_0022_0028_007B1496a755_002D94de_002D11d0_002D8c3f_002D00c04fc2aae2_007D_003A1502_0029_0022_0020shortcut_003D_0022Up_0022_0020_002F_003E/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Housekeeping/VsHighlighting/SuppressVsSquiggles/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/Housekeeping/VsSavedAutocompletionValue/OverrideParameterInfo/=CSharp/@EntryIndexedValue">NotOverridden</s:String>
<s:String x:Key="/Default/Housekeeping/VsSavedAutocompletionValue/OverrideParameterInfo/=Css/@EntryIndexedValue">NotOverridden</s:String>
<s:String x:Key="/Default/Housekeeping/VsSavedAutocompletionValue/OverrideParameterInfo/=Html/@EntryIndexedValue">NotOverridden</s:String>
Expand Down
21 changes: 21 additions & 0 deletions Tewl/ICalendar/ICalendarTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Ical.Net.DataTypes;
using NodaTime;

namespace Tewl.ICalendar;

/// <summary>
/// Static methods pertaining to the iCalendar format.
/// </summary>
[ PublicAPI ]
public static class ICalendarTools {
/// <summary>
/// Creates an iCalendar date/time from this ZonedDateTime.
/// </summary>
public static CalDateTime ToICalendarTime( this ZonedDateTime time ) =>
new( time.Year, time.Month, time.Day, time.Hour, time.Minute, time.Second, time.Zone.Id );

/// <summary>
/// Creates an iCalendar date from this LocalDate.
/// </summary>
public static CalDateTime ToICalendarDate( this LocalDate date ) => new( date.Year, date.Month, date.Day );
}
25 changes: 10 additions & 15 deletions Tewl/IO/ExcelWorksheet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,31 +83,26 @@ private void addRowToWorksheet( bool bold, params string[] cellValues ) {
}

private void putRowValueInCell( IXLCell cell, string value ) {
var v = new Validator();
var detectedDate = v.GetNullableDateTime(
new ValidationErrorHandler( "" ),
value,
DateTimeTools.DayMonthYearFormats.Concat( DateTimeTools.MonthDayYearFormats ).ToArray(),
false,
DateTime.MinValue,
DateTime.MaxValue );
if( !v.ErrorsOccurred ) {
if( new Validator().GetNullableDateTime(
new ValidationErrorHandler( "" ),
value,
DateTimeTools.DayMonthYearFormats.Concat( DateTimeTools.MonthDayYearFormats ).ToArray(),
false,
DateTime.MinValue,
DateTime.MaxValue )
.Error( out var detectedDate ) is null ) {
setOrAddCellStyle( cell, date: true );
cell.Value = detectedDate;
return;
}

v = new Validator();
v.GetEmailAddress( new ValidationErrorHandler( "" ), value, false );
if( !v.ErrorsOccurred ) {
if( new Validator().GetEmailAddress( new ValidationErrorHandler( "" ), value, false ).Error( out _ ) is null ) {
cell.Value = value;
cell.SetHyperlink( new XLHyperlink( "mailto:" + value ) );
return;
}

v = new Validator();
var validatedUrl = v.GetUrl( new ValidationErrorHandler( "" ), value, false );
if( !v.ErrorsOccurred ) {
if( new Validator().GetUrl( new ValidationErrorHandler( "" ), value, false ).Error( out var validatedUrl ) is null ) {
cell.Value = value;
cell.SetHyperlink( new XLHyperlink( validatedUrl ) );
return;
Expand Down
16 changes: 10 additions & 6 deletions Tewl/IO/IoMethods.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net;
using System.Text;
using System.Threading;

namespace Tewl.IO;
Expand Down Expand Up @@ -146,13 +147,16 @@ public static long GetFolderSize( string path ) =>
File.Exists( path ) ? new FileInfo( path ).Length : Directory.GetFileSystemEntries( path ).Sum( filePath => GetFolderSize( filePath ) );

/// <summary>
/// Returns a text writer for writing a new file or overwriting an existing file.
/// Automatically creates any folders needed in the given path, if necessary.
/// We recommend passing an absolute path. If a relative path is passed, the working folder
/// is used as the root path.
/// Caller is responsible for properly disposing the stream.
/// Returns a text writer for writing a new file or overwriting an existing file, using UTF-8 encoding. Automatically creates any folders needed in the given
/// path, if necessary. We recommend passing an absolute path. If a relative path is passed, the working folder is used as the root path. Caller is
/// responsible for properly disposing the stream.
/// </summary>
public static TextWriter GetTextWriterForWrite( string filePath ) => new StreamWriter( GetFileStreamForWrite( filePath ) );
/// <param name="filePath"></param>
/// <param name="includeBom">Pass true to include a byte-order mark (BOM) indicating that the file is encoded with UTF-8. This helps avoid misinterpreted
/// characters when reading the file, especially if it is plain text. Pass false for XML files or any other format that has its own encoding declaration.
/// </param>
public static TextWriter GetTextWriterForWrite( string filePath, bool includeBom ) =>
new StreamWriter( GetFileStreamForWrite( filePath ), new UTF8Encoding( includeBom, true ) );

/// <summary>
/// Returns a file stream for writing a new file or overwriting an existing file.
Expand Down
162 changes: 67 additions & 95 deletions Tewl/IO/TabularDataParsing/CsvLineParser.cs
Original file line number Diff line number Diff line change
@@ -1,116 +1,88 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using JetBrains.Annotations;
using Tewl.Tools;

namespace Tewl.IO.TabularDataParsing {
namespace Tewl.IO.TabularDataParsing;

/// <summary>
/// Parses a line of a Microsoft Excel CSV file using the definition of CSV at
/// http://en.wikipedia.org/wiki/Comma-separated_values.
/// </summary>
[ PublicAPI ]
internal class CsvLineParser: TextBasedTabularDataParser {
/// <summary>
/// Parses a line of a Microsoft Excel CSV file using the definition of CSV at
/// http://en.wikipedia.org/wiki/Comma-separated_values.
/// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
/// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
/// </summary>
[PublicAPI]
internal class CsvLineParser: TextBasedTabularDataParser {
private readonly Dictionary<string, int> columnHeadersToIndexes = new Dictionary<string, int>();

/// <summary>
/// Creates a line parser with no header row. Fields will be access via indexes rather than by column name.
/// </summary>
public CsvLineParser() { }

/// <summary>
/// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
/// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
/// </summary>
public static TabularDataParser CreateWithFilePath( string filePath, bool hasHeaderRow ) {
return new CsvLineParser { fileReader = new FileReader( filePath ), hasHeaderRow = hasHeaderRow };
}

/// <summary>
/// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
/// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
/// </summary>
public static TabularDataParser CreateWithStream( Stream stream, bool hasHeaderRow ) {
return new CsvLineParser { fileReader = new FileReader( stream ), hasHeaderRow = hasHeaderRow };
}

/// <summary>
/// Creates a line parser with a header row. The column names are extracted from the header row, and
/// parsed CsvLines will allow field access through column name or column index.
/// </summary>
public CsvLineParser( string headerLine ) {
var index = 0;
foreach( var columnHeader in ( Parse( headerLine ) as TextBasedParsedLine ).Fields ) {
columnHeadersToIndexes[ columnHeader.ToLower() ] = index;
index++;
}
}
public CsvLineParser( string filePath ) {
fileReader = new FileReader( filePath );
}

/// <summary>
/// Parses a line of a Microsoft Excel CSV file and returns a collection of string fields.
/// Internal use only.
/// Use ParseAndProcessAllLines instead.
/// </summary>
internal override TextBasedParsedLine Parse( string line ) {
var fields = new List<string>();
if( !line.IsNullOrWhiteSpace() ) {
using( TextReader tr = new StringReader( line ) )
parseCommaSeparatedFields( tr, fields );
}
var parsedLine = new TextBasedParsedLine( fields );
parsedLine.ColumnHeadersToIndexes = columnHeadersToIndexes;
return parsedLine;
}
/// <summary>
/// Creates a parser designed to parse a CSV file. Passing true for hasHeaderRow will result in the first row being used to map
/// header names to column indices. This will allow you to access fields using the header name in addition to the column index.
/// </summary>
public CsvLineParser( Stream stream ) {
fileReader = new FileReader( stream );
}

private static void parseCommaSeparatedFields( TextReader tr, List<string> fields ) {
parseCommaSeparatedField( tr, fields );
while( tr.Peek() == ',' ) {
tr.Read();
/// <summary>
/// Parses a line of a Microsoft Excel CSV file and returns a collection of string fields.
/// </summary>
protected override IReadOnlyList<string> parseLine( string? line ) {
var fields = new List<string>();
if( !line.IsNullOrWhiteSpace() )
using( TextReader tr = new StringReader( line ) )
parseCommaSeparatedFields( tr, fields );
}
}
return fields;
}

private static void parseCommaSeparatedField( TextReader tr, List<string> fields ) {
if( tr.Peek() != -1 ) {
string field;
if( tr.Peek() != '"' )
field = parseSimpleField( tr );
else
field = parseQuotedField( tr );
fields.Add( field.Trim() );
}
private void parseCommaSeparatedFields( TextReader tr, List<string> fields ) {
parseCommaSeparatedField( tr, fields );
while( tr.Peek() == ',' ) {
tr.Read();
parseCommaSeparatedFields( tr, fields );
}
}

private static string parseSimpleField( TextReader tr ) {
var sb = new StringBuilder();
private void parseCommaSeparatedField( TextReader tr, List<string> fields ) {
if( tr.Peek() != -1 ) {
string field;
if( tr.Peek() != '"' )
field = parseSimpleField( tr );
else
field = parseQuotedField( tr );
fields.Add( field.Trim() );
}
}

var ch = tr.Peek();
while( ch != -1 && ch != ',' ) {
sb.Append( (char)tr.Read() );
ch = tr.Peek();
}
private string parseSimpleField( TextReader tr ) {
var sb = new StringBuilder();

return sb.ToString();
var ch = tr.Peek();
while( ch != -1 && ch != ',' ) {
sb.Append( (char)tr.Read() );
ch = tr.Peek();
}

private static string parseQuotedField( TextReader tr ) {
var sb = new StringBuilder();
return sb.ToString();
}

// Skip the opening quote
tr.Read();
private string parseQuotedField( TextReader tr ) {
var sb = new StringBuilder();

var ch = tr.Read();
// Continue until the end of the file or until we reach an unescaped quote.
while( ch != -1 && !( ch == '"' && tr.Peek() != '"' ) ) {
// If we encounter an escaped double quote, skip one of the double quotes.
if( ch == '"' && tr.Peek() == '"' )
tr.Read();
// Skip the opening quote
tr.Read();

sb.Append( (char)ch );
ch = tr.Read();
}
var ch = tr.Read();
// Continue until the end of the file or until we reach an unescaped quote.
while( ch != -1 && !( ch == '"' && tr.Peek() != '"' ) ) {
// If we encounter an escaped double quote, skip one of the double quotes.
if( ch == '"' && tr.Peek() == '"' )
tr.Read();

return sb.ToString();
sb.Append( (char)ch );
ch = tr.Read();
}

return sb.ToString();
}
}
32 changes: 32 additions & 0 deletions Tewl/IO/TabularDataParsing/DataValidationError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Tewl.IO.TabularDataParsing;

/// <summary>
/// Holds information about a validation error generated as a result of processing a parsed line.
/// </summary>
[ PublicAPI ]
public class DataValidationError {
/// <summary>
/// An explanation of the place in the original data that caused the error. For example, "Line 32".
/// </summary>
public string ErrorSource { get; set; }

/// <summary>
/// True if this is a fatal error.
/// </summary>
public bool IsFatal { get; }

/// <summary>
/// The error message.
/// </summary>
public string ErrorMessage { get; }

/// <summary>
/// Creates a validation error that occurred when processing the given line number.
/// Error source is an explanation of the place in the original data that caused the error. For example, "Line 32".
/// </summary>
public DataValidationError( string errorSource, bool isFatal, string errorMessage ) {
ErrorSource = errorSource;
ErrorMessage = errorMessage;
IsFatal = isFatal;
}
}
40 changes: 22 additions & 18 deletions Tewl/IO/TabularDataParsing/ExcelParsedLine.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
using System.Collections.Generic;
using ClosedXML.Excel;
using ClosedXML.Excel;

namespace Tewl.IO.TabularDataParsing {
internal class ExcelParsedLine: ParsedLine {
private readonly List<string> headerFields;
private readonly IXLRangeRow row;
namespace Tewl.IO.TabularDataParsing;

public ExcelParsedLine( List<string> headerFields, IXLRangeRow row ) {
this.headerFields = headerFields;
this.row = row;
}
internal class ExcelParsedLine: ParsedLine {
private readonly IReadOnlyDictionary<string, int> columnIndicesByName;
private readonly IXLRangeRow row;

public bool ContainsData => !row.IsEmpty();
public ExcelParsedLine( IReadOnlyDictionary<string, int> columnIndicesByName, IXLRangeRow row ) {
this.columnIndicesByName = columnIndicesByName;
this.row = row;
}

public int LineNumber => row.RowNumber();
bool ParsedLine.ContainsData => !row.IsEmpty();

// Index is zero-based. The Cell() function on the IXLRangeRow is 1-based.
public string this[ int index ] => row.Cell( index + 1 ).Value.ToString();
int ParsedLine.LineNumber => row.RowNumber();

// Index is zero-based. The Cell() function on the IXLRangeRow is 1-based.
public string this[ string columnName ] => row.Cell( headerFields.IndexOf( columnName.ToLower() ) + 1 ).Value.ToString();
public string this[ int index ] =>
// Index is zero-based. The Cell() function on the IXLRangeRow is 1-based.
row.Cell( index + 1 ).Value.ToString();

public bool ContainsField( string fieldName ) => headerFields.Contains( fieldName );
}

string ParsedLine.this[ string columnName ] =>
columnIndicesByName.TryGetValue( columnName, out var index )
? this[ index ]
: throw new ArgumentException(
$"Column “{columnName}” does not exist. The columns are {StringTools.GetEnglishListPhrase( columnIndicesByName.Keys.Select( i => $"“{i}”" ), true )}." );

bool ParsedLine.ContainsField( string fieldName ) => columnIndicesByName.ContainsKey( fieldName );
}
Loading