diff --git a/README.md b/README.md index 22f4980..6886d12 100644 --- a/README.md +++ b/README.md @@ -953,12 +953,18 @@ tabs, the ones N1MM has: spot line, or "Jump to this spot" on the right-click menu, puts the radio there with the call in the entry window. Scrolling stops while the pointer is over the traffic, so a line can be read while the node keeps sending. -- **Clusters** keeps a list of nodes with their names, ports, passwords and the - commands to send after login. Connect and Disconnect are there, with automatic - logon, the call to log on with, and the keep-alive interval. **Download** under - it fetches the published list of nodes from +- **Clusters** keeps a list of favourite nodes: the callsign in bold, the + address and, under them, the sysop's note. Each favourite has its own password, + its own commands to send after login and its own call to log on with (empty + means the one on the Login & Spots tab). Clicking a favourite fills the boxes + so it can be edited, and **Save** writes the change back. **Autoconnect** marks + one favourite as the node to connect to when the program starts and moves it to + the top; on that favourite the button reads **Don't connect**, and with no + favourite marked nothing connects at start. **Download**, across the bottom, + fetches the published list of nodes from [NG3K](https://www.ng3k.com/misc/cluster.html) — around fifty nodes with the - sysop's call, the address and a note about each — and clicking one fills the + sysop's call, the address and a note about each — and refreshes the notes of the + favourites that are in it. Clicking a node in the list fills the boxes in. N1MM downloads its list from its own web service, which asks the operator to opt in to data collection; this reads a public page instead. - **Filters** says which spots reach the bandmap: bands, modes, beacons, busted @@ -970,7 +976,8 @@ tabs, the ones N1MM has: sends. - **Buttons** edits the twelve command buttons: a label and its commands, or back to the defaults. -- **Spot comment** is what goes out with the spots you send. +- **Login & Spots** holds the call to log on with, the keep-alive interval and the + comment that goes out with the spots you send. The buttons come from the settings; without any, they are `sh/dx`, `sh/dx/20`, `sh/wwv`, `sh/users`, `sh/c/n`, `help` and `bye`. A button holds what N1MM's @@ -1020,7 +1027,7 @@ retried. The last two hundred lines are kept, so a window opened mid-contest is not blank. Alt+P, or **Edit → Spot It**, puts the call being typed on the cluster at the -current frequency, with the comment from the Spot comment tab — which takes the +current frequency, with the comment from the Login & Spots tab — which takes the message macros too; with nothing typed it spots the last contact logged. The spot also goes straight onto our own bandmap rather than waiting to come back round from the node. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index ae24741..bd3da36 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -941,6 +941,30 @@ public sealed partial class AppSession : IDisposable radios.Clear(); } + private static string FirstNonEmpty(params string[] values) => + values.FirstOrDefault(v => v.Length > 0) ?? ""; + + /// The favourite the program connects to when it starts, if one is marked. + public StoredClusterNode? AutoConnectNode => Settings.ClusterNodes.FirstOrDefault(n => n.AutoConnect); + + /// Connects to the favourite marked for it. Without one, nothing connects. + public void ConnectAutoCluster() + { + if (AutoConnectNode is not { } node) + { + return; + } + Save(Settings with + { + ClusterHost = node.Host, + ClusterPort = node.Port, + ClusterPassword = node.Password, + ClusterCommands = node.Commands, + ClusterNodeLogonCall = node.LogonCall, + }); + ConnectCluster(); + } + public void ConnectCluster() { cluster?.Dispose(); @@ -948,7 +972,7 @@ public sealed partial class AppSession : IDisposable cluster = new ClusterClient( Settings.ClusterHost, Settings.ClusterPort, - Settings.ClusterLogonCall.Length > 0 ? Settings.ClusterLogonCall : Settings.Station.Callsign, + FirstNonEmpty(Settings.ClusterNodeLogonCall, Settings.ClusterLogonCall, Settings.Station.Callsign), Settings.ClusterCommands, Settings.ClusterPassword, autoLogon: Settings.ClusterAutoLogon, diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index d375ee2..8950731 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -33,12 +33,15 @@ public sealed record Settings /// tab. Connecting to one copies it into `ClusterHost` and the rest. public IReadOnlyList ClusterNodes { get; init; } = []; - /// Off for a node that wants the call typed in by hand. + /// Always on now; the telnet window no longer has a switch for it. public bool ClusterAutoLogon { get; init; } = true; /// The call sent at login, or empty for the station callsign. public string ClusterLogonCall { get; init; } = ""; + /// The call the node being used wants instead of that one, or empty. + public string ClusterNodeLogonCall { get; init; } = ""; + /// How often to send an empty line to a node that has said nothing. Nodes /// drop a connection that has been quiet for a quarter of an hour. public int ClusterKeepAliveMinutes { get; init; } = 4; @@ -147,8 +150,6 @@ public sealed record Settings public bool RadioEnabled { get; init; } - public bool ClusterEnabled { get; init; } - public string NetworkStationName { get; init; } = ""; public int NetworkPort { get; init; } = 12060; diff --git a/src/Nonemm.App/Configuration/StoredClusterNode.cs b/src/Nonemm.App/Configuration/StoredClusterNode.cs index 39b6a4c..f38c2b0 100644 --- a/src/Nonemm.App/Configuration/StoredClusterNode.cs +++ b/src/Nonemm.App/Configuration/StoredClusterNode.cs @@ -18,7 +18,26 @@ public sealed record StoredClusterNode /// Sent to this node after login, one command per entry. public IReadOnlyList Commands { get; init; } = []; - /// How the node reads in the favourites list. + /// The call sent at login to this node, or empty for the one set on the + /// Login & Spots tab. + public string LogonCall { get; init; } = ""; + + /// What the published list says about the node. It is refreshed when a new + /// list is downloaded. + public string Description { get; init; } = ""; + + /// The node the program connects to when it starts. At most one favourite + /// has it. + public bool AutoConnect { get; init; } + [JsonIgnore] - public string Label => Name.Length > 0 ? $"{Name} — {Host}:{Port}" : $"{Host}:{Port}"; + public string Address => $"{Host}:{Port}"; + + /// The first line of the favourites list: the node's callsign, or its host + /// when it has none. + [JsonIgnore] + public string Title => Name.Length > 0 ? Name : Host; + + [JsonIgnore] + public bool HasDescription => Description.Length > 0; } diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 91e9481..393be46 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -199,9 +199,9 @@ public sealed partial class EntryWindow : Window { yield return ("radios", session.ConnectRadios); } - if (session.Settings.ClusterEnabled) + if (session.AutoConnectNode is not null) { - yield return ("cluster", session.ConnectCluster); + yield return ("cluster", session.ConnectAutoCluster); } if (session.Settings.NetworkEnabled) { diff --git a/src/Nonemm.App/Windows/TelnetWindow.Clusters.cs b/src/Nonemm.App/Windows/TelnetWindow.Clusters.cs index daba44e..c5d69d2 100644 --- a/src/Nonemm.App/Windows/TelnetWindow.Clusters.cs +++ b/src/Nonemm.App/Windows/TelnetWindow.Clusters.cs @@ -7,7 +7,8 @@ using Nonemm.Spotting; namespace Nonemm.App.Windows; /// The Clusters tab: the nodes the operator keeps, the one being talked to, and -/// how to log on to it. +/// how to log on to it. The Login & Spots tab holds what is the same for every +/// node. public sealed partial class TelnetWindow { private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) }; @@ -15,22 +16,63 @@ public sealed partial class TelnetWindow private readonly List nodes = []; private readonly List published = []; - private void LoadClusters() + /// What the published list says about the node in the boxes, kept for when + /// it is saved as a favourite. + private string pendingDescription = ""; + + /// True while the favourites list is being filled in, so the selection it + /// makes does not overwrite the boxes. + private bool refreshingNodes; + + /// Fills the favourites, the boxes and the Login & Spots tab from the + /// settings. The list opens on `select`, or on the node the settings are + /// pointing at. + private void LoadClusters(StoredClusterNode? select = null) { - nodes.Clear(); - nodes.AddRange(session.Settings.ClusterNodes); - NodeList.ItemsSource = nodes.Select(n => n.Label).ToList(); - NodeList.SelectedIndex = nodes.FindIndex(Matches); + ShowNodes(select); Settings settings = session.Settings; - HostBox.Text = settings.ClusterHost; - PortBox.Text = settings.ClusterPort.ToString(CultureInfo.InvariantCulture); - PasswordBox.Text = settings.ClusterPassword; - CommandsBox.Text = string.Join("\n", settings.ClusterCommands); + if (NodeList.SelectedIndex < 0) + { + HostBox.Text = settings.ClusterHost; + PortBox.Text = settings.ClusterPort.ToString(CultureInfo.InvariantCulture); + PasswordBox.Text = settings.ClusterPassword; + NodeLogonBox.Text = settings.ClusterNodeLogonCall; + CommandsBox.Text = string.Join("\n", settings.ClusterCommands); + } LoadPublishedNodes(); - AutoLogonBox.IsChecked = settings.ClusterAutoLogon; LogonCallBox.Text = settings.ClusterLogonCall; - KeepAliveBox.Text = settings.ClusterKeepAliveMinutes.ToString(CultureInfo.InvariantCulture); + KeepAliveBox.Value = settings.ClusterKeepAliveMinutes; + SpotCommentBox.Text = settings.SpotComment; + UpdateNodeButtons(); + } + + /// Fills the list from `nodes` and puts the selection back. + private void ShowNodes(StoredClusterNode? select = null) + { + refreshingNodes = true; + nodes.Clear(); + nodes.AddRange(session.Settings.ClusterNodes); + NodeList.ItemsSource = nodes.ToList(); + NodeList.SelectedIndex = select is null + ? nodes.FindIndex(Matches) + : nodes.FindIndex(n => SameNode(n, select)); + refreshingNodes = false; + } + + private static bool SameNode(StoredClusterNode a, StoredClusterNode b) => + string.Equals(a.Host, b.Host, StringComparison.OrdinalIgnoreCase) && a.Port == b.Port; + + /// The save button says "Save" for a favourite being edited, and the + /// autoconnect button follows the flag of the selected one. + private void UpdateNodeButtons() + { + StoredClusterNode? selected = NodeList.SelectedIndex >= 0 && NodeList.SelectedIndex < nodes.Count + ? nodes[NodeList.SelectedIndex] + : null; + SaveNodeButton.Content = selected is null ? "Save to favourites" : "Save"; + AutoConnectButton.IsEnabled = selected is not null; + AutoConnectButton.Content = selected is { AutoConnect: true } ? "Don't connect" : "Autoconnect"; } /// True for the node the settings are pointing at, so the list opens on it. @@ -38,22 +80,32 @@ public sealed partial class TelnetWindow string.Equals(node.Host, session.Settings.ClusterHost, StringComparison.OrdinalIgnoreCase) && node.Port == session.Settings.ClusterPort; + /// A click on a favourite puts its fields in the boxes to be edited. private void OnNodePicked(object? sender, SelectionChangedEventArgs e) { - if (NodeList.SelectedIndex < 0 || NodeList.SelectedIndex >= nodes.Count) + if (refreshingNodes) { return; } - StoredClusterNode node = nodes[NodeList.SelectedIndex]; - NameBox.Text = node.Name; - HostBox.Text = node.Host; - PortBox.Text = node.Port.ToString(CultureInfo.InvariantCulture); - PasswordBox.Text = node.Password; - CommandsBox.Text = string.Join("\n", node.Commands); + if (NodeList.SelectedIndex >= 0 && NodeList.SelectedIndex < nodes.Count) + { + StoredClusterNode node = nodes[NodeList.SelectedIndex]; + PublishedNodeList.SelectedIndex = -1; + NameBox.Text = node.Name; + HostBox.Text = node.Host; + PortBox.Text = node.Port.ToString(CultureInfo.InvariantCulture); + PasswordBox.Text = node.Password; + NodeLogonBox.Text = node.LogonCall; + CommandsBox.Text = string.Join("\n", node.Commands); + pendingDescription = node.Description; + } + UpdateNodeButtons(); } - /// Adds what is in the boxes to the list, or replaces the entry that has - /// the same host and port. + /// With a favourite selected, saves the boxes over it, so its host can be + /// edited too. Otherwise adds a favourite, or replaces the one with the same + /// host and port. The auto-connect flag and the description stay with the + /// favourite. private void OnStoreNode(object? sender, RoutedEventArgs e) { StoredClusterNode node = new() @@ -62,16 +114,20 @@ public sealed partial class TelnetWindow Host = Host(), Port = Port(), Password = PasswordBox.Text ?? "", + LogonCall = (NodeLogonBox.Text ?? "").Trim(), Commands = Lines(CommandsBox.Text), + Description = pendingDescription, }; if (node.Host.Length == 0) { return; } - int at = nodes.FindIndex(n => - string.Equals(n.Host, node.Host, StringComparison.OrdinalIgnoreCase) && n.Port == node.Port); + int at = NodeList.SelectedIndex >= 0 && NodeList.SelectedIndex < nodes.Count + ? NodeList.SelectedIndex + : nodes.FindIndex(n => SameNode(n, node)); if (at >= 0) { + node = node with { AutoConnect = nodes[at].AutoConnect }; nodes[at] = node; } else @@ -79,7 +135,36 @@ public sealed partial class TelnetWindow nodes.Add(node); } session.Save(session.Settings with { ClusterNodes = [.. nodes] }); - LoadClusters(); + LoadClusters(node); + } + + /// Marks the selected favourite as the one to connect to at start and moves + /// it to the top, or takes the mark off when it already has it. + private void OnToggleAutoConnect(object? sender, RoutedEventArgs e) + { + if (NodeList.SelectedIndex < 0 || NodeList.SelectedIndex >= nodes.Count) + { + return; + } + StoredClusterNode chosen = nodes[NodeList.SelectedIndex]; + if (chosen.AutoConnect) + { + chosen = chosen with { AutoConnect = false }; + nodes[NodeList.SelectedIndex] = chosen; + } + else + { + nodes.RemoveAt(NodeList.SelectedIndex); + chosen = chosen with { AutoConnect = true }; + for (int i = 0; i < nodes.Count; i++) + { + nodes[i] = nodes[i] with { AutoConnect = false }; + } + nodes.Insert(0, chosen); + } + session.Save(session.Settings with { ClusterNodes = [.. nodes] }); + ShowNodes(chosen); + UpdateNodeButtons(); } private void OnRemoveNode(object? sender, RoutedEventArgs e) @@ -90,7 +175,9 @@ public sealed partial class TelnetWindow } nodes.RemoveAt(NodeList.SelectedIndex); session.Save(session.Settings with { ClusterNodes = [.. nodes] }); - LoadClusters(); + ShowNodes(); + NodeList.SelectedIndex = -1; + UpdateNodeButtons(); } /// The list downloaded from the published page, if one has been. Its @@ -126,11 +213,15 @@ public sealed partial class TelnetWindow return; } ClusterNode node = published[PublishedNodeList.SelectedIndex]; + NodeList.SelectedIndex = -1; NameBox.Text = node.Name; HostBox.Text = node.Host; PortBox.Text = node.Port.ToString(CultureInfo.InvariantCulture); PasswordBox.Text = ""; + NodeLogonBox.Text = ""; CommandsBox.Text = ""; + pendingDescription = node.Description; + UpdateNodeButtons(); } private async void OnDownloadNodes(object? sender, RoutedEventArgs e) @@ -147,6 +238,36 @@ public sealed partial class TelnetWindow Show($"*** {error.Message}", NoticeColour); } LoadPublishedNodes(); + UpdateDescriptions(); + } + + /// Takes the descriptions of the list just downloaded into the favourites + /// that are in it, matched by host and port or by name. + private void UpdateDescriptions() + { + bool changed = false; + for (int i = 0; i < nodes.Count; i++) + { + ClusterNode? listed = published.FirstOrDefault(p => + (string.Equals(p.Host, nodes[i].Host, StringComparison.OrdinalIgnoreCase) && p.Port == nodes[i].Port) + || (nodes[i].Name.Length > 0 && string.Equals(p.Name, nodes[i].Name, StringComparison.OrdinalIgnoreCase))); + if (listed is not null && listed.Description != nodes[i].Description) + { + nodes[i] = nodes[i] with { Description = listed.Description }; + changed = true; + } + } + if (!changed) + { + return; + } + StoredClusterNode? selected = NodeList.SelectedIndex >= 0 ? nodes[NodeList.SelectedIndex] : null; + session.Save(session.Settings with { ClusterNodes = [.. nodes] }); + ShowNodes(selected); + if (selected is not null) + { + pendingDescription = nodes[NodeList.SelectedIndex].Description; + } } /// `{CONN}name` connects to the favourite of that name rather than sending @@ -176,21 +297,22 @@ public sealed partial class TelnetWindow private void OnDisconnect(object? sender, RoutedEventArgs e) { Forget(session.Cluster); - session.Save(session.Settings with { ClusterEnabled = false }); session.DisconnectCluster(); Show("*** disconnected", NoticeColour); Refresh(); } - private void OnSaveOptions(object? sender, RoutedEventArgs e) + private void OnSaveLogin(object? sender, RoutedEventArgs e) { session.Save(session.Settings with { - ClusterAutoLogon = AutoLogonBox.IsChecked == true, ClusterLogonCall = (LogonCallBox.Text ?? "").Trim(), - ClusterKeepAliveMinutes = Minutes(KeepAliveBox.Text, session.Settings.ClusterKeepAliveMinutes), + ClusterKeepAliveMinutes = KeepAliveBox.Value is { } minutes + ? (int)minutes + : session.Settings.ClusterKeepAliveMinutes, + SpotComment = (SpotCommentBox.Text ?? "").Trim(), }); - Show("*** options saved — they apply on the next connection", NoticeColour); + Show("*** login and spot comment saved — the login applies on the next connection", NoticeColour); } /// Dials the node in the boxes and keeps it as the one to connect to on the @@ -210,7 +332,7 @@ public sealed partial class TelnetWindow ClusterPort = Port(), ClusterPassword = PasswordBox.Text ?? "", ClusterCommands = Lines(CommandsBox.Text), - ClusterEnabled = true, + ClusterNodeLogonCall = (NodeLogonBox.Text ?? "").Trim(), }); Traffic.Items.Clear(); session.ConnectCluster(); diff --git a/src/Nonemm.App/Windows/TelnetWindow.Filters.cs b/src/Nonemm.App/Windows/TelnetWindow.Filters.cs index d703e78..6d53472 100644 --- a/src/Nonemm.App/Windows/TelnetWindow.Filters.cs +++ b/src/Nonemm.App/Windows/TelnetWindow.Filters.cs @@ -5,8 +5,7 @@ using Nonemm.App.Configuration; namespace Nonemm.App.Windows; -/// The Filters and Spot comment tabs: which spots reach the bandmap, how long -/// they stay there, and what goes out with a spot the operator sends. +/// The Filters tab: which spots reach the bandmap. public sealed partial class TelnetWindow { /// The bands worth a tick box. The rest of the microwave bands would fill @@ -48,7 +47,6 @@ public sealed partial class TelnetWindow CallAreasBox.Text = string.Join(' ', filter.CallAreas); BlockedSpottersBox.Text = string.Join(' ', filter.BlockedSpotters); BlockedCallsBox.Text = string.Join(' ', filter.BlockedCalls); - SpotCommentBox.Text = session.Settings.SpotComment; } private void OnApplyFilters(object? sender, RoutedEventArgs e) @@ -74,12 +72,6 @@ public sealed partial class TelnetWindow Show("*** filters applied — they hold for the spots that arrive from now on", NoticeColour); } - private void OnSaveSpotComment(object? sender, RoutedEventArgs e) - { - session.Save(session.Settings with { SpotComment = (SpotCommentBox.Text ?? "").Trim() }); - Show("*** spot comment saved", NoticeColour); - } - private IReadOnlyList Modes() { List modes = []; diff --git a/src/Nonemm.App/Windows/TelnetWindow.axaml b/src/Nonemm.App/Windows/TelnetWindow.axaml index b8b0c7c..465d9c1 100644 --- a/src/Nonemm.App/Windows/TelnetWindow.axaml +++ b/src/Nonemm.App/Windows/TelnetWindow.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:Nonemm.App.Windows" xmlns:theming="using:Nonemm.App.Theming" + xmlns:config="using:Nonemm.App.Configuration" x:Class="Nonemm.App.Windows.TelnetWindow" Title="Telnet" Width="760" Height="520"> @@ -47,50 +48,65 @@ - - - - - -