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:
@@ -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.
|
||||
|
||||
14
src/Nonemm.App/Dialogs/ConfirmDialog.axaml
Normal file
14
src/Nonemm.App/Dialogs/ConfirmDialog.axaml
Normal file
@@ -0,0 +1,14 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.ConfirmDialog"
|
||||
Width="380" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<DockPanel Margin="14">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
|
||||
Spacing="6" Margin="0,12,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" IsCancel="True" />
|
||||
<Button Name="ConfirmButton" Click="OnConfirm" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<TextBlock Name="QuestionText" TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
</Window>
|
||||
20
src/Nonemm.App/Dialogs/ConfirmDialog.axaml.cs
Normal file
20
src/Nonemm.App/Dialogs/ConfirmDialog.axaml.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// Asks a yes or no question before something that cannot be undone.
|
||||
public sealed partial class ConfirmDialog : Window
|
||||
{
|
||||
public ConfirmDialog(string title, string question, string confirmLabel)
|
||||
{
|
||||
InitializeComponent();
|
||||
Title = title;
|
||||
QuestionText.Text = question;
|
||||
ConfirmButton.Content = confirmLabel;
|
||||
}
|
||||
|
||||
private void OnConfirm(object? sender, RoutedEventArgs e) => Close(true);
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -1,24 +1,36 @@
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// One line of the log window. It carries the colour so the log is coloured by
|
||||
/// the same scorer as the entry window and the bandmap.
|
||||
public sealed record LogRow(Qso Qso, Verdict? Verdict)
|
||||
/// One line of the log window. The grid reaches the editable fields through the
|
||||
/// indexer, so the columns can follow the contest exchange instead of being
|
||||
/// fixed in the XAML. The colour comes from the same scorer as the entry window
|
||||
/// and the bandmap.
|
||||
public sealed class LogRow
|
||||
{
|
||||
public string Time => Qso.TimestampUtc.ToString("MM-dd HH:mm");
|
||||
private readonly Func<Qso, QsoField, string, bool> edit;
|
||||
|
||||
public string Call => Qso.Call.Text;
|
||||
public LogRow(Qso qso, Verdict? verdict, Func<Qso, QsoField, string, bool> edit)
|
||||
{
|
||||
Qso = qso;
|
||||
Verdict = verdict;
|
||||
this.edit = edit;
|
||||
}
|
||||
|
||||
public string Frequency => Qso.Frequency.Kilohertz.ToString("0.0");
|
||||
public Qso Qso { get; }
|
||||
|
||||
public string Mode => Qso.Mode.Name;
|
||||
public Verdict? Verdict { get; }
|
||||
|
||||
public string Sent => $"{Qso.SentReport} {SentExchange()}".Trim();
|
||||
|
||||
public string Received => $"{Qso.ReceivedReport} {ReceivedExchange()}".Trim();
|
||||
/// `name` is a `QsoField` name. A refused edit leaves the contact alone, so
|
||||
/// the grid reads the old value back and the cell reverts.
|
||||
public string this[string name]
|
||||
{
|
||||
get => QsoEditor.Read(Qso, Enum.Parse<QsoField>(name));
|
||||
set => edit(Qso, Enum.Parse<QsoField>(name), value);
|
||||
}
|
||||
|
||||
public string Country => Qso.CountryPrefix;
|
||||
|
||||
@@ -29,31 +41,5 @@ public sealed record LogRow(Qso Qso, Verdict? Verdict)
|
||||
Qso.IsMultiplier2 ? "2" : "",
|
||||
Qso.IsMultiplier3 ? "3" : "");
|
||||
|
||||
public string Operator => Qso.Operator;
|
||||
|
||||
public IBrush Colour => Verdicts.Colour(Verdict);
|
||||
|
||||
private string SentExchange() =>
|
||||
Qso.SentNumber > 0 ? Qso.SentNumber.ToString() : "";
|
||||
|
||||
private string ReceivedExchange()
|
||||
{
|
||||
List<string> parts = [];
|
||||
if (Qso.ReceivedNumber > 0)
|
||||
{
|
||||
parts.Add(Qso.ReceivedNumber.ToString());
|
||||
}
|
||||
if (Qso.Zone > 0)
|
||||
{
|
||||
parts.Add(Qso.Zone.ToString());
|
||||
}
|
||||
foreach (string value in new[] { Qso.Section, Qso.Exchange1, Qso.Name, Qso.GridSquare, Qso.MiscText })
|
||||
{
|
||||
if (value.Length > 0)
|
||||
{
|
||||
parts.Add(value);
|
||||
}
|
||||
}
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,13 @@
|
||||
Title="Log" Width="1000" Height="420">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
|
||||
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
||||
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
|
||||
x:DataType="local:LogRow">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Time" Binding="{Binding Time}" Width="125" />
|
||||
<DataGridTextColumn Header="Freq" Binding="{Binding Frequency}" Width="95" />
|
||||
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" Width="60" />
|
||||
<DataGridTextColumn Header="Call" Binding="{Binding Call}" Width="120"
|
||||
Foreground="{Binding Colour}" />
|
||||
<DataGridTextColumn Header="Sent" Binding="{Binding Sent}" Width="100" />
|
||||
<DataGridTextColumn Header="Received" Binding="{Binding Received}" Width="160" />
|
||||
<DataGridTextColumn Header="Cty" Binding="{Binding Country}" Width="70" />
|
||||
<DataGridTextColumn Header="Pts" Binding="{Binding Points}" Width="60" />
|
||||
<DataGridTextColumn Header="Mult" Binding="{Binding Multipliers}" Width="70" />
|
||||
<DataGridTextColumn Header="Op" Binding="{Binding Operator}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
<DataGrid Name="Rows" GridLinesVisibility="Horizontal" CanUserSortColumns="False"
|
||||
FontSize="12" FontFamily="monospace">
|
||||
<DataGrid.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
|
||||
</ContextMenu>
|
||||
</DataGrid.ContextMenu>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Dialogs;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The contacts of the contest in progress, newest last.
|
||||
/// The contacts of the contest in progress, newest last. Double-click a cell to
|
||||
/// change it; the Delete key removes the contact.
|
||||
public sealed partial class LogWindow : RefreshableWindow
|
||||
{
|
||||
/// Roughly the width of one character of the grid font, in device pixels.
|
||||
private const double CharacterWidth = 8;
|
||||
|
||||
private readonly AppSession session;
|
||||
private string builtFor = "";
|
||||
private string message = "";
|
||||
|
||||
public LogWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
Rows.KeyDown += OnKeyDown;
|
||||
Rows.LoadingRow += (_, e) =>
|
||||
{
|
||||
if (e.Row.DataContext is LogRow row)
|
||||
{
|
||||
e.Row.Foreground = row.Colour;
|
||||
}
|
||||
};
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
if (session.Logging is null)
|
||||
@@ -23,17 +42,100 @@ public sealed partial class LogWindow : RefreshableWindow
|
||||
SummaryText.Text = "no contest is open";
|
||||
return;
|
||||
}
|
||||
BuildColumns(session.Logging.Editor);
|
||||
List<LogRow> rows = session.Logging.Log.Qsos
|
||||
.Select(q => new LogRow(q, VerdictFor(q)))
|
||||
.Select(q => new LogRow(q, VerdictFor(q), Edit))
|
||||
.ToList();
|
||||
Rows.ItemsSource = rows;
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
Rows.ScrollIntoView(rows[^1], null);
|
||||
}
|
||||
SummaryText.Text =
|
||||
$"{session.Logging.Log.Tally.Qsos} contacts · {session.Logging.Log.Tally.Points} points · " +
|
||||
$"{session.Logging.Log.Tally.TotalMultipliers} multipliers · score {session.Logging.Log.TotalScore:N0}";
|
||||
SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging);
|
||||
message = "";
|
||||
}
|
||||
|
||||
private static string Summary(LoggingSession logging) =>
|
||||
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
|
||||
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
|
||||
|
||||
/// The columns follow the contest exchange, so they are rebuilt when a
|
||||
/// different contest is opened.
|
||||
private void BuildColumns(QsoEditor editor)
|
||||
{
|
||||
string wanted = string.Join('|', editor.Columns.Select(c => c.Label));
|
||||
if (wanted == builtFor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
builtFor = wanted;
|
||||
Rows.Columns.Clear();
|
||||
foreach (QsoColumn column in editor.Columns)
|
||||
{
|
||||
Rows.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = column.Label,
|
||||
Width = new DataGridLength(column.Width * CharacterWidth),
|
||||
Binding = new Binding($"[{column.Field}]") { Mode = BindingMode.TwoWay },
|
||||
});
|
||||
}
|
||||
Rows.Columns.Add(ReadOnlyColumn("Cty", nameof(LogRow.Country), 6));
|
||||
Rows.Columns.Add(ReadOnlyColumn("Pts", nameof(LogRow.Points), 4));
|
||||
Rows.Columns.Add(ReadOnlyColumn("Mult", nameof(LogRow.Multipliers), 5));
|
||||
}
|
||||
|
||||
private static DataGridTextColumn ReadOnlyColumn(string header, string property, int characters) =>
|
||||
new()
|
||||
{
|
||||
Header = header,
|
||||
IsReadOnly = true,
|
||||
Width = new DataGridLength(characters * CharacterWidth),
|
||||
Binding = new Binding(property),
|
||||
};
|
||||
|
||||
/// True when the edit was taken. A refused edit puts the reason in the
|
||||
/// summary line and the cell falls back to what it held before.
|
||||
private bool Edit(Qso qso, QsoField field, string text)
|
||||
{
|
||||
if (session.Logging is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
QsoEdit edit = session.Logging.Edit(qso.Id, field, text);
|
||||
if (edit.IsAccepted)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
SummaryText.Text = edit.Error;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Delete && e.Source is not TextBox)
|
||||
{
|
||||
OnDelete(sender, new RoutedEventArgs());
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDelete(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (session.Logging is null || Rows.SelectedItem is not LogRow row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ConfirmDialog dialog = new(
|
||||
"Delete contact",
|
||||
$"Delete {row.Qso.Call.Text} at {row.Qso.TimestampUtc:yyyy-MM-dd HH:mm:ss}? " +
|
||||
"The other stations are told to delete it too.",
|
||||
"Delete");
|
||||
if (await dialog.ShowDialog<bool>(this))
|
||||
{
|
||||
session.Logging.Delete(row.Qso.Id);
|
||||
message = $"{row.Qso.Call.Text} deleted";
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// A logged contact is coloured by what it turned out to be worth, not by
|
||||
|
||||
Reference in New Issue
Block a user