Keep the files in N1MM's folders
The folders under the user's own directory are N1MM's now, with N1MM's names, read out of N1MMLibrary.dll: Databases, SupportFiles, UserDefinedContests, FunctionKeyMessages, CallHistoryFiles, ExportFiles, GoalFiles, LettersFiles, QsoRecording, SkinsAndLayouts, SystemFiles, TransactionLogFiles, Diagnostics and Wav. A station that runs both programs keeps its files in one shape. An operator's recordings go in Wav/<his callsign>, which is where N1MM plays them from, and the folder is made when he takes the radio. Cabrillo and ADIF start in ExportFiles, call history files in CallHistoryFiles, and the .mc function key files in FunctionKeyMessages. N1MM's three *DDL folders and its Piper folders are left out: they hold the migrations for N1MM's own admin databases and the program it speaks with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
23
README.md
23
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
|
position is the standard low-precision solar formula, good to about a hundredth
|
||||||
of a degree.
|
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
|
### Operators
|
||||||
|
|
||||||
**Ctrl+O**, or **Config → Change Operator…**, asks who is at the radio. The
|
**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 |
|
| 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 |
|
| 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/<his callsign>`, 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
|
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;
|
with the windows and colours on the screen. **Remove** drops one from the list;
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ namespace Nonemm.App.Configuration;
|
|||||||
|
|
||||||
/// Where the operator's files live. Windows keeps them under Documents the way
|
/// Where the operator's files live. Windows keeps them under Documents the way
|
||||||
/// N1MM does; everywhere else follows the XDG configuration directory.
|
/// 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/<operator>/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 sealed class UserPaths
|
||||||
{
|
{
|
||||||
public UserPaths(string root)
|
public UserPaths(string root)
|
||||||
@@ -10,6 +17,17 @@ public sealed class UserPaths
|
|||||||
Databases = Path.Combine(root, "Databases");
|
Databases = Path.Combine(root, "Databases");
|
||||||
UserDefinedContests = Path.Combine(root, "UserDefinedContests");
|
UserDefinedContests = Path.Combine(root, "UserDefinedContests");
|
||||||
SupportFiles = Path.Combine(root, "SupportFiles");
|
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());
|
public static UserPaths Default() => new(DefaultRoot());
|
||||||
@@ -22,6 +40,38 @@ public sealed class UserPaths
|
|||||||
|
|
||||||
public string SupportFiles { get; }
|
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 SettingsFile => Path.Combine(Root, "settings.json");
|
||||||
|
|
||||||
public string CountryFile => Path.Combine(SupportFiles, "wl_cty.dat");
|
public string CountryFile => Path.Combine(SupportFiles, "wl_cty.dat");
|
||||||
@@ -32,13 +82,41 @@ public sealed class UserPaths
|
|||||||
/// from so a later version can read more out of it.
|
/// from so a later version can read more out of it.
|
||||||
public string ClusterListFile => Path.Combine(SupportFiles, "cluster-list.html");
|
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()
|
public void CreateFolders()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Root);
|
foreach (string folder in All())
|
||||||
Directory.CreateDirectory(Databases);
|
{
|
||||||
Directory.CreateDirectory(UserDefinedContests);
|
Directory.CreateDirectory(folder);
|
||||||
Directory.CreateDirectory(SupportFiles);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every folder that is made at startup, the root first.
|
||||||
|
public IReadOnlyList<string> All() =>
|
||||||
|
[
|
||||||
|
Root,
|
||||||
|
Databases,
|
||||||
|
UserDefinedContests,
|
||||||
|
SupportFiles,
|
||||||
|
FunctionKeyMessages,
|
||||||
|
CallHistoryFiles,
|
||||||
|
ExportFiles,
|
||||||
|
GoalFiles,
|
||||||
|
LettersFiles,
|
||||||
|
QsoRecording,
|
||||||
|
SkinsAndLayouts,
|
||||||
|
SystemFiles,
|
||||||
|
TransactionLogFiles,
|
||||||
|
Diagnostics,
|
||||||
|
Wav,
|
||||||
|
];
|
||||||
|
|
||||||
private static string DefaultRoot() =>
|
private static string DefaultRoot() =>
|
||||||
OperatingSystem.IsWindows()
|
OperatingSystem.IsWindows()
|
||||||
|
|||||||
@@ -33,9 +33,19 @@ public sealed partial class MessagesDialog : Window
|
|||||||
|
|
||||||
private readonly ModeCategory mode;
|
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.mode = mode;
|
||||||
|
this.folder = folder;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
Title = $"{what} — {(mode == ModeCategory.Phone ? "phone" : "CW")}";
|
Title = $"{what} — {(mode == ModeCategory.Phone ? "phone" : "CW")}";
|
||||||
TextArea.Text = text.Trim().Length > 0 ? text : Messages.DefaultFor(mode);
|
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",
|
Title = "Import a function key file",
|
||||||
AllowMultiple = false,
|
AllowMultiple = false,
|
||||||
FileTypeFilter = [MessageFiles],
|
FileTypeFilter = [MessageFiles],
|
||||||
|
SuggestedStartLocation = await Start(),
|
||||||
});
|
});
|
||||||
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
||||||
{
|
{
|
||||||
@@ -126,6 +137,7 @@ public sealed partial class MessagesDialog : Window
|
|||||||
{
|
{
|
||||||
Title = "Export the function key file",
|
Title = "Export the function key file",
|
||||||
SuggestedFileName = mode == ModeCategory.Phone ? "phone.mc" : "cw.mc",
|
SuggestedFileName = mode == ModeCategory.Phone ? "phone.mc" : "cw.mc",
|
||||||
|
SuggestedStartLocation = await Start(),
|
||||||
DefaultExtension = "mc",
|
DefaultExtension = "mc",
|
||||||
FileTypeChoices = [MessageFiles],
|
FileTypeChoices = [MessageFiles],
|
||||||
});
|
});
|
||||||
@@ -149,4 +161,7 @@ public sealed partial class MessagesDialog : Window
|
|||||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? "");
|
private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? "");
|
||||||
|
|
||||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||||
|
|
||||||
|
private async Task<IStorageFolder?> Start() =>
|
||||||
|
folder is null ? null : await StorageProvider.TryGetFolderFromPathAsync(folder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ public sealed partial class EntryWindow
|
|||||||
string stored = mode == ModeCategory.Phone
|
string stored = mode == ModeCategory.Phone
|
||||||
? session.Settings.PhoneMessageFile
|
? session.Settings.PhoneMessageFile
|
||||||
: session.Settings.CwMessageFile;
|
: session.Settings.CwMessageFile;
|
||||||
if (await new MessagesDialog(mode, stored).ShowDialog<string?>(this) is not { } edited)
|
if (await new MessagesDialog(mode, stored, folder: session.Paths.FunctionKeyMessages).ShowDialog<string?>(this) is not { } edited)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ public sealed partial class EntryWindow
|
|||||||
{
|
{
|
||||||
Title = "Export Cabrillo",
|
Title = "Export Cabrillo",
|
||||||
SuggestedFileName = $"{session.Settings.Station.Callsign}.log",
|
SuggestedFileName = $"{session.Settings.Station.Callsign}.log",
|
||||||
|
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
|
||||||
});
|
});
|
||||||
if (file?.TryGetLocalPath() is not { } path)
|
if (file?.TryGetLocalPath() is not { } path)
|
||||||
{
|
{
|
||||||
@@ -142,6 +143,7 @@ public sealed partial class EntryWindow
|
|||||||
{
|
{
|
||||||
Title = "Export ADIF",
|
Title = "Export ADIF",
|
||||||
SuggestedFileName = $"{session.Settings.Station.Callsign}.adi",
|
SuggestedFileName = $"{session.Settings.Station.Callsign}.adi",
|
||||||
|
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
|
||||||
});
|
});
|
||||||
if (file?.TryGetLocalPath() is not { } path)
|
if (file?.TryGetLocalPath() is not { } path)
|
||||||
{
|
{
|
||||||
@@ -162,6 +164,7 @@ public sealed partial class EntryWindow
|
|||||||
{
|
{
|
||||||
Title = "Import ADIF",
|
Title = "Import ADIF",
|
||||||
AllowMultiple = false,
|
AllowMultiple = false,
|
||||||
|
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
|
||||||
});
|
});
|
||||||
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
||||||
{
|
{
|
||||||
@@ -573,7 +576,7 @@ public sealed partial class EntryWindow
|
|||||||
{
|
{
|
||||||
Title = "Call history file",
|
Title = "Call history file",
|
||||||
AllowMultiple = false,
|
AllowMultiple = false,
|
||||||
SuggestedStartLocation = await Folder(session.Paths.SupportFiles),
|
SuggestedStartLocation = await Folder(session.Paths.CallHistoryFiles),
|
||||||
});
|
});
|
||||||
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public sealed partial class EntryWindow
|
|||||||
? known with { WavPath = wanted.WavPath }
|
? known with { WavPath = wanted.WavPath }
|
||||||
// a new operator starts with the windows and colours on the screen
|
// a new operator starts with the windows and colours on the screen
|
||||||
: wanted with { Theme = settings.Theme, Windows = Layout() };
|
: wanted with { Theme = settings.Theme, Windows = Layout() };
|
||||||
|
stored = stored with { WavPath = WavFolder(stored) };
|
||||||
session.Save(settings with
|
session.Save(settings with
|
||||||
{
|
{
|
||||||
Operators = [.. settings.Operators.Where(o => !Is(o, stored.Callsign)), stored],
|
Operators = [.. settings.Operators.Where(o => !Is(o, stored.Callsign)), stored],
|
||||||
@@ -62,6 +63,28 @@ public sealed partial class EntryWindow
|
|||||||
Status($"operator {stored.Callsign}");
|
Status($"operator {stored.Callsign}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The operator's recordings folder: the one he named, or `Wav/<callsign>`
|
||||||
|
/// 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
|
/// 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.
|
/// a time, so each one is placed as it appears rather than all at once.
|
||||||
private void Place(Window window, StoredOperator? stored = null)
|
private void Place(Window window, StoredOperator? stored = null)
|
||||||
|
|||||||
56
tests/Nonemm.App.Tests/UserPathsTests.cs
Normal file
56
tests/Nonemm.App.Tests/UserPathsTests.cs
Normal file
@@ -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<string>().Order()];
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"CallHistoryFiles", "Databases", "Diagnostics", "ExportFiles", "FunctionKeyMessages",
|
||||||
|
"GoalFiles", "LettersFiles", "QsoRecording", "SkinsAndLayouts", "SupportFiles",
|
||||||
|
"SystemFiles", "TransactionLogFiles", "UserDefinedContests", "Wav",
|
||||||
|
],
|
||||||
|
made);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM plays `Wav\<operator>\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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user