Carry out the call history section directives

A call history file is built from years of logs, so its section and
state columns carry old names, and the file says at the top what to do
about that. The four lines that say it were read and passed over.

All four are now carried out, in N1MM's order: !!MapOnSection!! stores
the old Ontario sections as ON, !!ValidateArrlSection!! drops a section
that is not one of N1MM's, !!GTA2GH_NT2TER!! stores GTA as GH and NT as
TER, and !!Validate50State!! keeps a state, turns a section into the
state it is in, or drops the value.

The section list is the one N1MM's call history reader validates
against, which is not the list its entry window offers: it holds the
retired GTA and NT, and leaves out ON. It sits beside the reader in
Nonemm.Core rather than with the contest lists, which are the entry
window's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 14:21:02 +00:00
parent 2eeda451c3
commit 76b57cee1d
5 changed files with 271 additions and 55 deletions

View File

@@ -39,11 +39,7 @@ public sealed class CallHistory
public static CallHistory Parse(string text)
{
Dictionary<string, CallHistoryEntry> found = new(StringComparer.Ordinal);
string[] order = DefaultOrder;
bool fourCharacterGrid = false;
bool useAlternateGrid = true;
bool mapStateToSection = false;
bool appendUserText = true;
Directives said = new();
foreach (string line in text.Split('\n'))
{
@@ -56,40 +52,81 @@ public sealed class CallHistory
string first = fields[0].Trim().ToUpperInvariant();
if (first.StartsWith("!!", StringComparison.Ordinal))
{
switch (first)
{
case "!!ORDER!!":
order = [.. fields.Skip(1).Select(f => f.Trim().ToUpperInvariant())];
break;
case "!!FOURCHARGRIDSQ!!":
fourCharacterGrid = true;
break;
case "!!NOLOC2ALTGRID!!":
useAlternateGrid = false;
break;
case "!!MAPSTATETOSECT!!":
mapStateToSection = true;
break;
case "!!APPENDUSERTEXT!!":
appendUserText = true;
break;
case "!!NOAPPENDUSERTEXT!!":
appendUserText = false;
break;
}
// the rest are validation directives, and validating a section
// list we do not hold would throw data away
said.Read(first, fields);
continue;
}
CallHistoryEntry? entry = Read(fields, order, fourCharacterGrid, useAlternateGrid, mapStateToSection);
CallHistoryEntry? entry = Read(fields, said);
if (entry is not null)
{
found[entry.Call] = Merge(found.GetValueOrDefault(entry.Call), entry, appendUserText);
found[entry.Call] = Merge(
found.GetValueOrDefault(entry.Call),
entry,
said.AppendUserText);
}
}
return new CallHistory(found);
}
/// The `!!…!!` lines at the top of a file, which say how to read the rest
/// of it. A line the format does not define is passed over.
private sealed class Directives
{
public string[] Order { get; private set; } = DefaultOrder;
public bool FourCharacterGrid { get; private set; }
public bool UseAlternateGrid { get; private set; } = true;
public bool MapStateToSection { get; private set; }
public bool AppendUserText { get; private set; } = true;
public bool ValidateArrlSection { get; private set; }
public bool MapOnSection { get; private set; }
public bool RenameGtaAndNt { get; private set; }
public bool ValidateFiftyState { get; private set; }
public void Read(string directive, string[] fields)
{
switch (directive)
{
case "!!ORDER!!":
Order = [.. fields.Skip(1).Select(f => f.Trim().ToUpperInvariant())];
break;
case "!!FOURCHARGRIDSQ!!":
FourCharacterGrid = true;
break;
case "!!NOLOC2ALTGRID!!":
UseAlternateGrid = false;
break;
case "!!MAPSTATETOSECT!!":
MapStateToSection = true;
break;
case "!!APPENDUSERTEXT!!":
AppendUserText = true;
break;
case "!!NOAPPENDUSERTEXT!!":
AppendUserText = false;
break;
case "!!VALIDATEARRLSECTION!!":
ValidateArrlSection = true;
break;
case "!!MAPONSECTION!!":
MapOnSection = true;
break;
case "!!GTA2GH_NT2TER!!":
RenameGtaAndNt = true;
break;
case "!!VALIDATE50STATE!!":
ValidateFiftyState = true;
break;
}
}
}
/// A call can appear more than once. Later lines win, except that user text
/// is added to rather than replaced unless the file says otherwise.
private static CallHistoryEntry Merge(CallHistoryEntry? earlier, CallHistoryEntry later, bool append)
@@ -105,29 +142,25 @@ public sealed class CallHistory
return later with { UserText = $"{earlier.UserText} {later.UserText}" };
}
private static CallHistoryEntry? Read(
string[] fields,
string[] order,
bool fourCharacterGrid,
bool useAlternateGrid,
bool mapStateToSection)
private static CallHistoryEntry? Read(string[] fields, Directives said)
{
string[] order = said.Order;
string call = At(fields, order, "CALL").ToUpperInvariant();
if (call.Length == 0)
{
return null;
}
string section = At(fields, order, "SECT");
string state = At(fields, order, "STATE");
string section = Section(At(fields, order, "SECT"), said);
string state = State(At(fields, order, "STATE"), said);
return new CallHistoryEntry
{
Call = call,
Name = At(fields, order, "NAME"),
GridSquare = Grid(At(fields, order, "LOC1"), fourCharacterGrid),
AlternateGridSquare = useAlternateGrid
? Grid(At(fields, order, "LOC2"), fourCharacterGrid)
GridSquare = Grid(At(fields, order, "LOC1"), said.FourCharacterGrid),
AlternateGridSquare = said.UseAlternateGrid
? Grid(At(fields, order, "LOC2"), said.FourCharacterGrid)
: "",
Section = section.Length == 0 && mapStateToSection ? state : section,
Section = section.Length == 0 && said.MapStateToSection ? state : section,
State = state,
Check = Number(At(fields, order, "CK")),
Exchange1 = At(fields, order, "EXCH1"),
@@ -139,6 +172,48 @@ public sealed class CallHistory
};
}
/// The section as the file's directives ask for it. The three run in
/// N1MM's order, so a file asking for both `!!MapOnSection!!` and
/// `!!ValidateArrlSection!!` ends up with no section at all: the first
/// makes `ON`, and `ON` is not on the list the second checks against.
private static string Section(string section, Directives said)
{
if (section.Length == 0)
{
return section;
}
string name = section.ToUpperInvariant();
if (said.MapOnSection && name is "GTA" or "GH" or "ONE" or "ONN" or "ONS")
{
name = "ON";
}
if (said.ValidateArrlSection && !SectionNames.IsArrlSection(name))
{
return "";
}
if (said.RenameGtaAndNt)
{
name = name switch { "GTA" => "GH", "NT" => "TER", _ => name };
}
return name;
}
/// `!!Validate50State!!` keeps a value that is one of the 50 states, turns
/// a section into the state it is in, and drops anything else.
private static string State(string state, Directives said)
{
if (state.Length == 0 || !said.ValidateFiftyState)
{
return state;
}
if (SectionNames.IsFiftyState(state))
{
return state.ToUpperInvariant();
}
string fromSection = SectionNames.StateFor(state);
return SectionNames.IsFiftyState(fromSection) ? fromSection : "";
}
/// N1MM takes semicolons in preference to commas, so a file whose user text
/// holds commas can still be read.
private static string[] Split(string line) =>

