Skip to content

Commit ec4ffe3

Browse files
committed
📑 Rewrite FileHelper in idiomatic C#
1 parent ab91578 commit ec4ffe3

File tree

3 files changed

+95
-158
lines changed

3 files changed

+95
-158
lines changed

‎YoutubeDl.Wpf/Models/Settings.cs‎

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -78,48 +78,39 @@ public class Settings
7878
/// Loads settings from Settings.json.
7979
/// </summary>
8080
/// <param name="cancellationToken">A token that may be used to cancel the read operation.</param>
81-
/// <returns>
82-
/// A ValueTuple containing a <see cref="Settings"/> object and an optional error message.
83-
/// </returns>
84-
public static async Task<(Settings settings, string? errMsg)> LoadSettingsAsync(CancellationToken cancellationToken = default)
81+
/// <returns>The <see cref="Settings"/> object.</returns>
82+
public static async Task<Settings> LoadAsync(CancellationToken cancellationToken = default)
8583
{
86-
var (settings, errMsg) = await FileHelper.LoadJsonAsync("Settings.json", SettingsJsonSerializerContext.Default.Settings, cancellationToken).ConfigureAwait(false);
87-
errMsg ??= settings.UpdateSettings();
88-
return (settings, errMsg);
84+
var settings = await FileHelper.LoadFromJsonFileAsync("Settings.json", SettingsJsonSerializerContext.Default.Settings, cancellationToken).ConfigureAwait(false);
85+
settings.UpdateSettings();
86+
return settings;
8987
}
9088

9189
/// <summary>
9290
/// Saves settings to Settings.json.
9391
/// </summary>
94-
/// <param name="settings">The <see cref="Settings"/> object to save.</param>
9592
/// <param name="cancellationToken">A token that may be used to cancel the write operation.</param>
96-
/// <returns>
97-
/// An optional error message.
98-
/// Null if no errors occurred.
99-
/// </returns>
100-
public static Task<string?> SaveSettingsAsync(Settings settings, CancellationToken cancellationToken = default)
101-
=> FileHelper.SaveJsonAsync("Settings.json", settings, SettingsJsonSerializerContext.Default.Settings, false, false, cancellationToken);
93+
/// <returns>A task that represents the asynchronous write operation.</returns>
94+
public Task SaveAsync(CancellationToken cancellationToken = default)
95+
=> FileHelper.SaveToJsonFileAsync("Settings.json", this, SettingsJsonSerializerContext.Default.Settings, cancellationToken);
10296

10397
/// <summary>
10498
/// Updates the loaded settings to the latest version.
105-
/// If the loaded settings have a higher version number,
106-
/// an error message is returned.
10799
/// </summary>
108-
/// <returns>
109-
/// An optional error message.
110-
/// Null if no errors occurred.
111-
/// </returns>
112-
public string? UpdateSettings()
100+
/// <exception cref="Exception">
101+
/// The loaded settings have a higher version number than supported.
102+
/// </exception>
103+
public void UpdateSettings()
113104
{
114105
switch (Version)
115106
{
116107
case 0: // nothing to do
117108
Version++;
118109
goto case 1; // go to the next update path
119110
case DefaultVersion: // current version
120-
return null;
111+
return;
121112
default:
122-
return $"Settings version {Version} is newer than supported.";
113+
throw new Exception($"Settings version {Version} is newer than supported.");
123114
}
124115
}
125116
}

‎YoutubeDl.Wpf/Utils/FileHelper.cs‎

Lines changed: 64 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -5,142 +5,83 @@
55
using System.Threading;
66
using System.Threading.Tasks;
77

