Lay out a Cabrillo line the way a .udc file asks for it
`CabrilloString` names a field and a column width in turn. The line it makes follows N1MM: the operator's callsign in the first 13 columns, then every column padded to its width with one space after it, and a value longer than its column cut rather than pushed sideways. The cut is new for every contest, not just the user-defined ones — the columns are the sponsor's, not ours. `CabrilloVersion` is read too, so a contest whose sponsor still asks for 2.0 gets it in the header. A contest that lays out its own line can put the transmitter column where it wants it, or leave it out, so the writer no longer adds one at the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -9,4 +9,10 @@ public sealed record CabrilloField(string Value, int Width);
|
||||
/// goes: most put it first, Sweepstakes puts it last.
|
||||
public sealed record CabrilloExchange(
|
||||
IReadOnlyList<CabrilloField> Sent,
|
||||
IReadOnlyList<CabrilloField> Received);
|
||||
IReadOnlyList<CabrilloField> Received)
|
||||
{
|
||||
/// Whether the writer adds the transmitter column at the end of the line.
|
||||
/// A user-defined contest that lays out its own line can put that column
|
||||
/// where it wants it, or leave it out.
|
||||
public bool AddsTransmitter { get; init; } = true;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ public interface Contest
|
||||
/// The name the sponsor's Cabrillo header asks for.
|
||||
string CabrilloName { get; }
|
||||
|
||||
/// The Cabrillo version the sponsor accepts. Sponsors have taken 3.0 for
|
||||
/// years; a few user-defined contests still ask for 2.0.
|
||||
string CabrilloVersion => "3.0";
|
||||
|
||||
/// The boxes the entry window shows. ARRL DX and others ask for a
|
||||
/// different exchange depending on where the operator is.
|
||||
IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me);
|
||||
|
||||
80
src/Nonemm.Contests/Udc/UdcCabrillo.cs
Normal file
80
src/Nonemm.Contests/Udc/UdcCabrillo.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Udc;
|
||||
|
||||
/// The `CabrilloString` setting: a field name and a column width in turn,
|
||||
/// which together say what one QSO line holds after the date and time. N1MM
|
||||
/// puts the operator's own callsign in the first 13 columns before anything
|
||||
/// the setting names.
|
||||
///
|
||||
/// A name this does not know contributes an empty column of the width given,
|
||||
/// so the columns after it stay where the sponsor expects them.
|
||||
public sealed class UdcCabrillo
|
||||
{
|
||||
private readonly List<(string Field, int Width)> columns = [];
|
||||
|
||||
public UdcCabrillo(string setting)
|
||||
{
|
||||
string[] parts = setting.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
for (int at = 0; at + 1 < parts.Length; at += 2)
|
||||
{
|
||||
if (int.TryParse(parts[at + 1].Trim(), out int width))
|
||||
{
|
||||
columns.Add((parts[at].Trim(), width));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDefined => columns.Count > 0;
|
||||
|
||||
/// True when the contact's transmitter number is one of the columns, so the
|
||||
/// writer does not add it a second time.
|
||||
public bool HasTransmitter =>
|
||||
columns.Any(c => c.Field.Equals("Run1Run2", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public IReadOnlyList<CabrilloField> Fields(Qso qso, StationInfo me, ContestEntry entry) =>
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
.. columns.Select(c => new CabrilloField(Value(c.Field, qso, entry), c.Width)),
|
||||
];
|
||||
|
||||
private static string Value(string field, Qso qso, ContestEntry entry) =>
|
||||
field.ToUpperInvariant() switch
|
||||
{
|
||||
"SNT" => qso.SentReport,
|
||||
"RCV" => qso.ReceivedReport,
|
||||
"SENTNR" => $"{qso.SentNumber:000}",
|
||||
"RCVNR" => $"{qso.ReceivedNumber:000}",
|
||||
"CALLSIGN" => qso.Call.Text,
|
||||
"NAME" => qso.Name,
|
||||
"COMMENT" => qso.Comment,
|
||||
"EXCHANGE1" => qso.Exchange1,
|
||||
"SECT" => qso.Section,
|
||||
"MISC" or "MISCTEXT" => qso.MiscText,
|
||||
"GRIDSQUARE" => qso.GridSquare,
|
||||
"POINTS" => qso.Points.ToString(),
|
||||
"MULTIPLIER1" => Flag(qso.IsMultiplier1),
|
||||
"MULTIPLIER2" => Flag(qso.IsMultiplier2),
|
||||
"MULTIPLIER3" => Flag(qso.IsMultiplier3),
|
||||
"SENTEXCH" => entry.SentExchange,
|
||||
"SENTEXCHPART1" => Part(entry.SentExchange, 0),
|
||||
"SENTEXCHPART2" => Part(entry.SentExchange, 1),
|
||||
"SENTEXCHPART3" => Part(entry.SentExchange, 2),
|
||||
"FREQ" => qso.Frequency.Kilohertz.ToString("0"),
|
||||
"MODE" => qso.Mode.CabrilloCode,
|
||||
"TIMESTAMP" => qso.TimestampUtc.ToString("HHmm"),
|
||||
"RUN1RUN2" => qso.RadioNumber > 1 ? "1" : "0",
|
||||
"CONTACTNETWORKEDCOMPNR" => qso.NetworkedComputerNumber.ToString(),
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string Flag(bool set) => set ? "1" : "0";
|
||||
|
||||
/// The sent exchange in parts, as the operator typed it: `599 14 BRA` is
|
||||
/// three of them.
|
||||
private static string Part(string sentExchange, int index)
|
||||
{
|
||||
string[] parts = sentExchange.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length > index ? parts[index] : "";
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public sealed class UserDefinedContest : Contest
|
||||
private readonly IReadOnlyList<UdcMultiplier> multipliers;
|
||||
private readonly IReadOnlyList<ExchangeField> exchangeFields;
|
||||
private readonly IReadOnlyList<string> workableStations;
|
||||
private readonly UdcCabrillo cabrillo;
|
||||
|
||||
public UserDefinedContest(UdcFile file)
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public sealed class UserDefinedContest : Contest
|
||||
multipliers = ReadMultipliers(file);
|
||||
exchangeFields = ReadExchangeFields(file);
|
||||
workableStations = file.List("IsWorkable");
|
||||
cabrillo = new UdcCabrillo(file.Text("CabrilloString"));
|
||||
}
|
||||
|
||||
public static UserDefinedContest Load(string path) =>
|
||||
@@ -35,6 +37,8 @@ public sealed class UserDefinedContest : Contest
|
||||
|
||||
public string CabrilloName => file.Text("CabrilloName", Name);
|
||||
|
||||
public string CabrilloVersion => file.Text("CabrilloVersion", "3.0");
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFieldsFor(StationInfo me) => exchangeFields;
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames =>
|
||||
@@ -115,18 +119,26 @@ public sealed class UserDefinedContest : Contest
|
||||
return (int)Math.Round(total * pointsMultiplier.ForEntry(entry));
|
||||
}
|
||||
|
||||
/// `CabrilloString` lays out the whole line, which is how a contest whose
|
||||
/// sponsor asks for its own columns is written. Without it the line is the
|
||||
/// usual one: each station, its report and its exchange.
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) =>
|
||||
new(
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField(SentExchangePart(qso), 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(ReceivedExchangePart(qso), 6),
|
||||
]);
|
||||
cabrillo.IsDefined
|
||||
? new CabrilloExchange(cabrillo.Fields(qso, me, entry), [])
|
||||
{
|
||||
AddsTransmitter = cabrillo.HasTransmitter,
|
||||
}
|
||||
: new CabrilloExchange(
|
||||
[
|
||||
new CabrilloField(me.Callsign, 13),
|
||||
new CabrilloField(qso.SentReport, 3),
|
||||
new CabrilloField(SentExchangePart(qso), 6),
|
||||
],
|
||||
[
|
||||
new CabrilloField(qso.Call.Text, 13),
|
||||
new CabrilloField(qso.ReceivedReport, 3),
|
||||
new CabrilloField(ReceivedExchangePart(qso), 6),
|
||||
]);
|
||||
|
||||
/// `IsWorkable` names the stations the contest counts: a continent, my own
|
||||
/// country, everything but my own country, or a list of country prefixes. A
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class CabrilloWriter
|
||||
public string Write(CabrilloHeader header, IEnumerable<Qso> qsos)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
text.Append("START-OF-LOG: 3.0\r\n");
|
||||
text.Append("START-OF-LOG: ").Append(contest.CabrilloVersion).Append("\r\n");
|
||||
Line(text, "CONTEST", header.Contest);
|
||||
Line(text, "CALLSIGN", header.Callsign);
|
||||
Line(text, "CATEGORY-OPERATOR", header.OperatorCategory);
|
||||
@@ -72,18 +72,23 @@ public sealed class CabrilloWriter
|
||||
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
|
||||
Append(line, exchange.Sent);
|
||||
Append(line, exchange.Received);
|
||||
if (contest.IsContact(qso))
|
||||
if (contest.IsContact(qso) && exchange.AddsTransmitter)
|
||||
{
|
||||
line.Append(qso.RadioNumber > 1 ? '1' : '0');
|
||||
}
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
/// Every column is the width the sponsor asks for and one space, and a
|
||||
/// value longer than its column is cut, which is what N1MM writes.
|
||||
private static void Append(StringBuilder line, IReadOnlyList<CabrilloField> fields)
|
||||
{
|
||||
foreach (CabrilloField field in fields)
|
||||
{
|
||||
line.Append(field.Value.PadRight(field.Width)).Append(' ');
|
||||
string value = field.Value.Length > field.Width
|
||||
? field.Value[..field.Width]
|
||||
: field.Value.PadRight(field.Width);
|
||||
line.Append(value).Append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user