View File

@@ -0,0 +1,70 @@
namespace Nonemm.Core.Calls;
/// The section and state names a call history file is cleaned up against.
///
/// The section list is N1MM's `IsArrlSection`, which is not the same as the
/// list its entry window offers: it holds the retired `GTA` and `NT` and the
/// odd `MD`, `HI` and `MAR` as well, so a file carrying an old name is fixed
/// rather than thrown away. It leaves out `ON`, which is why
/// `!!ValidateArrlSection!!` removes a section a `!!MapOnSection!!` line has
/// just made.
public static class SectionNames
{
private static readonly IReadOnlySet<string> Sections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"AB", "AK", "AL", "AR", "AZ", "BC", "CO", "CT", "DE", "EB", "EMA",
"ENY", "EPA", "EWA", "GA", "GH", "GTA", "HI", "IA", "ID", "IL", "IN",
"KS", "KY", "LA", "LAX", "MAR", "MB", "MD", "MDC", "ME", "MI", "MN",
"MO", "MS", "MT", "NB", "NC", "ND", "NE", "NFL", "NH", "NL", "NLI",
"NM", "NNJ", "NNY", "NS", "NT", "NTX", "NV", "OH", "OK", "ONE", "ONN",
"ONS", "OR", "ORG", "PAC", "PE", "PR", "QC", "RI", "SB", "SC", "SCV",
"SD", "SDG", "SF", "SFL", "SJV", "SK", "SNJ", "STX", "SV", "TER", "TN",
"UT", "VA", "VI", "VT", "WCF", "WI", "WMA", "WNY", "WPA", "WTX", "WV",
"WWA", "WY",
};
private static readonly IReadOnlySet<string> FiftyStates = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"AK", "AL", "AR", "AZ", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "IA",
"ID", "IL", "IN", "KS", "KY", "LA", "MA", "MD", "ME", "MI", "MN", "MO",
"MS", "MT", "NC", "ND", "NE", "NH", "NJ", "NM", "NV", "NY", "OH", "OK",
"OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VA", "VT", "WA", "WI",
"WV", "WY",
};
/// The state each section that splits one belongs to. A section that is a
/// state on its own is not here, and neither is a Canadian one.
private static readonly IReadOnlyDictionary<string, string> StatesBySection =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["EMA"] = "MA", ["WMA"] = "MA",
["SNJ"] = "NJ", ["NNJ"] = "NJ",
["EWA"] = "WA", ["WWA"] = "WA",
["EB"] = "CA", ["LAX"] = "CA", ["ORG"] = "CA", ["SB"] = "CA",
["SCV"] = "CA", ["SDG"] = "CA", ["SF"] = "CA", ["SJV"] = "CA",
["SV"] = "CA",
["NTX"] = "TX", ["STX"] = "TX", ["WTX"] = "TX",
["NFL"] = "FL", ["SFL"] = "FL", ["WCF"] = "FL",
["ENY"] = "NY", ["NLI"] = "NY", ["NNY"] = "NY", ["WNY"] = "NY",
["EPA"] = "PA", ["WPA"] = "PA",
["MDC"] = "MD",
["PAC"] = "HI",
};
public static bool IsArrlSection(string section) => Sections.Contains(section.Trim());
public static bool IsFiftyState(string state) => FiftyStates.Contains(state.Trim());
/// The state a section is in. A name that is not a section of a state comes
/// back as it went in, which is what N1MM's `StateForSection` returns, and
/// `DX` comes back empty.
public static string StateFor(string section)
{
string wanted = section.Trim();
if (wanted.Equals("DX", StringComparison.OrdinalIgnoreCase))
{
return "";
}
return StatesBySection.TryGetValue(wanted, out string? state) ? state : wanted;
}
}