diff --git a/.gitignore b/.gitignore index 930e9f1..65f9c94 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bin/ obj/ *.user /N1MM/ +/N1MMsln/ diff --git a/README.md b/README.md index 5f4cbf3..f0cc537 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | While typing | dupe check, multiplier check, points, country and zone from the country file | | Windows | entry, log, check, bandmap, score summary, packet | +| Editing | double-click a cell in the log to change it, Delete to remove the contact; both go out to the other stations | | Radio | hamlib `rigctld`, reconnecting on its own | | Cluster | DX cluster over telnet, spots feeding the bandmap | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | @@ -94,13 +95,30 @@ messages for CW and for phone. The macros are N1MM's: `{MYCALL}`, `{CALL}`, `{EXCH}`, `{SENTRST}`, `{SENTNR}`, `#` for the serial number, and `{SENTRSTCUT}` for cut numbers. Escape stops sending. +### Editing the log + +Double-click a cell in the log window to change it. The columns follow the +contest exchange, so CQ WW shows a Zone column and Sweepstakes shows Nr, Prec, +Ck and Sec. A value the contest does not accept — zone 41, an ARRL section that +does not exist, a callsign with a space in it — is refused and the reason +appears under the log. Changing the callsign looks the country up again. + +Delete, or the right-click menu, removes the selected contact after a +confirmation. Either way the whole log is scored again, so a multiplier the +removed contact was holding passes to the next contact that claims it. + ### Networked stations **Config → Network** names this station and lists the others. Each contact is sent to them as it is logged, in N1MM's `contactinfo` message, so an N1MM -station on the same network sees them too. With no addresses listed the contacts -are broadcast. A contact that arrives is scored again here from the rules rather -than trusted. +station on the same network sees them too. Editing a contact sends +`contactreplace` and deleting one sends `contactdelete`, the same as N1MM. With +no addresses listed the messages are broadcast. A contact that arrives is scored +again here from the rules rather than trusted. + +An incoming edit or delete is matched by contact id first. N1MM does not know +our ids, so the call and the timestamp are the fallback — that is the pair N1MM +itself keys a contact on. ## Layout @@ -125,8 +143,9 @@ covered by plain unit tests. Working: logging a contest end to end, live dupe and multiplier checking, eight built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import, -the log, check, bandmap, score and packet windows, radio control, DX cluster -spots, contacts shared between networked stations, and CW keying. +the log, check, bandmap, score and packet windows, editing and deleting logged +contacts, radio control, DX cluster spots, contacts shared between networked +stations, and CW keying. Checked against N1MM 1.0.11031: a log this program wrote opens in N1MM, which reads the contest, its categories and the contacts. See diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index a1b9b7d..0f4ba1f 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -112,6 +112,9 @@ public sealed class AppSession : IDisposable Logging.Logged += (_, qso) => Bandmap.Add(new Spot( qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log)); Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign); + Logging.Edited += (_, change) => _ = network?.SendEditAsync( + change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc); + Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign); Check = new CheckWindowSources(Logging, Calls, Bandmap); Save(Settings with { ContestNumber = contestNumber }); ContestChanged?.Invoke(this, EventArgs.Empty); @@ -132,29 +135,60 @@ public sealed class AppSession : IDisposable Settings.NetworkPort, Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName, Settings.NetworkPeers); - network.ContactArrived += (_, qso) => TakeFromNetwork(qso); + network.UpdateArrived += (_, update) => TakeFromNetwork(update); network.Start(); Changed?.Invoke(this, EventArgs.Empty); } - /// A contact another station logged. It goes into the same log under the - /// contest that is open, and is scored here rather than trusting the - /// points the sender put in the message. - private void TakeFromNetwork(Qso qso) + /// What another station did to its log, applied to ours. The store is + /// written directly rather than through `Logging`, so the change is not + /// broadcast back out again. Points and multipliers are worked out here + /// from the rules instead of trusting what the sender put in the message. + private void TakeFromNetwork(ContactUpdate update) { if (Logging is null || store is null) { return; } - if (Logging.Log.Qsos.Any(q => q.Id == qso.Id)) + int contestNumber = Logging.Instance.ContestNumber; + switch (update) { - return; + case ContactLogged logged when !Logging.Log.Qsos.Any(q => q.Id == logged.Qso.Id): + store.Add(logged.Qso with { ContestNumber = contestNumber, IsOriginal = false }); + break; + case ContactReplaced replaced: + if (FindLocal(replaced.Qso.Id, replaced.OldCall, replaced.OldTimestampUtc) is not { } old) + { + return; + } + store.Update(replaced.Qso with + { + Id = old.Id, + ContestNumber = contestNumber, + IsOriginal = false, + }); + break; + case ContactDeleted deleted: + if (FindLocal(deleted.Id, deleted.Call, deleted.TimestampUtc) is not { } gone) + { + return; + } + store.Delete(gone.Id); + break; + default: + return; } - Qso mine = qso with { ContestNumber = Logging.Instance.ContestNumber, IsOriginal = false }; - store.Add(mine); - OpenContest(Logging.Instance.ContestNumber); + OpenContest(contestNumber); } + /// N1MM keys a contact on its call and time, so a message from N1MM carries + /// no id we would recognise. Fall back to that pair when the id misses. + private Qso? FindLocal(string id, string call, DateTime timestampUtc) => + Logging?.Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id) + ?? Logging?.Log.Qsos.FirstOrDefault(q => + string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase) + && q.TimestampUtc == timestampUtc); + /// Starts, restarts or stops the keyer, following what the settings say. /// A keyer that will not open is reported; the program keeps running /// without one. diff --git a/src/Nonemm.App/Dialogs/ConfirmDialog.axaml b/src/Nonemm.App/Dialogs/ConfirmDialog.axaml new file mode 100644 index 0000000..18e86b9 --- /dev/null +++ b/src/Nonemm.App/Dialogs/ConfirmDialog.axaml @@ -0,0 +1,14 @@ + + + +