diff --git a/Nonemm.slnx b/Nonemm.slnx index d7f39d1..839928a 100644 --- a/Nonemm.slnx +++ b/Nonemm.slnx @@ -21,5 +21,6 @@ + diff --git a/README.md b/README.md index aa76f7a..e8da5cc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ To work a contest: **Config → Station** for your callsign and zones, then **File → New Database** and **File → New Contest**. In the entry window, type a callsign and press **space** to move to the exchange, then **Enter** to log. Type a frequency into the callsign box and press Enter to change band. **View** -opens the log, check, bandmap, score and packet windows. +opens the log, check, bandmap, score and telnet windows. Everything that judges a station — the bar under the callsign box, a row in the check window, a spot on the bandmap, a line in the log — is coloured by the same @@ -44,10 +44,10 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests | | 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, exchange filled from a call history file | -| Windows | entry, log, check, bandmap, available mults and Qs, score summary, packet | +| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet | | 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 | one or two radios over hamlib `rigctld`, split, reconnecting on its own | -| Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station | +| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station | | Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | | Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros | @@ -92,9 +92,30 @@ mode stay where they were last typed. **Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a WinKeyer (a serial port), sets the speed, and edits the twelve function key -messages for CW and for phone. The macros are N1MM's: `{MYCALL}`, `{CALL}`, -`{EXCH}`, `{SENTRST}`, `{SENTNR}`, `#` for the serial number, and `{SENTRSTCUT}` -for cut numbers. Escape stops sending. +messages for CW and for phone. Escape stops sending. + +The macros are N1MM's, spelled the way N1MM's function-key documentation spells +them, so a `.mc` file written for either program says the same thing. The text +macros that are filled in: + +| | | +|---|---| +| `*`, `{MYCALL}` | your callsign | +| `!`, `{CALL}` | the call being worked, or the last one logged when the box is empty | +| `#` | the serial number for this contact, or the last one when the box is empty | +| `{LASTCALL}`, `{PREVNR}` | the call and the serial of the contact just logged | +| `{EXCH}` | the sent exchange from the contest setup | +| `{SENTRST}`, `{SENTRSTCUT}` | the report you send, plain or in cut numbers | +| `{NAME}`, `{NAMEANDSPACE}`, `{CHNAME}` | the other operator's name, from the entry window or the call history file | +| `{GRID}` / `{GRIDSQUARE}` | your grid / theirs | +| `{GRIDBEARING}`, `{REVGRIDBEARING}`, `{KMGRIDDISTANCE}` | bearing and distance between the two | +| `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW | +| `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station | +| `{TIMESTAMP}`, `{TIME2}` | the time now | + +N1MM's action macros — `{WIPE}`, `{LOG}`, `{RUN}`, the CAT and SO2R families — +are not run; they stand for nothing, so a message that holds one still sends the +right characters. ### Editing the log @@ -318,9 +339,57 @@ always 0, so 2190M and 630M are told apart by their edges alone. ### The DX cluster -**Config → Cluster** takes the node's address, a password for the few nodes that -ask for one, and the commands to send after login. Filters are the node's -business, so whatever goes in the command box is sent as typed and left alone. +**View → Telnet**, or **Config → Cluster**, opens the telnet window. It has four +tabs, the ones N1MM has: + +- **Telnet** shows the node's traffic as it arrived, spot lines in green and + what went out in blue. Type a command at the bottom, or press one of the + buttons; up and down walk back through what has been typed. Double clicking a + 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 + [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 + 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 + calls, stations outside the call history file, blacklisted spotters and calls, + whether to take spots only from your country, your continent or a list of + prefixes, and how long a spot stays on the bandmap. It also holds the preferred + spotters and the switch that randomises incoming CW spot frequencies. Nothing + is filtered out of the traffic itself; the operator sees everything the node + 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. + +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 +buttons hold: several commands separated by semicolons, the function-key message +macros — `sh/dx {MYCALL}` sends the station callsign — or `{CONN}` and the name +of a favourite, which connects to that node instead of sending anything. The +label takes the macros too, expanded once when the buttons load, so a label of +`{MYCALL}` reads as the callsign. Right-clicking a button opens the editor, and +its tooltip shows the command behind it. What is typed on the command line goes +out as typed, again as N1MM sends it. The one place this differs from N1MM: the +commands of one button go out together rather than a quarter of a second apart. + +Three of the filters are worth spelling out: + +- **Busted spots.** A spotted call the callsign database has never heard, but + which is one character away from a call it does know, is a miscopy and is + dropped. A call nothing resembles is kept, because that is what a new station + looks like. Without `MASTER.SCP` nothing is called busted. +- **Preferred spotters.** Spot lines from those spotters are written in bold, so + the spotters worth believing stand out. They are matched on the start of the + call, so `W3LPL` also covers `W3LPL-#`. Nothing is filtered by this. +- **Randomised frequencies.** Each incoming CW spot is moved thirty or sixty + hertz either way, which is what N1MM does, so the operator has to find the + station by ear. Phone and digital spots are left alone. The client speaks telnet properly: it answers the option negotiation instead of letting the control bytes turn up in the first lines of text, and it reads the @@ -328,12 +397,17 @@ login prompt out of a partial line, because nodes write `login: ` with no line ending. If no prompt arrives within ten seconds the callsign goes out anyway, which is what N1MM does. A connection that has heard nothing for four minutes gets a blank line, so the node does not drop it as idle. A dropped connection is -retried. +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 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. +current frequency, with the comment from the Spot comment 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. + +Filters are applied to the spots, not to the node: it is still worth filtering +at the node as well, because that is less traffic on the wire. ### Networked stations @@ -371,7 +445,7 @@ covered by plain unit tests. Working: logging a contest end to end, live dupe and multiplier checking, eight built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import, -the log, check, graphical bandmap, score and packet windows, editing and deleting logged +the log, check, graphical bandmap, score and telnet windows, editing and deleting logged contacts, radio control, DX cluster spots, contacts shared between networked stations, and CW keying. diff --git a/docs/unfinished.md b/docs/unfinished.md index e0ae10b..f875d95 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -59,6 +59,43 @@ and is listed as a US call now, and China was not split by call area yet. The counts a callsign it cannot place — `D1M` — as a multiplier with an empty value, and we do not. +**Telnet window: what N1MM has and this does not.** Left out: the special-calls +list, and saving received spots to a database — N1MM keeps those spots in its +admin database, which is not the log file the two programs share, so there is +nothing to be compatible with and nothing asks to read them back. The band plan +tab is not repeated either, because Config ▸ Sub bands already edits the same +numbers. + +The node list is downloaded from the public NG3K page rather than from N1MM's +own web service, which asks the operator to opt in to data collection and is +N1MM's to run. A page that changes shape would stop the download working; the +reading only needs `telnet://` links in a table, and the old list stays in place +when a download brings back something unreadable. + +**The action macros do nothing.** The text macros are N1MM's, spelled as its +function-key documentation spells them, and they are listed in the README. What +is missing is the other half of N1MM's table: the macros that run a program +command rather than standing for text — `{WIPE}`, `{LOG}`, `{ENTER}`, `{RUN}`, +`{S&P}`, `{SPOTME}`, `{TOGGLE}`, `{END}`, the `{CAT…}` and `{OTRSP…}` families, +the audio, rotator and call-stacking macros. They expand to nothing, so a +message that holds one sends the right characters and takes no action. + +They cannot be added to `MessageExpander` as it stands: it turns a template into +a string, and an action macro has to reach the entry window, the keyer or the +SO2R box, some of it after the message has been sent (that is what N1MM's +`{END}` is for). That wants a different shape — a list of steps rather than a +string. + +Two more text macros are out for want of the data behind them: `{LASTEXCH}`, +which N1MM only fills for ROPOCO and LZ Open, and `{OPERATOR}`, because there is +no operator separate from the station callsign here. `@`, which voices the +receive frequency from recorded letter files, waits on voice keying. + +**The busted-spot check is one character wide.** N1MM offers two. Every call one +character away from the spotted one is looked up in the callsign database, which +is a few hundred lookups per spot; two characters away is a few hundred thousand, +which is too much work per spot for the little it would add. + **Digital modes.** A contact can be logged as RTTY or another digital mode, and the contest rules score it, but there is no digital window: no decoding, no transmitting, no interface to fldigi, MMTTY or similar. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 6322344..8191331 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -27,6 +27,12 @@ public sealed class AppSession : IDisposable private AlternatingCq? alternating; private So2rBox? box; + /// Which cluster spots reach the bandmap. Rebuilt whenever the settings or + /// the support files change. + private SpotFilter spotFilter = new(); + + private readonly Random jitter = new(); + public AppSession(UserPaths paths, Settings settings) { Paths = paths; @@ -38,6 +44,7 @@ public sealed class AppSession : IDisposable Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList problems); UserDefinedContestProblems = problems; BandPlan = settings.ToBandPlan(); + ApplySpotSettings(); } public UserPaths Paths { get; } @@ -64,6 +71,11 @@ public sealed class AppSession : IDisposable /// The contest in progress: one log and one score, however many radios. public ContestSession? Logging { get; private set; } + /// The radio the operator is not on, or null at a one-radio station. The + /// message macros that pass a station to the other band need it. + public RadioPosition? Other(RadioPosition position) => + positions.FirstOrDefault(p => p.RadioNumber != position.RadioNumber); + /// One per radio, in radio-number order. There is always at least one, so /// the program works with no radio connected. public IReadOnlyList Positions => positions; @@ -111,6 +123,7 @@ public sealed class AppSession : IDisposable { Settings = settings; BandPlan = settings.ToBandPlan(); + ApplySpotSettings(); settings.Save(Paths.SettingsFile); Changed?.Invoke(this, EventArgs.Empty); } @@ -358,7 +371,7 @@ public sealed class AppSession : IDisposable } MoveToRadio(radioNumber); await PointTransmitAtAsync(radioNumber).ConfigureAwait(false); - await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false); + await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false); } /// Puts both radios in the headphones, or goes back to one. An operator @@ -433,10 +446,12 @@ public sealed class AppSession : IDisposable cluster = new ClusterClient( Settings.ClusterHost, Settings.ClusterPort, - Settings.Station.Callsign, + Settings.ClusterLogonCall.Length > 0 ? Settings.ClusterLogonCall : Settings.Station.Callsign, Settings.ClusterCommands, - Settings.ClusterPassword); - cluster.SpotArrived += (_, spot) => Bandmap.Add(spot); + Settings.ClusterPassword, + autoLogon: Settings.ClusterAutoLogon, + keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes))); + cluster.SpotArrived += (_, spot) => TakeSpot(spot); cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); cluster.Start(); } @@ -448,12 +463,30 @@ public sealed class AppSession : IDisposable Changed?.Invoke(this, EventArgs.Empty); } + /// A spot the node sent, kept or dropped by the filters on the telnet + /// window. + private void TakeSpot(Spot spot) + { + if (!spotFilter.Accepts(spot)) + { + return; + } + Bandmap.Add(Settings.RandomizeSpots ? SpotJitter.Shifted(spot, BandPlan, jitter) : spot); + } + + private void ApplySpotSettings() + { + spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History); + Bandmap.Lifetime = TimeSpan.FromMinutes(Math.Max(1, Settings.SpotTimeoutMinutes)); + } + public void ReloadSupportFiles() { Countries = LoadCountryFile(Paths.CountryFile); Calls = LoadCallDatabase(Paths.CallDatabaseFile); History = LoadCallHistory(Settings.CallHistoryFile); Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _); + ApplySpotSettings(); if (Logging is not null) { OpenContest(Logging.Instance.ContestNumber); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 9be36ff..7e8ec83 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text.Json; using Nonemm.Core; @@ -21,6 +22,37 @@ public sealed record Settings public IReadOnlyList ClusterCommands { get; init; } = []; + /// The nodes the operator keeps, listed on the telnet window's Clusters + /// 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. + public bool ClusterAutoLogon { get; init; } = true; + + /// The call sent at login, or empty for the station callsign. + public string ClusterLogonCall { 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; + + /// The buttons along the bottom of the telnet window. Empty means the ones + /// in `StoredTelnetButton.Default`. + public IReadOnlyList TelnetButtons { get; init; } = []; + + /// Which cluster spots reach the bandmap. + public StoredSpotFilter SpotFilter { get; init; } = new(); + + /// How long a spot stays on the bandmap. N1MM's own default is 60. + public int SpotTimeoutMinutes { get; init; } = 60; + + /// Moves each incoming CW spot by a few tens of hertz, so the operator has + /// to find the station by ear. N1MM calls it randomising. + public bool RandomizeSpots { get; init; } + + /// Sent with a spot the operator puts on the cluster with Alt+P. + public string SpotComment { get; init; } = ""; + /// One entry per radio, in radio-number order. A second radio makes the /// station SO2R. public IReadOnlyList Radios { get; init; } = []; @@ -103,8 +135,9 @@ public sealed record Settings } try { - return Migrated( - JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new Settings()); + Settings read = JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new Settings(); + FillNulls(read); + return Migrated(read); } catch (JsonException) { @@ -131,6 +164,51 @@ public sealed record Settings public void Save(string path) => File.WriteAllText(path, JsonSerializer.Serialize(this, Json)); + /// A settings file can hold a null where the property is not nullable: a + /// file written before the property existed and then edited, or one an + /// older version wrote. JSON puts the null in and the property's own + /// default never runs, so the program reads a null it does not expect. + /// Every null is put back to the default the property declares, top to + /// bottom, so nothing downstream has to check. + private static void FillNulls(object target) + { + Type type = target.GetType(); + object fresh = Activator.CreateInstance(type)!; + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanWrite || property.GetIndexParameters().Length > 0) + { + continue; + } + object? value = property.GetValue(target); + if (value is null) + { + property.SetValue(target, property.GetValue(fresh)); + } + else if (value is System.Collections.IEnumerable items and not string) + { + foreach (object? item in items) + { + FillOurOwn(item); + } + } + else + { + FillOurOwn(value); + } + } + } + + /// Only the types in this folder are walked into. A string, a number or a + /// framework type has nothing to fill in. + private static void FillOurOwn(object? value) + { + if (value is not null && value.GetType().Namespace == typeof(Settings).Namespace) + { + FillNulls(value); + } + } + /// Carries the single radio an older settings file holds into the list. private static Settings Migrated(Settings settings) => settings.Radios.Count > 0 || settings.RigctldHost.Length == 0 diff --git a/src/Nonemm.App/Configuration/StoredClusterNode.cs b/src/Nonemm.App/Configuration/StoredClusterNode.cs new file mode 100644 index 0000000..39b6a4c --- /dev/null +++ b/src/Nonemm.App/Configuration/StoredClusterNode.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Nonemm.App.Configuration; + +/// One cluster node in the operator's list. N1MM keeps the same list in its +/// admin database and calls it Favorites. +public sealed record StoredClusterNode +{ + public string Name { get; init; } = ""; + + public string Host { get; init; } = ""; + + public int Port { get; init; } = 7373; + + /// Only the few nodes that ask for one; most take the callsign alone. + public string Password { get; init; } = ""; + + /// Sent to this node after login, one command per entry. + public IReadOnlyList Commands { get; init; } = []; + + /// How the node reads in the favourites list. + [JsonIgnore] + public string Label => Name.Length > 0 ? $"{Name} — {Host}:{Port}" : $"{Host}:{Port}"; +} diff --git a/src/Nonemm.App/Configuration/StoredSpotFilter.cs b/src/Nonemm.App/Configuration/StoredSpotFilter.cs new file mode 100644 index 0000000..a3c2c52 --- /dev/null +++ b/src/Nonemm.App/Configuration/StoredSpotFilter.cs @@ -0,0 +1,70 @@ +using Nonemm.Core; +using Nonemm.Core.Calls; +using Nonemm.Core.Country; +using Nonemm.Spotting; + +namespace Nonemm.App.Configuration; + +/// The telnet window's filter settings as they are stored: band and mode names +/// rather than the types, so the file keeps working when those change. +public sealed record StoredSpotFilter +{ + /// Band names as `Bands` spells them. Empty means every band. + public IReadOnlyList Bands { get; init; } = []; + + /// `CW`, `PHONE` or `DIGITAL`. Empty means every mode. + public IReadOnlyList Modes { get; init; } = []; + + public bool MyCountryOnly { get; init; } + + public bool MyContinentOnly { get; init; } + + public IReadOnlyList CallAreas { get; init; } = []; + + public IReadOnlyList BlockedSpotters { get; init; } = []; + + public IReadOnlyList BlockedCalls { get; init; } = []; + + public bool ShowBeacons { get; init; } = true; + + public bool RemoveBustedSpots { get; init; } + + public bool OnlyInCallHistory { get; init; } + + /// Spotters whose lines the telnet window paints, so the ones worth + /// believing stand out. N1MM takes up to six. + public IReadOnlyList PreferredSpotters { get; init; } = []; + + public SpotFilter ToFilter( + BandPlan plan, + StoredStation station, + CountryFile? countries, + CallDatabase? calls, + CallHistory? history) => new() + { + Bands = [.. Bands.Select(Core.Bands.Named).OfType()], + Modes = [.. Modes.Select(ModeOf).OfType()], + Plan = plan, + Countries = countries, + MyCountry = station.CountryPrefix, + MyContinent = station.Continent, + MyCountryOnly = MyCountryOnly, + MyContinentOnly = MyContinentOnly, + CallAreas = CallAreas, + BlockedSpotters = BlockedSpotters, + BlockedCalls = BlockedCalls, + ShowBeacons = ShowBeacons, + Calls = calls, + RemoveBustedSpots = RemoveBustedSpots, + History = history, + OnlyInCallHistory = OnlyInCallHistory, + }; + + private static ModeCategory? ModeOf(string name) => name.ToUpperInvariant() switch + { + "CW" => ModeCategory.Cw, + "PHONE" => ModeCategory.Phone, + "DIGITAL" => ModeCategory.Digital, + _ => null, + }; +} diff --git a/src/Nonemm.App/Configuration/StoredTelnetButton.cs b/src/Nonemm.App/Configuration/StoredTelnetButton.cs new file mode 100644 index 0000000..141cf68 --- /dev/null +++ b/src/Nonemm.App/Configuration/StoredTelnetButton.cs @@ -0,0 +1,24 @@ +namespace Nonemm.App.Configuration; + +/// One button on the telnet window. The command holds what N1MM's buttons hold: +/// the message macros, several commands separated by semicolons, or `{CONN}` and +/// the name of a favourite to connect to it. +public sealed record StoredTelnetButton +{ + /// What the operator gets before changing anything: the commands a node + /// answers on any of the four cluster programs. + public static readonly IReadOnlyList Default = + [ + new() { Label = "Sh/DX", Command = "sh/dx" }, + new() { Label = "Sh/DX/20", Command = "sh/dx/20" }, + new() { Label = "WWV", Command = "sh/wwv" }, + new() { Label = "Users", Command = "sh/users" }, + new() { Label = "Nodes", Command = "sh/c/n" }, + new() { Label = "Help", Command = "help" }, + new() { Label = "Bye", Command = "bye" }, + ]; + + public string Label { get; init; } = ""; + + public string Command { get; init; } = ""; +} diff --git a/src/Nonemm.App/Configuration/SupportFileDownloader.cs b/src/Nonemm.App/Configuration/SupportFileDownloader.cs index 16697ee..62a3771 100644 --- a/src/Nonemm.App/Configuration/SupportFileDownloader.cs +++ b/src/Nonemm.App/Configuration/SupportFileDownloader.cs @@ -1,5 +1,6 @@ using Nonemm.Core.Calls; using Nonemm.Core.Country; +using Nonemm.Spotting; namespace Nonemm.App.Configuration; @@ -13,6 +14,11 @@ public sealed class SupportFileDownloader public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat"; public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP"; + /// NG3K's list of telnet cluster nodes. N1MM downloads its list from its + /// own web service, which asks the operator to opt in to data collection + /// and is N1MM's to run; this is a public page anyone may read. + public const string ClusterListUrl = "https://www.ng3k.com/misc/cluster.html"; + private readonly HttpClient http; public SupportFileDownloader(HttpClient http) => this.http = http; @@ -23,6 +29,9 @@ public sealed class SupportFileDownloader public Task DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) => DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation); + public Task DownloadClusterListAsync(string path, CancellationToken cancellation = default) => + DownloadAsync(ClusterListUrl, path, text => ClusterList.Parse(text).Count, "cluster nodes", cancellation); + private async Task DownloadAsync( string url, string path, diff --git a/src/Nonemm.App/Configuration/UserPaths.cs b/src/Nonemm.App/Configuration/UserPaths.cs index a388ade..fb8a493 100644 --- a/src/Nonemm.App/Configuration/UserPaths.cs +++ b/src/Nonemm.App/Configuration/UserPaths.cs @@ -28,6 +28,10 @@ public sealed class UserPaths public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP"); + /// The published list of cluster nodes, kept as the page it was downloaded + /// from so a later version can read more out of it. + public string ClusterListFile => Path.Combine(SupportFiles, "cluster-list.html"); + public void CreateFolders() { Directory.CreateDirectory(Root); diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml b/src/Nonemm.App/Dialogs/ClusterDialog.axaml deleted file mode 100644 index 0a725ac..0000000 --- a/src/Nonemm.App/Dialogs/ClusterDialog.axaml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - -