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:
@@ -0,0 +1,22 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Fills in the contact fields that come from the callsign: country,
|
||||
/// continent and the two prefixes. Used when a contact is logged and when its
|
||||
/// call is edited later.
|
||||
public static class CountryFields
|
||||
{
|
||||
public static Qso Apply(Qso qso, CountryFile? countries)
|
||||
{
|
||||
CountryLookup? country = countries?.Find(qso.Call);
|
||||
return qso with
|
||||
{
|
||||
CountryPrefix = country?.Entity.PrimaryPrefix ?? "",
|
||||
Continent = country?.Continent ?? "",
|
||||
StationPrefix = qso.Call.PortablePrefix ?? "",
|
||||
WpxPrefix = qso.Call.WpxPrefix() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed class LoggingSession
|
||||
Log = new ContestLog(contest, me, countries);
|
||||
Log.Restore(store.Qsos(instance.ContestNumber));
|
||||
Entry = new EntryFields(contest.ExchangeFieldsFor(me));
|
||||
Editor = new QsoEditor(contest, me, countries);
|
||||
SentNumber = NextSentNumber();
|
||||
}
|
||||
|
||||
@@ -42,6 +43,8 @@ public sealed class LoggingSession
|
||||
|
||||
public EntryFields Entry { get; }
|
||||
|
||||
public QsoEditor Editor { get; }
|
||||
|
||||
/// With no radio connected this stays where it was last typed.
|
||||
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
||||
|
||||
@@ -58,6 +61,12 @@ public sealed class LoggingSession
|
||||
|
||||
public event EventHandler<Qso>? Logged;
|
||||
|
||||
/// A contact that was edited, with the call and time it had before. The
|
||||
/// other stations need the old pair to find their copy of it.
|
||||
public event EventHandler<QsoChange>? Edited;
|
||||
|
||||
public event EventHandler<Qso>? Deleted;
|
||||
|
||||
public void Tune(Frequency frequency, Mode? mode = null)
|
||||
{
|
||||
Frequency = frequency;
|
||||
@@ -112,11 +121,31 @@ public sealed class LoggingSession
|
||||
|
||||
public void Delete(string id)
|
||||
{
|
||||
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
|
||||
store.Delete(id);
|
||||
Log.Remove(id);
|
||||
if (gone is not null)
|
||||
{
|
||||
Deleted?.Invoke(this, gone);
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Changes one field of a logged contact. A refused edit leaves the log
|
||||
/// alone and the caller gets the reason back.
|
||||
public QsoEdit Edit(string id, QsoField field, string text)
|
||||
{
|
||||
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
|
||||
?? throw new InvalidOperationException($"no contact with id {id} in the log");
|
||||
QsoEdit edit = Editor.Apply(before, field, text);
|
||||
if (edit.Result is not null)
|
||||
{
|
||||
Update(edit.Result);
|
||||
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
|
||||
}
|
||||
return edit;
|
||||
}
|
||||
|
||||
public void Update(Qso qso)
|
||||
{
|
||||
store.Update(qso);
|
||||
@@ -130,7 +159,6 @@ public sealed class LoggingSession
|
||||
private Qso BuildQso(string call)
|
||||
{
|
||||
Callsign parsed = Callsign.Parse(call);
|
||||
CountryLookup? country = countries?.Find(parsed);
|
||||
Qso qso = new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
@@ -142,14 +170,10 @@ public sealed class LoggingSession
|
||||
ContestNumber = Instance.ContestNumber,
|
||||
SentReport = DefaultReport(),
|
||||
SentNumber = SentNumber,
|
||||
CountryPrefix = country?.Entity.PrimaryPrefix ?? "",
|
||||
Continent = country?.Continent ?? "",
|
||||
StationPrefix = parsed.PortablePrefix ?? "",
|
||||
WpxPrefix = parsed.WpxPrefix() ?? "",
|
||||
Operator = Operator.Length > 0 ? Operator : Me.Callsign,
|
||||
IsRunQso = IsRunning,
|
||||
};
|
||||
return ApplyExchange(qso);
|
||||
return ApplyExchange(CountryFields.Apply(qso, countries));
|
||||
}
|
||||
|
||||
private Qso ApplyExchange(Qso qso)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// An edited contact, plus the call and time it had before the edit. Other
|
||||
/// stations look their copy up by that pair.
|
||||
public sealed record QsoChange(Qso Qso, string OldCall, DateTime OldTimestampUtc);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// One editable column of the log window.
|
||||
/// `Width` is in characters.
|
||||
public sealed record QsoColumn(string Label, QsoField Field, int Width);
|
||||
@@ -0,0 +1,13 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The result of an edit: either the changed contact or an error message.
|
||||
public sealed record QsoEdit(Qso? Result, string Error)
|
||||
{
|
||||
public static QsoEdit Accept(Qso qso) => new(qso, "");
|
||||
|
||||
public static QsoEdit Refuse(string error) => new(null, error);
|
||||
|
||||
public bool IsAccepted => Result is not null;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Globalization;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Contests.Multipliers;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Reads and changes one field of a logged contact. The contest decides which
|
||||
/// columns there are and what each one accepts. No UI framework here, so the
|
||||
/// checks are unit-tested.
|
||||
public sealed class QsoEditor
|
||||
{
|
||||
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private readonly CountryFile? countries;
|
||||
private readonly Dictionary<QsoField, ExchangeFieldKind> exchangeKinds = [];
|
||||
|
||||
public QsoEditor(Contest contest, StationInfo me, CountryFile? countries)
|
||||
{
|
||||
this.countries = countries;
|
||||
Columns = BuildColumns(contest, me, exchangeKinds);
|
||||
}
|
||||
|
||||
/// Log columns, left to right.
|
||||
public IReadOnlyList<QsoColumn> Columns { get; }
|
||||
|
||||
public static string Read(Qso qso, QsoField field) => field switch
|
||||
{
|
||||
QsoField.Time => qso.TimestampUtc.ToString(TimeFormat, CultureInfo.InvariantCulture),
|
||||
QsoField.Frequency => qso.Frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture),
|
||||
QsoField.Mode => qso.Mode.Name,
|
||||
QsoField.Call => qso.Call.Text,
|
||||
QsoField.SentReport => qso.SentReport,
|
||||
QsoField.SentNumber => Digits(qso.SentNumber),
|
||||
QsoField.ReceivedReport => qso.ReceivedReport,
|
||||
QsoField.ReceivedNumber => Digits(qso.ReceivedNumber),
|
||||
QsoField.Zone => Digits(qso.Zone),
|
||||
QsoField.Section => qso.Section,
|
||||
QsoField.Precedence => qso.Precedence,
|
||||
QsoField.Check => Digits(qso.Check),
|
||||
QsoField.Exchange1 => qso.Exchange1,
|
||||
QsoField.MiscText => qso.MiscText,
|
||||
QsoField.Name => qso.Name,
|
||||
QsoField.Qth => qso.Qth,
|
||||
QsoField.GridSquare => qso.GridSquare,
|
||||
QsoField.Power => qso.Power,
|
||||
QsoField.Comment => qso.Comment,
|
||||
QsoField.Operator => qso.Operator,
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// Returns the contact with the field changed, or an error message. An empty
|
||||
/// value clears the field, except for the call, time, mode and frequency.
|
||||
public QsoEdit Apply(Qso qso, QsoField field, string text)
|
||||
{
|
||||
string value = Normalize(field, text);
|
||||
if (KindError(field, value) is { } refused)
|
||||
{
|
||||
return QsoEdit.Refuse(refused);
|
||||
}
|
||||
return field switch
|
||||
{
|
||||
QsoField.Time => WithTime(qso, value),
|
||||
QsoField.Frequency => WithFrequency(qso, value),
|
||||
QsoField.Mode => WithMode(qso, value),
|
||||
QsoField.Call => WithCall(qso, value),
|
||||
QsoField.SentReport => QsoEdit.Accept(qso with { SentReport = value }),
|
||||
QsoField.SentNumber => Count(value, n => qso with { SentNumber = n }),
|
||||
QsoField.ReceivedReport => QsoEdit.Accept(qso with { ReceivedReport = value }),
|
||||
QsoField.ReceivedNumber => Count(value, n => qso with { ReceivedNumber = n }),
|
||||
QsoField.Zone => Count(value, n => qso with { Zone = n }),
|
||||
QsoField.Section => QsoEdit.Accept(qso with { Section = value }),
|
||||
QsoField.Precedence => QsoEdit.Accept(qso with { Precedence = value }),
|
||||
QsoField.Check => Count(value, n => qso with { Check = n }),
|
||||
QsoField.Exchange1 => QsoEdit.Accept(qso with { Exchange1 = value }),
|
||||
QsoField.MiscText => QsoEdit.Accept(qso with { MiscText = value }),
|
||||
QsoField.Name => QsoEdit.Accept(qso with { Name = value }),
|
||||
QsoField.Qth => QsoEdit.Accept(qso with { Qth = value }),
|
||||
QsoField.GridSquare => QsoEdit.Accept(qso with { GridSquare = value }),
|
||||
QsoField.Power => QsoEdit.Accept(qso with { Power = value }),
|
||||
QsoField.Comment => QsoEdit.Accept(qso with { Comment = value }),
|
||||
QsoField.Operator => QsoEdit.Accept(qso with { Operator = value }),
|
||||
_ => QsoEdit.Refuse($"{field} cannot be edited"),
|
||||
};
|
||||
}
|
||||
|
||||
// exchange fields are stored upper case, the same as the entry window does
|
||||
// it; names, QTHs, power and comments keep the case they were typed in
|
||||
private static string Normalize(QsoField field, string text)
|
||||
{
|
||||
string trimmed = text.Trim();
|
||||
return field is QsoField.Name or QsoField.Qth or QsoField.Power or QsoField.Comment
|
||||
? trimmed
|
||||
: trimmed.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private QsoEdit WithCall(Qso qso, string value)
|
||||
{
|
||||
Callsign call = Callsign.Parse(value);
|
||||
if (!call.IsPlausible)
|
||||
{
|
||||
return QsoEdit.Refuse($"{value} is not shaped like a callsign");
|
||||
}
|
||||
return QsoEdit.Accept(CountryFields.Apply(qso with { Call = call }, countries));
|
||||
}
|
||||
|
||||
private static QsoEdit WithTime(Qso qso, string value)
|
||||
{
|
||||
if (!DateTime.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime when))
|
||||
{
|
||||
return QsoEdit.Refuse($"{value} is not a date and time; write it as {TimeFormat}");
|
||||
}
|
||||
return QsoEdit.Accept(qso with { TimestampUtc = when });
|
||||
}
|
||||
|
||||
private static QsoEdit WithFrequency(Qso qso, string value)
|
||||
{
|
||||
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double kilohertz)
|
||||
|| kilohertz <= 0)
|
||||
{
|
||||
return QsoEdit.Refuse($"{value} is not a frequency in kilohertz");
|
||||
}
|
||||
return QsoEdit.Accept(qso with { Frequency = Frequency.FromKilohertz(kilohertz) });
|
||||
}
|
||||
|
||||
private static QsoEdit WithMode(Qso qso, string value)
|
||||
{
|
||||
Mode? mode = Modes.Parse(value);
|
||||
return mode is null
|
||||
? QsoEdit.Refuse($"{value} is not a mode this logger knows")
|
||||
: QsoEdit.Accept(qso with { Mode = mode });
|
||||
}
|
||||
|
||||
private static QsoEdit Count(string value, Func<int, Qso> set)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return QsoEdit.Accept(set(0));
|
||||
}
|
||||
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number)
|
||||
&& number >= 0
|
||||
? QsoEdit.Accept(set(number))
|
||||
: QsoEdit.Refuse($"{value} is not a whole number");
|
||||
}
|
||||
|
||||
/// Checks the value against the exchange field kind the contest defined.
|
||||
/// Returns null if it is acceptable. An empty value always is.
|
||||
private string? KindError(QsoField field, string value)
|
||||
{
|
||||
if (value.Length == 0 || !exchangeKinds.TryGetValue(field, out ExchangeFieldKind kind))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return kind switch
|
||||
{
|
||||
ExchangeFieldKind.Check => InRange(value, 0, 99) ? null : "the check is a two-digit year",
|
||||
ExchangeFieldKind.CqZone => InRange(value, 1, 40) ? null : "CQ zones run from 1 to 40",
|
||||
ExchangeFieldKind.ItuZone => InRange(value, 1, 90) ? null : "ITU zones run from 1 to 90",
|
||||
ExchangeFieldKind.ArrlSection =>
|
||||
ArrlSections.IsSection(value) ? null : $"{value} is not an ARRL section",
|
||||
ExchangeFieldKind.UsStateOrCanadianProvince =>
|
||||
StatesAndProvinces.IsStateOrProvince(value) ? null : $"{value} is not a state or province",
|
||||
ExchangeFieldKind.Grid =>
|
||||
GridSquare.TryParse(value, out _) ? null : $"{value} is not a grid square",
|
||||
ExchangeFieldKind.Precedence =>
|
||||
value.Length == 1 && char.IsLetter(value[0]) ? null : "the precedence is one letter",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool InRange(string value, int low, int high) =>
|
||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number)
|
||||
&& number >= low
|
||||
&& number <= high;
|
||||
|
||||
private static string Digits(int value) => value > 0 ? value.ToString(CultureInfo.InvariantCulture) : "";
|
||||
|
||||
private static IReadOnlyList<QsoColumn> BuildColumns(
|
||||
Contest contest,
|
||||
StationInfo me,
|
||||
Dictionary<QsoField, ExchangeFieldKind> kinds)
|
||||
{
|
||||
List<QsoColumn> columns =
|
||||
[
|
||||
new QsoColumn("Time", QsoField.Time, TimeFormat.Length),
|
||||
new QsoColumn("Freq", QsoField.Frequency, 8),
|
||||
new QsoColumn("Mode", QsoField.Mode, 5),
|
||||
new QsoColumn("Call", QsoField.Call, 12),
|
||||
new QsoColumn("Snt", QsoField.SentReport, 4),
|
||||
];
|
||||
if (contest.HasSerialNumbers)
|
||||
{
|
||||
columns.Add(new QsoColumn("SntNr", QsoField.SentNumber, 5));
|
||||
}
|
||||
foreach (ExchangeField field in contest.ExchangeFieldsFor(me))
|
||||
{
|
||||
QsoField mapped = FieldFor(field.Slot);
|
||||
kinds[mapped] = field.Kind;
|
||||
columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length)));
|
||||
}
|
||||
columns.Add(new QsoColumn("Op", QsoField.Operator, 8));
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static QsoField FieldFor(ExchangeSlot slot) => slot switch
|
||||
{
|
||||
ExchangeSlot.ReceivedReport => QsoField.ReceivedReport,
|
||||
ExchangeSlot.SerialNumber => QsoField.ReceivedNumber,
|
||||
ExchangeSlot.Zone => QsoField.Zone,
|
||||
ExchangeSlot.Section => QsoField.Section,
|
||||
ExchangeSlot.Check => QsoField.Check,
|
||||
ExchangeSlot.Precedence => QsoField.Precedence,
|
||||
ExchangeSlot.Exchange1 => QsoField.Exchange1,
|
||||
ExchangeSlot.MiscText => QsoField.MiscText,
|
||||
ExchangeSlot.Name => QsoField.Name,
|
||||
ExchangeSlot.Qth => QsoField.Qth,
|
||||
ExchangeSlot.GridSquare => QsoField.GridSquare,
|
||||
ExchangeSlot.Power => QsoField.Power,
|
||||
ExchangeSlot.Comment => QsoField.Comment,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(slot), slot, "no log column for this exchange slot"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// A field of a logged contact that can be edited in the log window. The names
|
||||
/// match the log columns.
|
||||
public enum QsoField
|
||||
{
|
||||
Time,
|
||||
Frequency,
|
||||
Mode,
|
||||
Call,
|
||||
SentReport,
|
||||
SentNumber,
|
||||
ReceivedReport,
|
||||
ReceivedNumber,
|
||||
Zone,
|
||||
Section,
|
||||
Precedence,
|
||||
Check,
|
||||
Exchange1,
|
||||
MiscText,
|
||||
Name,
|
||||
Qth,
|
||||
GridSquare,
|
||||
Power,
|
||||
Comment,
|
||||
Operator,
|
||||
}
|
||||
Reference in New Issue
Block a user