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() {