Add the edit contact dialog
Ctrl+Y from the entry window, or Enter on a row in the log, opens the whole contact in one dialog. It reaches the fields the log has no column for: QSX frequency, name, QTH, comment, grid, power, radio number, run position, and the country and WPX prefixes. Previous and Next walk the log without closing and offer to save first. The dialog checks values through the same QsoEditor the log columns use, so a zone the contest will not take is refused the same way in both places. QsoEdit now names the field that was refused, so the dialog can put the cursor back in the right box, and QsoEditor.ApplyAll changes several fields at once. Points and the multiplier flags are shown but not editable. They are worked out from the rules every time the log changes, so a typed value would be lost on the next edit. N1MM lets you type them; we do not, rather than offering a box that does nothing. A contact's own country prefix now wins over the country file when scoring. Without this, correcting the country in the dialog would change what the log shows and what Cabrillo says, but not the score. LoggingSession.Update now broadcasts the change like Edit does, so a contact saved from the dialog reaches the other stations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
14
README.md
14
README.md
@@ -45,7 +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 |
|
||||
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes 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 |
|
||||
@@ -107,6 +107,18 @@ 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.
|
||||
|
||||
**Edit → Edit Last Contact**, Ctrl+Y, or Enter on a row in the log opens the
|
||||
whole contact in one dialog. That is where the fields with no log column live:
|
||||
QSX frequency, name, QTH, comment, grid, power, radio number, run position, and
|
||||
the country and WPX prefixes. ◀ and ▶ walk the log without closing, offering to
|
||||
save first. Points and the multiplier flags are shown but cannot be typed in —
|
||||
they are worked out from the rules every time the log changes, so anything typed
|
||||
there would be overwritten on the next edit.
|
||||
|
||||
Correcting the country prefix by hand changes the score. The country file is a
|
||||
best guess for calls it has no rule for, so what the contact says now wins over
|
||||
what the file says.
|
||||
|
||||
### Networked stations
|
||||
|
||||
**Config → Network** names this station and lists the others. Each contact is
|
||||
|
||||
29
src/Nonemm.App/Dialogs/EditContactDialog.axaml
Normal file
29
src/Nonemm.App/Dialogs/EditContactDialog.axaml
Normal file
@@ -0,0 +1,29 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.EditContactDialog"
|
||||
Title="Edit contact" Width="640" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<DockPanel Margin="14">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="6" Margin="0,12,0,0">
|
||||
<Button Content="◀" Click="OnPrevious" ToolTip.Tip="Previous contact" />
|
||||
<Button Content="▶" Click="OnNext" ToolTip.Tip="Next contact" />
|
||||
<Button Content="Delete" Click="OnDelete" />
|
||||
<Panel Width="120" />
|
||||
<Button Content="Close" Click="OnClose" IsCancel="True" />
|
||||
<Button Content="Update" Click="OnUpdate" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<TextBlock DockPanel.Dock="Bottom" Name="MessageText" Margin="0,10,0,0"
|
||||
FontSize="11" TextWrapping="Wrap" />
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Name="PositionText" FontSize="11" Opacity="0.75" />
|
||||
<Grid Name="ContactFields" ColumnDefinitions="Auto,*,Auto,*" />
|
||||
<TextBlock Text="Contest" FontWeight="Bold" Margin="0,6,0,0" />
|
||||
<Grid Name="ExchangeFields" ColumnDefinitions="Auto,*,Auto,*" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<CheckBox Name="RunBox" Content="Ran this one" />
|
||||
<CheckBox Name="ClaimedBox" Content="Counts towards the score" />
|
||||
</StackPanel>
|
||||
<TextBlock Name="DerivedText" FontSize="11" Opacity="0.75" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
255
src/Nonemm.App/Dialogs/EditContactDialog.axaml.cs
Normal file
255
src/Nonemm.App/Dialogs/EditContactDialog.axaml.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// One logged contact with every field on it, including the ones the log window
|
||||
/// has no column for. Previous and Next walk the log without closing.
|
||||
public sealed partial class EditContactDialog : Window
|
||||
{
|
||||
private static readonly QsoField[] ContactRow =
|
||||
[
|
||||
QsoField.Time, QsoField.Call,
|
||||
QsoField.Frequency, QsoField.QsxFrequency,
|
||||
QsoField.Mode, QsoField.Operator,
|
||||
QsoField.SentReport, QsoField.ReceivedReport,
|
||||
QsoField.Name, QsoField.Qth,
|
||||
QsoField.RadioNumber, QsoField.RunPosition,
|
||||
];
|
||||
|
||||
private static readonly QsoField[] ExchangeRow =
|
||||
[
|
||||
QsoField.SentNumber, QsoField.ReceivedNumber,
|
||||
QsoField.Zone, QsoField.Section,
|
||||
QsoField.Precedence, QsoField.Check,
|
||||
QsoField.Exchange1, QsoField.MiscText,
|
||||
QsoField.GridSquare, QsoField.Power,
|
||||
QsoField.CountryPrefix, QsoField.WpxPrefix,
|
||||
];
|
||||
|
||||
private readonly LoggingSession logging;
|
||||
private readonly Dictionary<QsoField, TextBox> boxes = [];
|
||||
private int at;
|
||||
|
||||
public EditContactDialog(LoggingSession logging, string contactId)
|
||||
{
|
||||
this.logging = logging;
|
||||
InitializeComponent();
|
||||
BuildFields(ContactFields, ContactRow);
|
||||
BuildFields(ExchangeFields, ExchangeRow);
|
||||
AddWideField(ExchangeFields, QsoField.Comment);
|
||||
at = Math.Max(0, IndexOf(contactId));
|
||||
Display(Current);
|
||||
}
|
||||
|
||||
private Qso Current => logging.Log.Qsos[at];
|
||||
|
||||
private int IndexOf(string id)
|
||||
{
|
||||
for (int index = 0; index < logging.Log.Qsos.Count; index++)
|
||||
{
|
||||
if (logging.Log.Qsos[index].Id == id)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void BuildFields(Grid grid, IReadOnlyList<QsoField> fields)
|
||||
{
|
||||
for (int index = 0; index < fields.Count; index++)
|
||||
{
|
||||
int row = index / 2;
|
||||
int column = index % 2 == 0 ? 0 : 2;
|
||||
if (column == 0)
|
||||
{
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
}
|
||||
grid.Children.Add(LabelFor(fields[index], row, column));
|
||||
grid.Children.Add(BoxFor(fields[index], row, column + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private void AddWideField(Grid grid, QsoField field)
|
||||
{
|
||||
int row = grid.RowDefinitions.Count;
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
grid.Children.Add(LabelFor(field, row, 0));
|
||||
TextBox box = BoxFor(field, row, 1);
|
||||
Grid.SetColumnSpan(box, 3);
|
||||
}
|
||||
|
||||
private static TextBlock LabelFor(QsoField field, int row, int column)
|
||||
{
|
||||
TextBlock label = new()
|
||||
{
|
||||
Text = Label(field),
|
||||
Margin = new Avalonia.Thickness(0, 0, 8, 4),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
Grid.SetRow(label, row);
|
||||
Grid.SetColumn(label, column);
|
||||
return label;
|
||||
}
|
||||
|
||||
private TextBox BoxFor(QsoField field, int row, int column)
|
||||
{
|
||||
TextBox box = new() { Margin = new Avalonia.Thickness(0, 0, 12, 4) };
|
||||
Grid.SetRow(box, row);
|
||||
Grid.SetColumn(box, column);
|
||||
boxes[field] = box;
|
||||
return box;
|
||||
}
|
||||
|
||||
private void Display(Qso qso)
|
||||
{
|
||||
foreach ((QsoField field, TextBox box) in boxes)
|
||||
{
|
||||
box.Text = QsoEditor.Read(qso, field);
|
||||
}
|
||||
RunBox.IsChecked = qso.IsRunQso;
|
||||
ClaimedBox.IsChecked = qso.IsClaimed;
|
||||
PositionText.Text = $"contact {at + 1} of {logging.Log.Qsos.Count}";
|
||||
DerivedText.Text =
|
||||
$"band {qso.Band?.Name ?? "off band"} · continent {Or(qso.Continent)} · " +
|
||||
$"station prefix {Or(qso.StationPrefix)} · {qso.Points} points · " +
|
||||
$"multipliers {Or(Multipliers(qso))}. Points and multipliers are worked out " +
|
||||
"from the rules every time the log changes, so they cannot be typed in here.";
|
||||
MessageText.Text = "";
|
||||
}
|
||||
|
||||
private static string Or(string text) => text.Length > 0 ? text : "none";
|
||||
|
||||
private static string Multipliers(Qso qso) => string.Concat(
|
||||
qso.IsMultiplier1 ? "1" : "",
|
||||
qso.IsMultiplier2 ? "2" : "",
|
||||
qso.IsMultiplier3 ? "3" : "");
|
||||
|
||||
/// The fields whose box no longer matches the contact.
|
||||
private List<KeyValuePair<QsoField, string>> Changes(Qso qso) =>
|
||||
[.. boxes
|
||||
.Where(pair => (pair.Value.Text ?? "") != QsoEditor.Read(qso, pair.Key))
|
||||
.Select(pair => new KeyValuePair<QsoField, string>(pair.Key, pair.Value.Text ?? ""))];
|
||||
|
||||
private bool HasChanges() =>
|
||||
Changes(Current).Count > 0
|
||||
|| RunBox.IsChecked != Current.IsRunQso
|
||||
|| ClaimedBox.IsChecked != Current.IsClaimed;
|
||||
|
||||
/// True when the contact was saved. A value the contest will not take stops
|
||||
/// the save and puts the cursor back in the box that holds it.
|
||||
private bool Save()
|
||||
{
|
||||
Qso before = Current;
|
||||
QsoEdit edit = logging.Editor.ApplyAll(before, Changes(before));
|
||||
if (edit.Result is null)
|
||||
{
|
||||
MessageText.Text = edit.Error;
|
||||
if (edit.Field is { } field && boxes.TryGetValue(field, out TextBox? box))
|
||||
{
|
||||
box.Focus();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
logging.Update(edit.Result with
|
||||
{
|
||||
IsRunQso = RunBox.IsChecked == true,
|
||||
IsClaimed = ClaimedBox.IsChecked == true,
|
||||
});
|
||||
// editing the time re-sorts the log, so find the contact again
|
||||
at = IndexOf(before.Id);
|
||||
Display(Current);
|
||||
MessageText.Text = $"{Current.Call.Text} updated";
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnUpdate(object? sender, RoutedEventArgs e) => Save();
|
||||
|
||||
private async void OnPrevious(object? sender, RoutedEventArgs e) => await Move(-1);
|
||||
|
||||
private async void OnNext(object? sender, RoutedEventArgs e) => await Move(1);
|
||||
|
||||
private async Task Move(int by)
|
||||
{
|
||||
int wanted = at + by;
|
||||
if (wanted < 0 || wanted >= logging.Log.Qsos.Count)
|
||||
{
|
||||
MessageText.Text = by < 0 ? "this is the first contact" : "this is the last contact";
|
||||
return;
|
||||
}
|
||||
if (!await KeepChanges())
|
||||
{
|
||||
return;
|
||||
}
|
||||
at = wanted;
|
||||
Display(Current);
|
||||
}
|
||||
|
||||
/// Offers to save before leaving the contact. False means stay where we are.
|
||||
private async Task<bool> KeepChanges()
|
||||
{
|
||||
if (!HasChanges())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
ConfirmDialog dialog = new(
|
||||
"Save contact",
|
||||
$"Save the changes to {Current.Call.Text}?",
|
||||
"Save");
|
||||
return !await dialog.ShowDialog<bool>(this) || Save();
|
||||
}
|
||||
|
||||
private async void OnDelete(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Qso qso = Current;
|
||||
ConfirmDialog dialog = new(
|
||||
"Delete contact",
|
||||
$"Delete {qso.Call.Text} at {qso.TimestampUtc:yyyy-MM-dd HH:mm:ss}? " +
|
||||
"The other stations are told to delete it too.",
|
||||
"Delete");
|
||||
if (!await dialog.ShowDialog<bool>(this))
|
||||
{
|
||||
return;
|
||||
}
|
||||
logging.Delete(qso.Id);
|
||||
if (logging.Log.Qsos.Count == 0)
|
||||
{
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
at = Math.Min(at, logging.Log.Qsos.Count - 1);
|
||||
Display(Current);
|
||||
MessageText.Text = $"{qso.Call.Text} deleted";
|
||||
}
|
||||
|
||||
private async void OnClose(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (await KeepChanges())
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Label(QsoField field) => field switch
|
||||
{
|
||||
QsoField.Time => "Time UTC",
|
||||
QsoField.Frequency => "Frequency kHz",
|
||||
QsoField.QsxFrequency => "QSX kHz",
|
||||
QsoField.SentReport => "Report sent",
|
||||
QsoField.ReceivedReport => "Report received",
|
||||
QsoField.SentNumber => "Number sent",
|
||||
QsoField.ReceivedNumber => "Number received",
|
||||
QsoField.GridSquare => "Grid square",
|
||||
QsoField.MiscText => "Misc",
|
||||
QsoField.CountryPrefix => "Country prefix",
|
||||
QsoField.WpxPrefix => "WPX prefix",
|
||||
QsoField.RadioNumber => "Radio",
|
||||
QsoField.RunPosition => "Run position",
|
||||
QsoField.Qth => "QTH",
|
||||
_ => field.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -176,6 +176,16 @@ public sealed partial class EntryWindow
|
||||
|
||||
private void OnExit(object? sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private async void OnEditLastContact(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Logging is null || Logging.Log.Qsos.Count == 0)
|
||||
{
|
||||
Status("there is nothing in the log yet");
|
||||
return;
|
||||
}
|
||||
await new EditContactDialog(Logging, Logging.Log.Qsos[^1].Id).ShowDialog(this);
|
||||
}
|
||||
|
||||
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
|
||||
|
||||
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
<Separator />
|
||||
<MenuItem Header="E_xit" Click="OnExit" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Edit">
|
||||
<MenuItem Header="Edit _Last Contact…" Click="OnEditLastContact" InputGesture="Ctrl+Y" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="_Log" Click="OnShowLog" />
|
||||
<MenuItem Header="_Check" Click="OnShowCheck" />
|
||||
|
||||
@@ -179,6 +179,10 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
|
||||
break;
|
||||
case Key.Y when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
OnEditLastContact(this, new RoutedEventArgs());
|
||||
break;
|
||||
case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
ToggleRun();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
FontSize="12" FontFamily="monospace">
|
||||
<DataGrid.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Edit contact…" Click="OnEditContact" InputGesture="Enter" />
|
||||
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
|
||||
</ContextMenu>
|
||||
</DataGrid.ContextMenu>
|
||||
|
||||
@@ -112,11 +112,30 @@ public sealed partial class LogWindow : RefreshableWindow
|
||||
|
||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Delete && e.Source is not TextBox)
|
||||
if (e.Source is TextBox)
|
||||
{
|
||||
OnDelete(sender, new RoutedEventArgs());
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Delete:
|
||||
e.Handled = true;
|
||||
OnDelete(sender, new RoutedEventArgs());
|
||||
break;
|
||||
case Key.Enter:
|
||||
e.Handled = true;
|
||||
OnEditContact(sender, new RoutedEventArgs());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnEditContact(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (session.Logging is null || Rows.SelectedItem is not LogRow row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await new EditContactDialog(session.Logging, row.Qso.Id).ShowDialog(this);
|
||||
}
|
||||
|
||||
private async void OnDelete(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -11,9 +11,14 @@ public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me)
|
||||
|
||||
public ModeCategory ModeCategory => Qso.Mode.Category;
|
||||
|
||||
public string Continent => Country?.Continent ?? Qso.Continent;
|
||||
/// What the contact says wins over the country file. The two agree unless
|
||||
/// the operator corrected the country by hand, and then the correction is
|
||||
/// the point.
|
||||
public string Continent =>
|
||||
Qso.Continent.Length > 0 ? Qso.Continent : Country?.Continent ?? "";
|
||||
|
||||
public string CountryPrefix => Country?.Entity.PrimaryPrefix ?? Qso.CountryPrefix;
|
||||
public string CountryPrefix =>
|
||||
Qso.CountryPrefix.Length > 0 ? Qso.CountryPrefix : Country?.Entity.PrimaryPrefix ?? "";
|
||||
|
||||
public int CqZone => Country?.CqZone ?? Qso.Zone;
|
||||
|
||||
|
||||
@@ -133,26 +133,29 @@ public sealed class LoggingSession
|
||||
|
||||
/// 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)
|
||||
public QsoEdit Edit(string id, QsoField field, string text) =>
|
||||
EditWith(id, before => Editor.Apply(before, field, text));
|
||||
|
||||
/// Replaces a logged contact with one the caller has already built and
|
||||
/// checked. The edit contact dialog changes many fields at once and comes
|
||||
/// in this way.
|
||||
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
|
||||
|
||||
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
|
||||
{
|
||||
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);
|
||||
QsoEdit edit = change(before);
|
||||
if (edit.Result is not null)
|
||||
{
|
||||
Update(edit.Result);
|
||||
store.Update(edit.Result);
|
||||
Log.Replace(edit.Result);
|
||||
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
return edit;
|
||||
}
|
||||
|
||||
public void Update(Qso qso)
|
||||
{
|
||||
store.Update(qso);
|
||||
Log.Replace(qso);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int NextSentNumber() =>
|
||||
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ 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)
|
||||
/// `Field` names the field that was refused, so a dialog can put the cursor
|
||||
/// back in the right box.
|
||||
public sealed record QsoEdit(Qso? Result, string Error, QsoField? Field = null)
|
||||
{
|
||||
public static QsoEdit Accept(Qso qso) => new(qso, "");
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ public sealed class QsoEditor
|
||||
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.Frequency => Kilohertz(qso.Frequency),
|
||||
QsoField.QsxFrequency => qso.QsxFrequency.Hertz == 0 ? "" : Kilohertz(qso.QsxFrequency),
|
||||
QsoField.Mode => qso.Mode.Name,
|
||||
QsoField.Call => qso.Call.Text,
|
||||
QsoField.SentReport => qso.SentReport,
|
||||
@@ -47,12 +48,39 @@ public sealed class QsoEditor
|
||||
QsoField.Power => qso.Power,
|
||||
QsoField.Comment => qso.Comment,
|
||||
QsoField.Operator => qso.Operator,
|
||||
QsoField.CountryPrefix => qso.CountryPrefix,
|
||||
QsoField.WpxPrefix => qso.WpxPrefix,
|
||||
QsoField.RadioNumber => Digits(qso.RadioNumber),
|
||||
QsoField.RunPosition => Digits(qso.RunPosition),
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// 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)
|
||||
{
|
||||
QsoEdit edit = Applied(qso, field, text);
|
||||
return edit.IsAccepted ? edit : edit with { Field = field };
|
||||
}
|
||||
|
||||
/// Applies several fields in one go, stopping at the first value the
|
||||
/// contest will not take.
|
||||
public QsoEdit ApplyAll(Qso qso, IEnumerable<KeyValuePair<QsoField, string>> values)
|
||||
{
|
||||
Qso changed = qso;
|
||||
foreach ((QsoField field, string text) in values)
|
||||
{
|
||||
QsoEdit edit = Apply(changed, field, text);
|
||||
if (edit.Result is null)
|
||||
{
|
||||
return edit;
|
||||
}
|
||||
changed = edit.Result;
|
||||
}
|
||||
return QsoEdit.Accept(changed);
|
||||
}
|
||||
|
||||
private QsoEdit Applied(Qso qso, QsoField field, string text)
|
||||
{
|
||||
string value = Normalize(field, text);
|
||||
if (KindError(field, value) is { } refused)
|
||||
@@ -62,7 +90,8 @@ public sealed class QsoEditor
|
||||
return field switch
|
||||
{
|
||||
QsoField.Time => WithTime(qso, value),
|
||||
QsoField.Frequency => WithFrequency(qso, value),
|
||||
QsoField.Frequency => WithFrequency(value, f => qso with { Frequency = f }, allowEmpty: false),
|
||||
QsoField.QsxFrequency => WithFrequency(value, f => qso with { QsxFrequency = f }, allowEmpty: true),
|
||||
QsoField.Mode => WithMode(qso, value),
|
||||
QsoField.Call => WithCall(qso, value),
|
||||
QsoField.SentReport => QsoEdit.Accept(qso with { SentReport = value }),
|
||||
@@ -81,6 +110,10 @@ public sealed class QsoEditor
|
||||
QsoField.Power => QsoEdit.Accept(qso with { Power = value }),
|
||||
QsoField.Comment => QsoEdit.Accept(qso with { Comment = value }),
|
||||
QsoField.Operator => QsoEdit.Accept(qso with { Operator = value }),
|
||||
QsoField.CountryPrefix => QsoEdit.Accept(qso with { CountryPrefix = value }),
|
||||
QsoField.WpxPrefix => QsoEdit.Accept(qso with { WpxPrefix = value }),
|
||||
QsoField.RadioNumber => Count(value, n => qso with { RadioNumber = n }),
|
||||
QsoField.RunPosition => Count(value, n => qso with { RunPosition = n }),
|
||||
_ => QsoEdit.Refuse($"{field} cannot be edited"),
|
||||
};
|
||||
}
|
||||
@@ -118,14 +151,18 @@ public sealed class QsoEditor
|
||||
return QsoEdit.Accept(qso with { TimestampUtc = when });
|
||||
}
|
||||
|
||||
private static QsoEdit WithFrequency(Qso qso, string value)
|
||||
private static QsoEdit WithFrequency(string value, Func<Frequency, Qso> set, bool allowEmpty)
|
||||
{
|
||||
if (value.Length == 0 && allowEmpty)
|
||||
{
|
||||
return QsoEdit.Accept(set(Frequency.Zero));
|
||||
}
|
||||
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) });
|
||||
return QsoEdit.Accept(set(Frequency.FromKilohertz(kilohertz)));
|
||||
}
|
||||
|
||||
private static QsoEdit WithMode(Qso qso, string value)
|
||||
@@ -178,6 +215,9 @@ public sealed class QsoEditor
|
||||
&& number >= low
|
||||
&& number <= high;
|
||||
|
||||
private static string Kilohertz(Frequency frequency) =>
|
||||
frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Digits(int value) => value > 0 ? value.ToString(CultureInfo.InvariantCulture) : "";
|
||||
|
||||
private static IReadOnlyList<QsoColumn> BuildColumns(
|
||||
|
||||
@@ -6,6 +6,7 @@ public enum QsoField
|
||||
{
|
||||
Time,
|
||||
Frequency,
|
||||
QsxFrequency,
|
||||
Mode,
|
||||
Call,
|
||||
SentReport,
|
||||
@@ -24,4 +25,8 @@ public enum QsoField
|
||||
Power,
|
||||
Comment,
|
||||
Operator,
|
||||
CountryPrefix,
|
||||
WpxPrefix,
|
||||
RadioNumber,
|
||||
RunPosition,
|
||||
}
|
||||
|
||||
@@ -209,6 +209,21 @@ public class LoggingSessionTests
|
||||
Assert.Equal("JA2XYZ", change?.Qso.Call.Text);
|
||||
}
|
||||
|
||||
/// The country file gets a call wrong now and then. Correcting the country
|
||||
/// by hand has to change the score, or the correction is decoration.
|
||||
[Fact]
|
||||
public void CorrectingTheCountryChangesTheScore()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
Type(session, "JA1XYZ", "25");
|
||||
Qso logged = session.LogContact();
|
||||
Assert.Equal(3, session.Log.Tally.Points);
|
||||
|
||||
session.Edit(logged.Id, QsoField.CountryPrefix, "DL");
|
||||
|
||||
Assert.Equal(0, session.Log.Tally.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADeleteReportsTheContactThatWent()
|
||||
{
|
||||
|
||||
@@ -132,4 +132,54 @@ public class QsoEditorTests
|
||||
[Fact]
|
||||
public void AnEmptyNumberReadsBackAsAnEmptyColumn() =>
|
||||
Assert.Equal("", QsoEditor.Read(Contact() with { Zone = 0 }, QsoField.Zone));
|
||||
|
||||
[Fact]
|
||||
public void SeveralFieldsCanBeChangedAtOnce()
|
||||
{
|
||||
QsoEdit edit = CqWw().ApplyAll(Contact(),
|
||||
[
|
||||
new(QsoField.Call, "DL2XYZ"),
|
||||
new(QsoField.Zone, "14"),
|
||||
new(QsoField.Comment, "first QSO"),
|
||||
]);
|
||||
|
||||
Assert.Equal("DL2XYZ", edit.Result!.Call.Text);
|
||||
Assert.Equal(14, edit.Result.Zone);
|
||||
Assert.Equal("first QSO", edit.Result.Comment);
|
||||
Assert.Equal("DL", edit.Result.CountryPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFirstValueTheContestWillNotTakeStopsTheWholeChange()
|
||||
{
|
||||
QsoEdit edit = CqWw().ApplyAll(Contact(),
|
||||
[
|
||||
new(QsoField.Call, "DL2XYZ"),
|
||||
new(QsoField.Zone, "41"),
|
||||
]);
|
||||
|
||||
Assert.Null(edit.Result);
|
||||
Assert.Equal(QsoField.Zone, edit.Field);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARefusalNamesTheFieldItCameFrom() =>
|
||||
Assert.Equal(QsoField.Call, CqWw().Apply(Contact(), QsoField.Call, "12345").Field);
|
||||
|
||||
[Fact]
|
||||
public void AQsxFrequencyCanBeCleared() =>
|
||||
Assert.Equal(
|
||||
0,
|
||||
CqWw().Apply(Contact() with { QsxFrequency = Frequency.FromKilohertz(14_200) },
|
||||
QsoField.QsxFrequency, "").Result!.QsxFrequency.Hertz);
|
||||
|
||||
/// The working frequency is what the contact was made on, so it cannot be
|
||||
/// emptied the way the QSX frequency can.
|
||||
[Fact]
|
||||
public void TheWorkingFrequencyCannotBeCleared() =>
|
||||
Assert.False(CqWw().Apply(Contact(), QsoField.Frequency, "").IsAccepted);
|
||||
|
||||
[Fact]
|
||||
public void ACorrectedCountryPrefixIsKept() =>
|
||||
Assert.Equal("DL", CqWw().Apply(Contact(), QsoField.CountryPrefix, "dl").Result!.CountryPrefix);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user