8-
namespace YoutubeDl.Wpf.Utils
8+
namespace YoutubeDl.Wpf.Utils;
9+
10+
public static class FileHelper
911
{
10-
public static class FileHelper
11-
{
12-
public static readonly string configDirectory;
12+
private static readonly string s_configDirectory;
1313

14-
static FileHelper()
15-
{
14+
static FileHelper()
15+
{
1616
#if PACKAGED
17-
// ~/.config on Linux
18-
// ~/AppData/Roaming on Windows
19-
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
20-
configDirectory = $"{appDataPath}/youtube-dl-wpf";
17+
// ~/.config on Linux
18+
// ~/AppData/Roaming on Windows
19+
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
20+
s_configDirectory = Path.Join(appDataPath, "youtube-dl-wpf");
2121
#else
22-
// Use executable directory
23-
// Executable directory for single-file deployments in .NET 5: https://docs.microsoft.com/en-us/dotnet/core/deploying/single-file
24-
configDirectory = AppContext.BaseDirectory;
22+
// Use executable directory
23+
// Executable directory for single-file deployments in .NET 5+: https://docs.microsoft.com/en-us/dotnet/core/deploying/single-file
24+
s_configDirectory = AppContext.BaseDirectory;
2525
#endif
26-
}
26+
}
2727

28-
/// <summary>
29-
/// Gets the fully qualified absolute path
30-
/// that the original path points to.
31-
/// </summary>
32-
/// <param name="path">A relative or absolute path.</param>
33-
/// <returns>A fully qualified path.</returns>
34-
public static string GetAbsolutePath(string path)
35-
=> Path.IsPathFullyQualified(path) ? path : $"{configDirectory}/{path}";
28+
/// <summary>
29+
/// Gets the absolute path pointed to by the specified path.
30+
/// </summary>
31+
/// <param name="path">A relative or absolute path.</param>
32+
/// <returns>A fully qualified path.</returns>
33+
public static string GetAbsolutePath(string path) => Path.Combine(s_configDirectory, path);
34+
35+
/// <summary>
36+
/// Loads the specified JSON file and deserializes its content as a <typeparamref name="TValue"/>.
37+
/// </summary>
38+
/// <typeparam name="TValue">The type to deserialize the JSON value into.</typeparam>
39+
/// <param name="path">JSON file path.</param>
40+
/// <param name="jsonTypeInfo">Metadata about the type to convert.</param>
41+
/// <param name="cancellationToken">A token that may be used to cancel the read operation.</param>
42+
/// <returns>A <typeparamref name="TValue"/>.</returns>
43+
public static async Task<TValue> LoadFromJsonFileAsync<TValue>(string path, JsonTypeInfo<TValue> jsonTypeInfo, CancellationToken cancellationToken = default) where TValue : class, new()
44+
{
45+
path = GetAbsolutePath(path);
46+
if (!File.Exists(path))
47+
return new();
3648

37-
/// <summary>
38-
/// Loads data from a JSON file.
39-
/// </summary>
40-
/// <typeparam name="T">Data object type.</typeparam>
41-
/// <param name="filename">JSON file name.</param>
42-
/// <param name="jsonTypeInfo">Metadata about the type to convert.</param>
43-
/// <param name="cancellationToken">A token that may be used to cancel the read operation.</param>
44-
/// <returns>
45-
/// A ValueTuple containing a data object loaded from the JSON file and an error message.
46-
/// The error message is null if no errors occurred.
47-
/// </returns>
48-
public static async Task<(T, string? errMsg)> LoadJsonAsync<T>(string filename, JsonTypeInfo<T> jsonTypeInfo, CancellationToken cancellationToken = default) where T : class, new()
49+
var fileStream = new FileStream(path, FileMode.Open);
50+
await using (fileStream.ConfigureAwait(false))
4951
{
50-
// extend relative path
51-
filename = GetAbsolutePath(filename);
52-
53-
if (!File.Exists(filename))
54-
return (new(), null);
55-
56-
if (cancellationToken.IsCancellationRequested)
57-
return (new(), "The operation was canceled.");
58-
59-
T? jsonData = null;
60-
string? errMsg = null;
61-
FileStream? jsonFile = null;
62-
63-
try
64-
{
65-
jsonFile = new(filename, FileMode.Open);
66-
jsonData = await JsonSerializer.DeserializeAsync<T>(jsonFile, jsonTypeInfo, cancellationToken).ConfigureAwait(false);
67-
}
68-
catch (Exception ex)
69-
{
70-
errMsg = $"Error: failed to load {filename}: {ex.Message}";
71-
}
72-
finally
73-
{
74-
if (jsonFile is not null)
75-
await jsonFile.DisposeAsync().ConfigureAwait(false);
76-
}
77-
78-
jsonData ??= new();
79-
return (jsonData, errMsg);
52+
return await JsonSerializer.DeserializeAsync(fileStream, jsonTypeInfo, cancellationToken).ConfigureAwait(false) ?? new();
8053
}
54+
}
8155

82-
/// <summary>
83-
/// Saves data to a JSON file.
84-
/// </summary>
85-
/// <typeparam name="T">Data object type.</typeparam>
86-
/// <param name="filename">JSON file name.</param>
87-
/// <param name="jsonData">The data object to save.</param>
88-
/// <param name="jsonTypeInfo">Metadata about the type to convert.</param>
89-
/// <param name="alwaysOverwrite">Always overwrite the original file.</param>
90-
/// <param name="noBackup">Do not create `filename.old` as backup.</param>
91-
/// <param name="cancellationToken">A token that may be used to cancel the write operation.</param>
92-
/// <returns>An error message. Null if no errors occurred.</returns>
93-
public static async Task<string?> SaveJsonAsync<T>(
94-
string filename,
95-
T jsonData,
96-
JsonTypeInfo<T> jsonTypeInfo,
97-
bool alwaysOverwrite = false,
98-
bool noBackup = false,
99-
CancellationToken cancellationToken = default)
100-
{
101-
// extend relative path
102-
filename = GetAbsolutePath(filename);
103-
104-
string? errMsg = null;
105-
FileStream? jsonFile = null;
106-
107-
try
108-
{
109-
if (cancellationToken.IsCancellationRequested)
110-
return "The operation was canceled.";
111-
112-
// create directory
113-
var directoryPath = Path.GetDirectoryName(filename);
114-
if (directoryPath is null)
115-
return $"Error: invalid path: {filename}";
56+
/// <summary>
57+
/// Serializes the provided value as JSON and saves to the specified file.
58+
/// </summary>
59+
/// <typeparam name="TValue">The type of the value to serialize.</typeparam>
60+
/// <param name="path">JSON file path.</param>
61+
/// <param name="value">The value to save.</param>
62+
/// <param name="jsonTypeInfo">Metadata about the type to convert.</param>
63+
/// <param name="cancellationToken">A token that may be used to cancel the write operation.</param>
64+
/// <returns>A task that represents the asynchronous write operation.</returns>
65+
public static async Task SaveToJsonFileAsync<TValue>(
66+
string path,
67+
TValue value,
68+
JsonTypeInfo<TValue> jsonTypeInfo,
69+
CancellationToken cancellationToken = default)
70+
{
71+
path = GetAbsolutePath(path);
11672

117-
Directory.CreateDirectory(directoryPath);
73+
var directoryPath = Path.GetDirectoryName(path);
74+
if (string.IsNullOrEmpty(directoryPath))
75+
throw new ArgumentException("Invalid path.", nameof(path));
11876

119-
// save JSON
120-
if (alwaysOverwrite || !File.Exists(filename)) // alwaysOverwrite or file doesn't exist. Just write to it.
121-
{
122-
jsonFile = new(filename, FileMode.Create);
123-
await JsonSerializer.SerializeAsync(jsonFile, jsonData, jsonTypeInfo, cancellationToken).ConfigureAwait(false);
124-
}
125-
else // File exists. Write to `filename.new` and then replace with the new file and creates backup `filename.old`.
126-
{
127-
jsonFile = new($"{filename}.new", FileMode.Create);
128-
await JsonSerializer.SerializeAsync(jsonFile, jsonData, jsonTypeInfo, cancellationToken).ConfigureAwait(false);
129-
jsonFile.Close();
130-
File.Replace($"{filename}.new", filename, noBackup ? null : $"{filename}.old");
131-
}
132-
}
133-
catch (Exception ex)
134-
{
135-
errMsg = $"Error: failed to save {filename}: {ex.Message}";
136-
}
137-
finally
138-
{
139-
if (jsonFile is not null)
140-
await jsonFile.DisposeAsync().ConfigureAwait(false);
141-
}
77+
_ = Directory.CreateDirectory(directoryPath);
14278

143-
return errMsg;
79+
var newPath = $"{path}.new";
80+
var fileStream = new FileStream(newPath, FileMode.Create);
81+
await using (fileStream.ConfigureAwait(false))
82+
{
83+
await JsonSerializer.SerializeAsync(fileStream, value, jsonTypeInfo, cancellationToken);
14484
}
85+
File.Replace(newPath, path, $"{path}.old");
14586
}
14687
}

‎YoutubeDl.Wpf/ViewModels/MainWindowViewModel.cs‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Serilog;
55
using Splat;
66
using Splat.Serilog;
7+
using System;
78
using System.ComponentModel;
89
using System.Threading;
910
using System.Threading.Tasks;
@@ -30,14 +31,17 @@ public class MainWindowViewModel : ReactiveObject
3031

3132
public MainWindowViewModel(ISnackbarMessageQueue snackbarMessageQueue)
3233
{
33-
var (settings, loadSettingsErrMsg) = Settings.LoadSettingsAsync().GetAwaiter().GetResult();
34-
if (loadSettingsErrMsg is not null)
34+
try
3535
{
36-
snackbarMessageQueue.Enqueue(loadSettingsErrMsg);
36+
_settings = Settings.LoadAsync().GetAwaiter().GetResult();
37+
}
38+
catch (Exception ex)
39+
{
40+
snackbarMessageQueue.Enqueue(ex.Message);
41+
_settings = new();
3742
}
3843

39-
_settings = settings;
40-
_observableSettings = new(settings);
44+
_observableSettings = new(_settings);
4145
_snackbarMessageQueue = snackbarMessageQueue;
4246

4347
// Configure logging.
@@ -66,21 +70,22 @@ public async Task<bool> SaveSettingsAsync(CancelEventArgs? cancelEventArgs = nul
6670
{
6771
_observableSettings.UpdateSettings(_settings);
6872

69-
var errMsg = await Settings.SaveSettingsAsync(_settings, cancellationToken);
70-
if (errMsg is not null)
73+
try
74+
{
75+
await _settings.SaveAsync(cancellationToken);
76+
}
77+
catch (Exception ex)
7178
{
72-
_snackbarMessageQueue.Enqueue(errMsg);
79+
_snackbarMessageQueue.Enqueue(ex.Message);
7380

7481
// Cancel window closing
7582
if (cancelEventArgs is not null)
7683
cancelEventArgs.Cancel = true;
7784

7885
return false;
7986
}
80-
else
81-
{
82-
return true;
83-
}
87+
88+
return true;
8489
}
8590
}
8691
}

0 commit comments

Comments
 (0)