From 0d9b4a713ee45cd208cd7d8d1d565497107ad12d Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 10 Sep 2026 19:31:04 +0000 Subject: [PATCH] Export WAE QTC traffic to ADIF, and read it back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADIF has no record for QTC traffic, which is why N1MM leaves it out and why a WAE log cannot be moved between databases as ADIF. The plain export now writes the contacts alone, as N1MM does: it used to write the QTC rows as contacts, with the time of the traffic in RST_SENT and the reported callsign in RST_RCVD. File > Export > Export ADIF with QTC traffic writes the whole log instead. A QTC row goes out with the station, time, band and mode, and the traffic in application-defined fields — APP_NONEMM_QTC and friends, the prefix the ADIF specification keeps for a program's own data — with the line in the comment as well so the file can be read without this program. The report columns are left off that record, because on a QTC row they hold the time and callsign of the contact being reported. Reading such a file back rebuilds the rows, so a WAE log survives the round trip whole. The item is only in the menu for a contest with traffic. Another program reading that file sees each QTC row as an ordinary contact with the station the traffic was exchanged with and ignores the extra fields. There is no way around that in ADIF, which is why the plain export is still the one to hand over. Contacts now also carry their row kind in APP_NONEMM_EXCHANGE1. Without it a WAE log read back holds contacts with no kind at all, which N1MM's own queries do not find. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ --- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 29 +++++-- src/Nonemm.App/Windows/EntryWindow.axaml | 3 + src/Nonemm.App/Windows/EntryWindow.axaml.cs | 1 + src/Nonemm.Contests/ContestLog.cs | 6 ++ src/Nonemm.Formats/Adif/AdifReader.cs | 49 +++++++++++- src/Nonemm.Formats/Adif/AdifWriter.cs | 83 +++++++++++++++++++-- tests/Nonemm.Contests.Tests/WaeTests.cs | 17 +++++ tests/Nonemm.Formats.Tests/AdifTests.cs | 71 ++++++++++++++++++ 8 files changed, 247 insertions(+), 12 deletions(-) diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 7daae4a..52684e7 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -132,25 +132,44 @@ public sealed partial class EntryWindow Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}"); } - private async void OnExportAdif(object? sender, RoutedEventArgs e) + /// The contacts alone, which is the file to hand to another program. ADIF + /// has no QTC record, and N1MM leaves the traffic out for that reason. + private void OnExportAdif(object? sender, RoutedEventArgs e) => + _ = ExportAdif(withQtcTraffic: false); + + /// The whole log, traffic included, which is the file to read back into + /// this program. The QTC rows carry their traffic in fields of our own; + /// another program reads each of them as a contact with the station the + /// traffic was exchanged with. + private void OnExportAdifWithQtcs(object? sender, RoutedEventArgs e) => + _ = ExportAdif(withQtcTraffic: true); + + private async Task ExportAdif(bool withQtcTraffic) { if (Logging is null) { Status("no contest is open"); return; } + string suffix = withQtcTraffic ? "-with-qtcs" : ""; IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { - Title = "Export ADIF", - SuggestedFileName = $"{session.Settings.Station.Callsign}.adi", + Title = withQtcTraffic ? "Export ADIF with QTC traffic" : "Export ADIF", + SuggestedFileName = $"{session.Settings.Station.Callsign}{suffix}.adi", SuggestedStartLocation = await Folder(session.Paths.ExportFiles), }); if (file?.TryGetLocalPath() is not { } path) { return; } - await File.WriteAllTextAsync(path, new AdifWriter(Logging.Me).Write(Logging.Log.Qsos)); - Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}"); + List written = [.. withQtcTraffic ? Logging.Log.Qsos : Logging.Log.Contacts]; + await File.WriteAllTextAsync( + path, new AdifWriter(Logging.Me).Write(written, withQtcTraffic)); + int traffic = written.Count(q => !Logging.Contest.IsContact(q)); + string what = traffic > 0 + ? $"{written.Count - traffic} contacts and {traffic} QTCs" + : $"{written.Count} contacts"; + Status($"{what} written to {Path.GetFileName(path)}"); } private async void OnImportAdif(object? sender, RoutedEventArgs e) diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index dccd92a..de1c720 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -65,6 +65,9 @@ + + diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 999e601..174336a 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -769,6 +769,7 @@ public sealed partial class EntryWindow : Window { ShowFunctionKeys(); ShowBandButtons(); + QtcExportItem.IsVisible = Logging?.Contest.HasQtcTraffic == true; if (Logging is null) { VerdictText.Text = ""; diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index d442757..9af2037 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -41,6 +41,12 @@ public sealed class ContestLog public IReadOnlyList Qsos => qsos; + /// The contacts alone. In WAE the log also holds QTC traffic, and a QTC + /// row is not a contact: its report columns hold the time and callsign of + /// the contact being reported. Anything that writes contacts out — the + /// ADIF export — takes these rather than every row. + public IEnumerable Contacts => qsos.Where(contest.IsContact); + public ScoreTally Tally => tally; /// The same score split the way N1MM's score window splits it: a row per diff --git a/src/Nonemm.Formats/Adif/AdifReader.cs b/src/Nonemm.Formats/Adif/AdifReader.cs index 5b91c3f..22ae211 100644 --- a/src/Nonemm.Formats/Adif/AdifReader.cs +++ b/src/Nonemm.Formats/Adif/AdifReader.cs @@ -1,10 +1,16 @@ using System.Globalization; +using Nonemm.Contests.Rules; using Nonemm.Core; namespace Nonemm.Formats.Adif; /// Reads ADIF into contacts. Fields the log has no room for are dropped rather /// than crammed into a comment. +/// +/// A record carrying the `APP_NONEMM_QTC_*` fields this program writes is WAE +/// QTC traffic rather than a contact, and it goes back into the log the way +/// N1MM keeps traffic: see `WaeQtc`. A record from another program never has +/// those fields, so it reads as a contact. public static class AdifReader { public static IReadOnlyList Read(string text, string contestName = "DX", int contestNumber = 1) @@ -74,7 +80,7 @@ public static class AdifReader return null; } DateTime when = ParseTimestamp(record); - return new Qso + Qso qso = new() { Id = Qso.NewId(), TimestampUtc = when, @@ -94,7 +100,48 @@ public static class AdifReader GridSquare = Value(record, "GRIDSQUARE"), Comment = Value(record, "COMMENT"), Operator = Value(record, "OPERATOR"), + Exchange1 = Value(record, "APP_NONEMM_EXCHANGE1"), }; + return Traffic(record) is { } qtc ? qtc.ApplyTo(qso) with { Comment = "" } : qso; + } + + /// The QTC line a record carries, or null when it is an ordinary contact. + /// A record with the direction field but nothing readable in the rest is + /// taken as a contact rather than as traffic with empty fields. + private static WaeQtc? Traffic(Dictionary record) + { + string direction = Value(record, "APP_NONEMM_QTC").ToUpperInvariant(); + if (direction is not ("SENT" or "RCVD")) + { + return null; + } + string call = Value(record, "APP_NONEMM_QTC_CALL"); + string time = Value(record, "APP_NONEMM_QTC_TIME"); + if (call.Length == 0 || time.Length == 0) + { + return null; + } + (int series, int count) = ParseSeries(Value(record, "APP_NONEMM_QTC_SERIES")); + return new WaeQtc( + Callsign.Parse(Value(record, "CALL")), + direction == "SENT", + series, + count, + time, + Callsign.Parse(call), + Integer(record, "APP_NONEMM_QTC_NR"), + Integer(record, "APP_NONEMM_QTC_OWNNR")); + } + + /// `3/10` is the third series, ten lines in it. + private static (int Series, int Count) ParseSeries(string text) + { + string[] parts = text.Split('/', StringSplitOptions.TrimEntries); + return parts.Length == 2 + && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int series) + && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) + ? (series, count) + : (0, 0); } private static Frequency ParseFrequency(Dictionary record) diff --git a/src/Nonemm.Formats/Adif/AdifWriter.cs b/src/Nonemm.Formats/Adif/AdifWriter.cs index 76613b3..10774a5 100644 --- a/src/Nonemm.Formats/Adif/AdifWriter.cs +++ b/src/Nonemm.Formats/Adif/AdifWriter.cs @@ -1,17 +1,35 @@ using System.Globalization; using System.Text; +using Nonemm.Contests.Rules; using Nonemm.Core; namespace Nonemm.Formats.Adif; /// Writes ADIF 3.1.4, which is what logbook and award programs read. +/// +/// ADIF has no record for WAE QTC traffic, which is why N1MM leaves it out of +/// its export and why a WAE log cannot be moved between N1MM databases as +/// ADIF. A QTC row here is written as a record of its own, with the traffic in +/// application-defined fields — `APP_NONEMM_QTC_*`, the prefix the ADIF +/// specification reserves for a program's own data. Another program reads the +/// record as an ordinary contact with the station the traffic was exchanged +/// with and ignores the extra fields, so the caller says whether to write the +/// traffic at all: `Write(qsos)` leaves it out, as N1MM does. public sealed class AdifWriter { + /// The name the application-defined fields carry, which is the PROGRAMID. + private const string Program = "NONEMM"; + private readonly StationInfo me; public AdifWriter(StationInfo me) => this.me = me; - public string Write(IEnumerable qsos) + /// The contacts alone, which is the file to hand to another program. + public string Write(IEnumerable qsos) => Write(qsos, withQtcTraffic: false); + + /// `withQtcTraffic` keeps the WAE QTC rows, so the file holds the whole log + /// and reading it back rebuilds it. + public string Write(IEnumerable qsos, bool withQtcTraffic) { StringBuilder text = new(); text.Append("Nonemm ADIF export\r\n"); @@ -20,6 +38,10 @@ public sealed class AdifWriter text.Append("\r\n"); foreach (Qso qso in qsos.OrderBy(q => q.TimestampUtc)) { + if (WaeQtc.IsQtc(qso) && !withQtcTraffic) + { + continue; + } text.Append(Record(qso)).Append("\r\n"); } return text.ToString(); @@ -28,16 +50,23 @@ public sealed class AdifWriter public string Record(Qso qso) { StringBuilder line = new(); + bool isTraffic = WaeQtc.IsQtc(qso); Field(line, "CALL", qso.Call.Text); Field(line, "QSO_DATE", qso.TimestampUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture)); Field(line, "TIME_ON", qso.TimestampUtc.ToString("HHmmss", CultureInfo.InvariantCulture)); Field(line, "BAND", qso.Band?.Name.ToLowerInvariant() ?? ""); Field(line, "FREQ", (qso.Frequency.Megahertz).ToString("0.000000", CultureInfo.InvariantCulture)); Field(line, "MODE", qso.Mode.AdifName); - Field(line, "RST_SENT", qso.SentReport); - Field(line, "RST_RCVD", qso.ReceivedReport); - Field(line, "STX", qso.SentNumber > 0 ? qso.SentNumber.ToString(CultureInfo.InvariantCulture) : ""); - Field(line, "SRX", qso.ReceivedNumber > 0 ? qso.ReceivedNumber.ToString(CultureInfo.InvariantCulture) : ""); + // the report columns of a QTC row hold the time and callsign of the + // contact being reported, not a report and not a serial number, so + // they are left out here and written under their own names below + if (!isTraffic) + { + Field(line, "RST_SENT", qso.SentReport); + Field(line, "RST_RCVD", qso.ReceivedReport); + Field(line, "STX", qso.SentNumber > 0 ? qso.SentNumber.ToString(CultureInfo.InvariantCulture) : ""); + Field(line, "SRX", qso.ReceivedNumber > 0 ? qso.ReceivedNumber.ToString(CultureInfo.InvariantCulture) : ""); + } Field(line, "CONTEST_ID", qso.ContestName); Field(line, "OPERATOR", qso.Operator.Length > 0 ? qso.Operator : me.Callsign); Field(line, "STATION_CALLSIGN", me.Callsign); @@ -46,11 +75,53 @@ public sealed class AdifWriter Field(line, "GRIDSQUARE", qso.GridSquare); Field(line, "CQZ", qso.Zone > 0 ? qso.Zone.ToString(CultureInfo.InvariantCulture) : ""); Field(line, "ARRL_SECT", qso.Section); - Field(line, "COMMENT", qso.Comment); + Field(line, "COMMENT", Comment(qso)); + // which kind of row this is: `QSO` for a contact in WAE, `SQTC` or + // `RQTC` for traffic. ADIF has no field for it, and without it a WAE + // log read back holds contacts that N1MM's own queries do not find + Field(line, $"APP_{Program}_EXCHANGE1", qso.Exchange1); + Traffic(line, qso); line.Append(""); return line.ToString(); } + /// The QTC line this row carries, in fields of our own: which way it went, + /// the series it belongs to, and the contact it reports. The reported time, + /// callsign and serial number are in the report columns of the row as well, + /// because that is where N1MM keeps them, but they are written again here + /// under their own names so the file says what they are. + private static void Traffic(StringBuilder line, Qso qso) + { + if (WaeQtc.From(qso) is not { } qtc) + { + return; + } + Field(line, $"APP_{Program}_QTC", qtc.IsSent ? "SENT" : "RCVD"); + Field(line, $"APP_{Program}_QTC_SERIES", qtc.SeriesText); + Field(line, $"APP_{Program}_QTC_TIME", qtc.TimeUtc); + Field(line, $"APP_{Program}_QTC_CALL", qtc.Call.Text); + Field(line, $"APP_{Program}_QTC_NR", qtc.Number.ToString(CultureInfo.InvariantCulture)); + if (qtc.OwnNumber > 0) + { + Field( + line, + $"APP_{Program}_QTC_OWNNR", + qtc.OwnNumber.ToString(CultureInfo.InvariantCulture)); + } + } + + /// A QTC row says so in the comment as well, so the traffic can be read by + /// somebody looking at the file rather than only by this program. + private static string Comment(Qso qso) + { + if (WaeQtc.From(qso) is not { } qtc) + { + return qso.Comment; + } + string what = qtc.IsSent ? "sent" : "rcvd"; + return $"WAE QTC {what} {qtc.SeriesText}: {qtc.TimeUtc} {qtc.Call.Text} {qtc.Number:000}"; + } + private static void Field(StringBuilder text, string name, string value) { if (value.Length > 0) diff --git a/tests/Nonemm.Contests.Tests/WaeTests.cs b/tests/Nonemm.Contests.Tests/WaeTests.cs index ccd6020..fb3b5fe 100644 --- a/tests/Nonemm.Contests.Tests/WaeTests.cs +++ b/tests/Nonemm.Contests.Tests/WaeTests.cs @@ -171,6 +171,23 @@ public class WaeTests Assert.Equal(2, log.Tally.TotalMultipliers); } + /// ADIF has no QTC record, so the export writes contacts alone. N1MM says + /// the same in its manual: "the ADIF file will not contain QTCs, only + /// QSOs". + [Fact] + public void TheContactsAreTheRowsWithoutTheTraffic() + { + ContestLog log = LogFor(Rtty, TestLog.Germany); + log.Add(On("JA1XYZ", 14_080, Modes.Rtty)); + log.Add(new WaeQtc( + Callsign.Parse("JA1XYZ"), IsSent: true, 1, 10, "1435", Callsign.Parse("DL9XYZ"), 42) + .ApplyTo(On("JA1XYZ", 14_080, Modes.Rtty))); + + Assert.Equal(2, log.Qsos.Count); + Qso only = Assert.Single(log.Contacts); + Assert.Equal(WaeQtc.Contact, only.Exchange1); + } + [Fact] public void AQtcGoesIntoTheLogTheWayN1mmKeepsIt() { diff --git a/tests/Nonemm.Formats.Tests/AdifTests.cs b/tests/Nonemm.Formats.Tests/AdifTests.cs index 024ae16..6e99531 100644 --- a/tests/Nonemm.Formats.Tests/AdifTests.cs +++ b/tests/Nonemm.Formats.Tests/AdifTests.cs @@ -1,3 +1,4 @@ +using Nonemm.Contests.Rules; using Nonemm.Core; using Nonemm.Formats.Adif; @@ -37,6 +38,76 @@ public class AdifTests Assert.Equal("CQWW", read.ContestName); } + private static Qso Traffic() => new WaeQtc( + Callsign.Parse("JA1XYZ"), + IsSent: true, + Series: 3, + CountInSeries: 10, + TimeUtc: "1435", + Call: Callsign.Parse("DL9XYZ"), + Number: 42, + OwnNumber: 7) + .ApplyTo(Contact() with { TimestampUtc = new DateTime(2026, 5, 30, 12, 40, 0, DateTimeKind.Utc) }); + + /// ADIF has no QTC record, so the plain export leaves the traffic out, the + /// way N1MM does. + [Fact] + public void TrafficIsLeftOutUnlessItIsAskedFor() + { + string text = new AdifWriter(Me).Write([Contact() with { Exchange1 = WaeQtc.Contact }, Traffic()]); + + Qso read = Assert.Single(AdifReader.Read(text)); + Assert.Equal("JA1XYZ", read.Call.Text); + // the row kind comes back, so a contact read into a WAE log is still + // a contact rather than a row of no kind at all + Assert.Equal(WaeQtc.Contact, read.Exchange1); + Assert.DoesNotContain("APP_NONEMM_QTC", text, StringComparison.Ordinal); + } + + /// Asked for, the traffic goes in fields of our own and comes back as the + /// same QTC, so a WAE log survives the round trip whole. + [Fact] + public void TrafficSurvivesAWriteAndRead() + { + string text = new AdifWriter(Me).Write([Contact(), Traffic()], withQtcTraffic: true); + + IReadOnlyList read = AdifReader.Read(text); + + Assert.Equal(2, read.Count); + Qso row = read.Single(WaeQtc.IsQtc); + Assert.Equal(new WaeQtc( + Callsign.Parse("JA1XYZ"), true, 3, 10, "1435", Callsign.Parse("DL9XYZ"), 42, 7), + WaeQtc.From(row)); + Assert.Equal(new DateTime(2026, 5, 30, 12, 40, 0, DateTimeKind.Utc), row.TimestampUtc); + Assert.Equal(Bands.Band20M, row.Band); + } + + /// The report columns of a QTC row hold the time and callsign of the + /// contact being reported, so writing them as a report and a serial number + /// would tell another program something untrue. + [Fact] + public void TrafficDoesNotWriteTheReportFields() + { + string text = new AdifWriter(Me).Write([Traffic()], withQtcTraffic: true); + + Assert.DoesNotContain("JA1XYZ 20260530 123456 CW "; + + Assert.False(WaeQtc.IsQtc(AdifReader.Read(text).Single())); + } + [Fact] public void HeaderIsSkipped() {