diff --git a/README.md b/README.md index 12d5bc7..182a97d 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,27 @@ to a tenth of a degree: `src/Nonemm.App/Assets/coastline.txt`. The sun's position is the standard low-precision solar formula, good to about a hundredth of a degree. +### Where the files live + +The program keeps its files under `Documents\Nonemm` on Windows and +`~/.config/nonemm` elsewhere. The folders inside are N1MM's, with N1MM's names, +so a station that runs both keeps its files in the same shape: + +| Folder | What goes in it | +|---|---| +| `Databases` | the `.s3db` log files | +| `SupportFiles` | `wl_cty.dat`, `MASTER.SCP`, the downloaded cluster list | +| `UserDefinedContests` | `.udc` files | +| `FunctionKeyMessages` | `.mc` function key files, where import and export start | +| `CallHistoryFiles` | the call history files published before a contest | +| `ExportFiles` | Cabrillo and ADIF, in and out | +| `Wav` | the voice keyer's recordings, one folder per operator: `Wav/OM3KFF/Cq.wav` | +| `GoalFiles`, `LettersFiles`, `QsoRecording`, `SkinsAndLayouts`, `SystemFiles`, `TransactionLogFiles`, `Diagnostics` | N1MM's, and made for the same reason: a station that keeps files in them does not lose them moving between the two programs. Nothing here writes to them yet | + +`settings.json` sits in the root. N1MM's three `*DDL` folders and its `Piper` +folders are not made: those hold the migrations for N1MM's own admin databases +and the program it speaks with, and neither has anything to do with a log. + ### Operators **Ctrl+O**, or **Config → Change Operator…**, asks who is at the radio. The @@ -413,7 +434,7 @@ pressing at a multi-operator station: |---|---| | Window positions | where every open window sits is stored against the operator leaving, and the operator taking over gets his own back. A window opened later is placed as it appears | | Colours | the theme he last had | -| Recordings folder | the folder his wav files are in, since a voice keyer plays the recordings of whoever is at the microphone | +| Recordings folder | the folder his wav files are in, since a voice keyer plays the recordings of whoever is at the microphone. It starts at `Wav/`, which is where N1MM looks, and the folder is made when he takes the radio | An operator whose callsign is not in the list is made by typing it, and starts with the windows and colours on the screen. **Remove** drops one from the list; diff --git a/src/Nonemm.App/Configuration/UserPaths.cs b/src/Nonemm.App/Configuration/UserPaths.cs index fb8a493..ea878aa 100644 --- a/src/Nonemm.App/Configuration/UserPaths.cs +++ b/src/Nonemm.App/Configuration/UserPaths.cs @@ -2,6 +2,13 @@ namespace Nonemm.App.Configuration; /// Where the operator's files live. Windows keeps them under Documents the way /// N1MM does; everywhere else follows the XDG configuration directory. +/// +/// The folders inside are N1MM's own, with N1MM's names, so a station that runs +/// both programs keeps its files in the same shape: `Wav//Cq.wav` is +/// where N1MM looks for that operator's recordings, and so is this. N1MM's +/// three `*DDL` folders and its `Piper` folders are left out: those hold the +/// migrations for N1MM's own admin databases and the program it speaks with, +/// and neither has anything to do with a log. public sealed class UserPaths { public UserPaths(string root) @@ -10,6 +17,17 @@ public sealed class UserPaths Databases = Path.Combine(root, "Databases"); UserDefinedContests = Path.Combine(root, "UserDefinedContests"); SupportFiles = Path.Combine(root, "SupportFiles"); + FunctionKeyMessages = Path.Combine(root, "FunctionKeyMessages"); + CallHistoryFiles = Path.Combine(root, "CallHistoryFiles"); + ExportFiles = Path.Combine(root, "ExportFiles"); + GoalFiles = Path.Combine(root, "GoalFiles"); + LettersFiles = Path.Combine(root, "LettersFiles"); + QsoRecording = Path.Combine(root, "QsoRecording"); + SkinsAndLayouts = Path.Combine(root, "SkinsAndLayouts"); + SystemFiles = Path.Combine(root, "SystemFiles"); + TransactionLogFiles = Path.Combine(root, "TransactionLogFiles"); + Diagnostics = Path.Combine(root, "Diagnostics"); + Wav = Path.Combine(root, "Wav"); } public static UserPaths Default() => new(DefaultRoot()); @@ -22,6 +40,38 @@ public sealed class UserPaths public string SupportFiles { get; } + /// The `.mc` function key files, which N1MM imports and exports here. + public string FunctionKeyMessages { get; } + + public string CallHistoryFiles { get; } + + /// Cabrillo and ADIF, in and out. + public string ExportFiles { get; } + + /// N1MM's rate targets, its letter and number recordings, its recorded + /// contacts, its window layouts, its own settings files, its transaction + /// logs and its traces. Nothing here writes to them yet; they are made so + /// that a station that keeps files in them does not lose them when it moves + /// between the two programs. + public string GoalFiles { get; } + + public string LettersFiles { get; } + + public string QsoRecording { get; } + + public string SkinsAndLayouts { get; } + + public string SystemFiles { get; } + + public string TransactionLogFiles { get; } + + public string Diagnostics { get; } + + /// The voice keyer's recordings. Each operator has a folder of his own + /// under it, named for his callsign, which is how N1MM finds the voice of + /// whoever is at the microphone. + public string Wav { get; } + public string SettingsFile => Path.Combine(Root, "settings.json"); public string CountryFile => Path.Combine(SupportFiles, "wl_cty.dat"); @@ -32,14 +82,42 @@ public sealed class UserPaths /// from so a later version can read more out of it. public string ClusterListFile => Path.Combine(SupportFiles, "cluster-list.html"); + /// One operator's recordings: `Wav/OM3KFF/Cq.wav` and the rest. Without an + /// operator the folder itself is the answer, which is where a station with + /// one operator keeps them. + public string WavFor(string operatorCallsign) => + operatorCallsign.Trim().Length == 0 + ? Wav + : Path.Combine(Wav, operatorCallsign.Trim().ToUpperInvariant()); + public void CreateFolders() { - Directory.CreateDirectory(Root); - Directory.CreateDirectory(Databases); - Directory.CreateDirectory(UserDefinedContests); - Directory.CreateDirectory(SupportFiles); + foreach (string folder in All()) + { + Directory.CreateDirectory(folder); + } } + /// Every folder that is made at startup, the root first. + public IReadOnlyList All() => + [ + Root, + Databases, + UserDefinedContests, + SupportFiles, + FunctionKeyMessages, + CallHistoryFiles, + ExportFiles, + GoalFiles, + LettersFiles, + QsoRecording, + SkinsAndLayouts, + SystemFiles, + TransactionLogFiles, + Diagnostics, + Wav, + ]; + private static string DefaultRoot() => OperatingSystem.IsWindows() ? Path.Combine( diff --git a/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs b/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs index a5ecc59..f0b3bb0 100644 --- a/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs @@ -33,9 +33,19 @@ public sealed partial class MessagesDialog : Window private readonly ModeCategory mode; - public MessagesDialog(ModeCategory mode, string text, string what = "Function key messages") + /// The folder import and export start in: `FunctionKeyMessages`, which is + /// where N1MM keeps its `.mc` files. Null leaves the picker wherever it was + /// last. + private readonly string? folder; + + public MessagesDialog( + ModeCategory mode, + string text, + string what = "Function key messages", + string? folder = null) { this.mode = mode; + this.folder = folder; InitializeComponent(); Title = $"{what} — {(mode == ModeCategory.Phone ? "phone" : "CW")}"; TextArea.Text = text.Trim().Length > 0 ? text : Messages.DefaultFor(mode); @@ -105,6 +115,7 @@ public sealed partial class MessagesDialog : Window Title = "Import a function key file", AllowMultiple = false, FileTypeFilter = [MessageFiles], + SuggestedStartLocation = await Start(), }); if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path) { @@ -126,6 +137,7 @@ public sealed partial class MessagesDialog : Window { Title = "Export the function key file", SuggestedFileName = mode == ModeCategory.Phone ? "phone.mc" : "cw.mc", + SuggestedStartLocation = await Start(), DefaultExtension = "mc", FileTypeChoices = [MessageFiles], }); @@ -149,4 +161,7 @@ public sealed partial class MessagesDialog : Window private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? ""); private void OnCancel(object? sender, RoutedEventArgs e) => Close(null); + + private async Task Start() => + folder is null ? null : await StorageProvider.TryGetFolderFromPathAsync(folder); } diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs index 973eb73..05ce5e4 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs @@ -52,7 +52,7 @@ public sealed partial class EntryWindow string stored = mode == ModeCategory.Phone ? session.Settings.PhoneMessageFile : session.Settings.CwMessageFile; - if (await new MessagesDialog(mode, stored).ShowDialog(this) is not { } edited) + if (await new MessagesDialog(mode, stored, folder: session.Paths.FunctionKeyMessages).ShowDialog(this) is not { } edited) { return; } diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs index 092726d..64072e2 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs @@ -102,6 +102,7 @@ public sealed partial class EntryWindow { Title = "Export Cabrillo", SuggestedFileName = $"{session.Settings.Station.Callsign}.log", + SuggestedStartLocation = await Folder(session.Paths.ExportFiles), }); if (file?.TryGetLocalPath() is not { } path) { @@ -142,6 +143,7 @@ public sealed partial class EntryWindow { Title = "Export ADIF", SuggestedFileName = $"{session.Settings.Station.Callsign}.adi", + SuggestedStartLocation = await Folder(session.Paths.ExportFiles), }); if (file?.TryGetLocalPath() is not { } path) { @@ -162,6 +164,7 @@ public sealed partial class EntryWindow { Title = "Import ADIF", AllowMultiple = false, + SuggestedStartLocation = await Folder(session.Paths.ExportFiles), }); if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path) { @@ -573,7 +576,7 @@ public sealed partial class EntryWindow { Title = "Call history file", AllowMultiple = false, - SuggestedStartLocation = await Folder(session.Paths.SupportFiles), + SuggestedStartLocation = await Folder(session.Paths.CallHistoryFiles), }); if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path) { diff --git a/src/Nonemm.App/Windows/EntryWindow.Operators.cs b/src/Nonemm.App/Windows/EntryWindow.Operators.cs index 4aa5d1e..591df35 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Operators.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Operators.cs @@ -41,6 +41,7 @@ public sealed partial class EntryWindow ? known with { WavPath = wanted.WavPath } // a new operator starts with the windows and colours on the screen : wanted with { Theme = settings.Theme, Windows = Layout() }; + stored = stored with { WavPath = WavFolder(stored) }; session.Save(settings with { Operators = [.. settings.Operators.Where(o => !Is(o, stored.Callsign)), stored], @@ -62,6 +63,28 @@ public sealed partial class EntryWindow Status($"operator {stored.Callsign}"); } + /// The operator's recordings folder: the one he named, or `Wav/` + /// beside the rest of the files, which is where N1MM keeps them. The folder + /// is made, so there is somewhere to put the files rather than a path that + /// is not there. + private string WavFolder(StoredOperator stored) + { + string folder = stored.WavPath.Trim().Length > 0 + ? stored.WavPath.Trim() + : session.Paths.WavFor(stored.Callsign); + try + { + Directory.CreateDirectory(folder); + } + catch (Exception failure) when (failure is IOException or UnauthorizedAccessException) + { + // a folder on a drive that is not there is still worth keeping: it + // is the operator's own setting, and he is the one who fixes it + Status($"{folder} could not be made: {failure.Message}"); + } + return folder; + } + /// Puts a window where this operator last had it. Windows are opened one at /// a time, so each one is placed as it appears rather than all at once. private void Place(Window window, StoredOperator? stored = null) diff --git a/tests/Nonemm.App.Tests/UserPathsTests.cs b/tests/Nonemm.App.Tests/UserPathsTests.cs new file mode 100644 index 0000000..945c5c0 --- /dev/null +++ b/tests/Nonemm.App.Tests/UserPathsTests.cs @@ -0,0 +1,56 @@ +using Nonemm.App.Configuration; + +namespace Nonemm.App.Tests; + +/// The folders under the user's own directory, which are N1MM's. +public class UserPathsTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}"); + + public void Dispose() + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void TheFoldersAreN1mmsOwnNames() + { + UserPaths paths = new(root); + + paths.CreateFolders(); + + string[] made = [.. Directory.GetDirectories(root).Select(Path.GetFileName).OfType().Order()]; + Assert.Equal( + [ + "CallHistoryFiles", "Databases", "Diagnostics", "ExportFiles", "FunctionKeyMessages", + "GoalFiles", "LettersFiles", "QsoRecording", "SkinsAndLayouts", "SupportFiles", + "SystemFiles", "TransactionLogFiles", "UserDefinedContests", "Wav", + ], + made); + } + + /// N1MM plays `Wav\\Cq.wav`, so that is where the recordings of + /// whoever is at the microphone are looked for here as well. + [Fact] + public void AnOperatorHasHisOwnFolderOfRecordings() + { + UserPaths paths = new(root); + + Assert.Equal(Path.Combine(root, "Wav", "OM3KFF"), paths.WavFor("om3kff")); + Assert.Equal(Path.Combine(root, "Wav"), paths.WavFor(" ")); + } + + [Fact] + public void MakingTheFoldersTwiceIsNotAnError() + { + UserPaths paths = new(root); + + paths.CreateFolders(); + paths.CreateFolders(); + + Assert.True(Directory.Exists(paths.Wav)); + } +}