Edit and delete contacts in the log
Double-click a cell in the log window to change it. The columns follow the contest exchange instead of being fixed in the XAML, so CQ WW gets a Zone column and Sweepstakes gets Nr, Prec, Ck and Sec. QsoEditor holds the column list and applies one field edit. Validation comes from the ExchangeFieldKind the contest declared: a CQ zone is 1 to 40, an ITU zone 1 to 90, a section is looked up in the ARRL list, a grid must parse. A refused edit returns the reason and leaves the log alone, so the cell reverts. Changing the callsign runs the country lookup again. Delete, or the right-click menu, removes the selected contact after a confirmation. Either way the log is rescored, so a multiplier the removed contact held passes to the next contact that claims it. Both go out to the other stations in N1MM's own messages: contactreplace carries oldcall and oldtimestamp, contactdelete names the contact. An incoming edit or delete is matched by contact id first, falling back to call plus timestamp because N1MM does not know our ids. Country, continent and the two prefixes were filled in twice, once when logging and once when editing. They now come from CountryFields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,22 +4,55 @@ using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// One contact as N1MM broadcasts it: a `contactinfo` XML document. Writing
|
||||
/// N1MM's own message means a Nonemm station and an N1MM station can sit on the
|
||||
/// same network and see each other's contacts.
|
||||
/// The contact messages N1MM broadcasts: `contactinfo` when a contact is
|
||||
/// logged, `contactreplace` when one is edited and `contactdelete` when one is
|
||||
/// removed. Writing N1MM's own messages lets a Nonemm station and an N1MM
|
||||
/// station sit on the same network and see each other's contacts.
|
||||
public static class ContactMessage
|
||||
{
|
||||
/// N1MM sends the frequencies in units of ten hertz.
|
||||
private const long FrequencyUnit = 10;
|
||||
|
||||
public static string Write(Qso qso, string myCallsign, string stationName)
|
||||
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
public static string Write(Qso qso, string myCallsign, string stationName) =>
|
||||
Contact("contactinfo", qso, myCallsign, stationName).ToString();
|
||||
|
||||
/// An edited contact. The old call and time tell the other stations which
|
||||
/// of their rows to replace.
|
||||
public static string WriteReplace(
|
||||
Qso qso,
|
||||
string myCallsign,
|
||||
string stationName,
|
||||
string oldCall,
|
||||
DateTime oldTimestampUtc)
|
||||
{
|
||||
XElement root = Contact("contactreplace", qso, myCallsign, stationName);
|
||||
root.Add(new XElement("oldtimestamp", Stamp(oldTimestampUtc)));
|
||||
root.Add(new XElement("oldcall", oldCall));
|
||||
return root.ToString();
|
||||
}
|
||||
|
||||
public static string WriteDelete(Qso qso, string myCallsign, string stationName) =>
|
||||
new XElement(
|
||||
"contactdelete",
|
||||
new XElement("app", "Nonemm"),
|
||||
new XElement("timestamp", Stamp(qso.TimestampUtc)),
|
||||
new XElement("call", qso.Call.Text),
|
||||
new XElement("mycall", myCallsign),
|
||||
new XElement("band", qso.Band?.MegahertzLabel ?? 0),
|
||||
new XElement("contestnr", qso.ContestNumber),
|
||||
new XElement("StationName", stationName),
|
||||
new XElement("ID", qso.Id)).ToString();
|
||||
|
||||
private static XElement Contact(string element, Qso qso, string myCallsign, string stationName)
|
||||
{
|
||||
XElement root = new(
|
||||
"contactinfo",
|
||||
element,
|
||||
new XElement("app", "Nonemm"),
|
||||
new XElement("contestname", qso.ContestName),
|
||||
new XElement("contestnr", qso.ContestNumber),
|
||||
new XElement("timestamp", qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
|
||||
new XElement("timestamp", Stamp(qso.TimestampUtc)),
|
||||
new XElement("mycall", myCallsign),
|
||||
new XElement("band", qso.Band?.MegahertzLabel ?? 0),
|
||||
new XElement("rxfreq", qso.Frequency.Hertz / FrequencyUnit),
|
||||
@@ -60,12 +93,12 @@ public static class ContactMessage
|
||||
new XElement("StationName", stationName),
|
||||
new XElement("ID", qso.Id),
|
||||
new XElement("IsClaimedQso", qso.IsClaimed ? 1 : 0));
|
||||
return root.ToString();
|
||||
return root;
|
||||
}
|
||||
|
||||
/// Null for a message that is not a contact, which is how the other N1MM
|
||||
/// message kinds on the same port are passed over.
|
||||
public static Qso? Read(string xml)
|
||||
/// Null for a message that is not about a contact. The other N1MM message
|
||||
/// kinds share the port, so they arrive here too.
|
||||
public static ContactUpdate? Read(string xml)
|
||||
{
|
||||
XElement root;
|
||||
try
|
||||
@@ -76,19 +109,39 @@ public static class ContactMessage
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (root.Name.LocalName != "contactinfo")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string call = Text(root, "call");
|
||||
if (call.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new Qso
|
||||
string station = Station(root);
|
||||
switch (root.Name.LocalName)
|
||||
{
|
||||
case "contactinfo":
|
||||
return new ContactLogged(ReadContact(root, call), station);
|
||||
case "contactreplace":
|
||||
return new ContactReplaced(
|
||||
ReadContact(root, call),
|
||||
Text(root, "oldcall") is { Length: > 0 } old ? old : call,
|
||||
Timestamp(root, "oldtimestamp"),
|
||||
station);
|
||||
case "contactdelete":
|
||||
return new ContactDeleted(
|
||||
Text(root, "ID"),
|
||||
call,
|
||||
Timestamp(root, "timestamp"),
|
||||
(int)Integer(root, "contestnr"),
|
||||
station);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Qso ReadContact(XElement root, string call) =>
|
||||
new Qso
|
||||
{
|
||||
Id = Text(root, "ID") is { Length: > 0 } id ? id : Qso.NewId(),
|
||||
TimestampUtc = Timestamp(root),
|
||||
TimestampUtc = Timestamp(root, "timestamp"),
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = Frequency.FromHertz(Integer(root, "rxfreq") * FrequencyUnit),
|
||||
QsxFrequency = Frequency.FromHertz(Integer(root, "txfreq") * FrequencyUnit),
|
||||
@@ -124,14 +177,11 @@ public static class ContactMessage
|
||||
RadioNumber = (int)Integer(root, "radionr"),
|
||||
IsRadioInterfaced = Flag(root, "RadioInterfaced"),
|
||||
NetworkedComputerNumber = (int)Integer(root, "NetworkedCompNr"),
|
||||
StationName = Text(root, "StationName") is { Length: > 0 } station
|
||||
? station
|
||||
: Text(root, "NetBiosName"),
|
||||
StationName = Station(root),
|
||||
// a contact that arrived over the network was made somewhere else
|
||||
IsOriginal = false,
|
||||
IsClaimed = Flag(root, "IsClaimedQso"),
|
||||
};
|
||||
}
|
||||
|
||||
private static string Text(XElement root, string name) =>
|
||||
root.Element(name)?.Value.Trim() ?? "";
|
||||
@@ -147,9 +197,15 @@ public static class ContactMessage
|
||||
return text.Equals("true", StringComparison.OrdinalIgnoreCase) || text == "1";
|
||||
}
|
||||
|
||||
private static DateTime Timestamp(XElement root) =>
|
||||
private static string Stamp(DateTime when) =>
|
||||
when.ToString(TimeFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Station(XElement root) =>
|
||||
Text(root, "StationName") is { Length: > 0 } name ? name : Text(root, "NetBiosName");
|
||||
|
||||
private static DateTime Timestamp(XElement root, string name) =>
|
||||
DateTime.TryParse(
|
||||
Text(root, "timestamp"),
|
||||
Text(root, name),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime when)
|
||||
|
||||
23
src/Nonemm.Network/ContactUpdate.cs
Normal file
23
src/Nonemm.Network/ContactUpdate.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Network;
|
||||
|
||||
/// Something another station did to its log. `StationName` is the computer that
|
||||
/// sent it, so a station can ignore the echo of its own message.
|
||||
public abstract record ContactUpdate(string StationName);
|
||||
|
||||
public sealed record ContactLogged(Qso Qso, string StationName)
|
||||
: ContactUpdate(StationName);
|
||||
|
||||
/// An edited contact. The receiver finds its copy by the old call and time,
|
||||
/// because that is the pair N1MM keys a contact on.
|
||||
public sealed record ContactReplaced(Qso Qso, string OldCall, DateTime OldTimestampUtc, string StationName)
|
||||
: ContactUpdate(StationName);
|
||||
|
||||
public sealed record ContactDeleted(
|
||||
string Id,
|
||||
string Call,
|
||||
DateTime TimestampUtc,
|
||||
int ContestNumber,
|
||||
string StationName)
|
||||
: ContactUpdate(StationName);
|
||||
@@ -31,17 +31,34 @@ public sealed class StationNetwork : IDisposable
|
||||
|
||||
public IReadOnlyList<IPEndPoint> Peers => peers;
|
||||
|
||||
/// A contact another station logged. It arrives already scored; the log
|
||||
/// works its points and multipliers out again from the rules.
|
||||
public event EventHandler<Qso>? ContactArrived;
|
||||
/// A contact another station logged, edited or deleted. A logged or edited
|
||||
/// contact arrives already scored; the log works its points and multipliers
|
||||
/// out again from the rules.
|
||||
public event EventHandler<ContactUpdate>? UpdateArrived;
|
||||
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public void Start() => loop ??= Task.Run(() => ListenAsync(stopping.Token));
|
||||
|
||||
public async Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default)
|
||||
public Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
|
||||
SendTextAsync(ContactMessage.Write(qso, myCallsign, stationName), cancellation);
|
||||
|
||||
public Task SendEditAsync(
|
||||
Qso qso,
|
||||
string myCallsign,
|
||||
string oldCall,
|
||||
DateTime oldTimestampUtc,
|
||||
CancellationToken cancellation = default) =>
|
||||
SendTextAsync(
|
||||
ContactMessage.WriteReplace(qso, myCallsign, stationName, oldCall, oldTimestampUtc),
|
||||
cancellation);
|
||||
|
||||
public Task SendDeleteAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
|
||||
SendTextAsync(ContactMessage.WriteDelete(qso, myCallsign, stationName), cancellation);
|
||||
|
||||
private async Task SendTextAsync(string xml, CancellationToken cancellation)
|
||||
{
|
||||
byte[] message = Encoding.UTF8.GetBytes(ContactMessage.Write(qso, myCallsign, stationName));
|
||||
byte[] message = Encoding.UTF8.GetBytes(xml);
|
||||
foreach (IPEndPoint peer in Destinations())
|
||||
{
|
||||
try
|
||||
@@ -75,10 +92,10 @@ public sealed class StationNetwork : IDisposable
|
||||
try
|
||||
{
|
||||
UdpReceiveResult received = await listener.ReceiveAsync(cancellation).ConfigureAwait(false);
|
||||
Qso? qso = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
|
||||
if (qso is not null && qso.StationName != stationName)
|
||||
ContactUpdate? update = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
|
||||
if (update is not null && update.StationName != stationName)
|
||||
{
|
||||
ContactArrived?.Invoke(this, qso);
|
||||
UpdateArrived?.Invoke(this, update);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
Reference in New Issue
Block a user