From cf5bdd60827e0e2f93e2d4cd17535f2f345e4031 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Mon, 31 Aug 2026 10:38:39 +0000 Subject: [PATCH] Lay out a Cabrillo line the way a .udc file asks for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- docs/unfinished.md | 14 +++- src/Nonemm.Contests/CabrilloExchange.cs | 8 +- src/Nonemm.Contests/Contest.cs | 4 + src/Nonemm.Contests/Udc/UdcCabrillo.cs | 80 +++++++++++++++++++ src/Nonemm.Contests/Udc/UserDefinedContest.cs | 34 +++++--- src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs | 11 ++- .../CabrilloWriterTests.cs | 54 +++++++++++++ 7 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 src/Nonemm.Contests/Udc/UdcCabrillo.cs 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" }, [])); + } }