diff --git a/docs/unfinished.md b/docs/unfinished.md index ed990d6..5b20d66 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -109,9 +109,17 @@ with the CW and SSB running of the contest. which takes the value from the call history file. `BonusPoints2`, which reads the bonus callsigns from a file. `MultiplierBands`, `MultWindowType` and `QsoErrorString`, which are about the windows rather than the score. -`CabrilloString`, `CabrilloFormat` and `GenericPrintString`, so a contest whose -Cabrillo line is not callsign, report and one exchange value comes out in the -default shape. The session, off-time and band-change settings +`GenericPrintString`, which is the layout of a printed log rather than a +Cabrillo one. + +`CabrilloString` and `CabrilloVersion` are read: a contest whose sponsor asks +for its own columns gets them, in N1MM's shape — the operator's callsign in the +first 13 columns, then every column padded to the width the file gives with one +space after it, and a value longer than its column cut rather than pushed. The +numbered `CabrilloFormat` layouts are not read: 2, 3, 4 and 5 name the NAQP, +NA Sprint, Sweepstakes and RFC lines, and a file asking for one of those gets +the default line instead. `CabrilloFormat = 0` means the sponsor takes no +Cabrillo log at all, and nothing here stops the operator writing one. The session, off-time and band-change settings (`MultipleSessions`, `MinimumOffTime`, the `…BandChange…` family, `DupeQSOMinutesAgo`) are read by nothing, so a contest with periods is logged as one long session. diff --git a/src/Nonemm.Contests/CabrilloExchange.cs b/src/Nonemm.Contests/CabrilloExchange.cs index 3200a70..fde87ee 100644 --- a/src/Nonemm.Contests/CabrilloExchange.cs +++ b/src/Nonemm.Contests/CabrilloExchange.cs @@ -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 Sent, - IReadOnlyList Received); + IReadOnlyList 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; +} diff --git a/src/Nonemm.Contests/Contest.cs b/src/Nonemm.Contests/Contest.cs index 883c0de..0ee909a 100644 --- a/src/Nonemm.Contests/Contest.cs +++ b/src/Nonemm.Contests/Contest.cs @@ -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 ExchangeFieldsFor(StationInfo me); diff --git a/src/Nonemm.Contests/Udc/UdcCabrillo.cs b/src/Nonemm.Contests/Udc/UdcCabrillo.cs new file mode 100644 index 0000000..31b8045 --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcCabrillo.cs @@ -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 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] : ""; + } +} diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index 1e991b9..e0e9ffc 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -14,6 +14,7 @@ public sealed class UserDefinedContest : Contest private readonly IReadOnlyList multipliers; private readonly IReadOnlyList exchangeFields; private readonly IReadOnlyList 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 ExchangeFieldsFor(StationInfo me) => exchangeFields; public IReadOnlyList 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 diff --git a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs index 3e3708f..4525971 100644 --- a/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs +++ b/src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs @@ -23,7 +23,7 @@ public sealed class CabrilloWriter public string Write(CabrilloHeader header, IEnumerable 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 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(' '); } } diff --git a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs index 00e8e64..500fccf 100644 --- a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs +++ b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs @@ -1,4 +1,6 @@ +using Nonemm.Contests; using Nonemm.Contests.Rules; +using Nonemm.Contests.Udc; using Nonemm.Core; using Nonemm.Formats.Cabrillo; @@ -80,4 +82,56 @@ public class CabrilloWriterTests [Contact() with { IsClaimed = false }]); Assert.DoesNotContain("QSO:", log); } + + /// The SA 10 file, whose sponsor asks for its own columns: the operator's + /// call in the first 13, then every column padded to the width the file + /// gives, with one space after it. + [Fact] + public void AUserDefinedContestCanLayOutItsOwnLine() + { + UserDefinedContest contest = new(UdcFile.Parse(""" + [Contest] + Name=SA10_DX + CabrilloName=SA10-DX + CabrilloFormat=99 + CabrilloString=SNT, 4, SentExch, 4, CallSign, 13, RCV, 4, Exchange1, 4 + """)); + CabrilloWriter writer = new(contest, Me, new ContestEntry { SentExchange = "14" }); + + string line = writer.QsoLine(Contact() with { Exchange1 = "25" }); + + Assert.Equal( + "QSO: 14025 CW 2026-05-30 1234 DL1ABC 599 14 JA1XYZ 599 25 ", + line); + } + + /// A value longer than its column is cut rather than pushing the columns + /// after it out of line, which is what N1MM writes. + [Fact] + public void AValueTooLongForItsColumnIsCut() + { + UserDefinedContest contest = new(UdcFile.Parse(""" + [Contest] + Name=TIGHT + CabrilloString=Exchange1, 3 + """)); + CabrilloWriter writer = new(contest, Me); + + Assert.EndsWith("ABC ", writer.QsoLine(Contact() with { Exchange1 = "ABCDEF" })); + } + + [Fact] + public void TheCabrilloVersionComesFromTheContest() + { + UserDefinedContest contest = new(UdcFile.Parse(""" + [Contest] + Name=OLD + CabrilloVersion=2.0 + """)); + CabrilloWriter writer = new(contest, Me); + + Assert.StartsWith( + "START-OF-LOG: 2.0", + writer.Write(new CabrilloHeader { Contest = "OLD", Callsign = "DL1ABC" }, [])); + } }