diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index 2e00afc4..18161443 100644 --- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs +++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs @@ -126,8 +126,8 @@ namespace AaxDecrypter if (DownloadOptions.SeriesName is string series) AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series); - if (DownloadOptions.SeriesNumber is float part) - AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString()); + if (DownloadOptions.SeriesNumber is string part) + AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part); } OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged); diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index d80a5b95..11e79db8 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -43,7 +43,7 @@ namespace AaxDecrypter string? Publisher { get; } string? Language { get; } string? SeriesName { get; } - float? SeriesNumber { get; } + string? SeriesNumber { get; } NAudio.Lame.LameConfig? LameConfig { get; } bool Downsample { get; } bool MatchSourceBitrate { get; } diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 2653ab57..2b512e81 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -121,7 +121,7 @@ namespace AppScaffolding zipFileSink["Name"] = "File"; fileChanged = true; } - var hooks = $"{nameof(LibationFileManager)}.{nameof(FileSinkHook)}, {nameof(LibationFileManager)}"; + var hooks = typeof(FileSinkHook).AssemblyQualifiedName; if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs && fileSinkArgs["hooks"]?.Value() != hooks) { @@ -158,7 +158,8 @@ namespace AppScaffolding // - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}"; // output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation // {Properties:j} needed for expanded exception logging - { "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" } + { "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }, + { "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook } } } diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index deca475b..4a3e60e1 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -2,6 +2,7 @@ using System.Linq; using Microsoft.EntityFrameworkCore; +#nullable enable namespace DataLayer { // only library importing should use tracking. All else should be NoTracking. @@ -24,13 +25,13 @@ namespace DataLayer .Where(c => !c.Book.IsEpisodeParent() || includeParents) .ToList(); - public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) + public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) => context .LibraryBooks .AsNoTrackingWithIdentityResolution() .GetLibraryBook(productId); - public static LibraryBook GetLibraryBook(this IQueryable library, string productId) + public static LibraryBook? GetLibraryBook(this IQueryable library, string productId) => library .GetLibrary() .SingleOrDefault(lb => lb.Book.AudibleProductId == productId); diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 29e7b286..a276e6ad 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -26,7 +26,7 @@ namespace FileLiberator public string Language => LibraryBook.Book.Language; public string? AudibleProductId => LibraryBookDto.AudibleProductId; public string? SeriesName => LibraryBookDto.FirstSeries?.Name; - public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number; + public string? SeriesNumber => LibraryBookDto.FirstSeries?.Number; public NAudio.Lame.LameConfig? LameConfig { get; } public string UserAgent => AudibleApi.Resources.Download_User_Agent; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged; diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index b0e05cf7..73ac5bbc 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -83,7 +83,7 @@ namespace FileLiberator .Select(sb => new SeriesDto( sb.Series.Name, - sb.Book.IsEpisodeParent() ? null : sb.Index, + sb.Book.IsEpisodeParent() ? null : sb.Order, sb.Series.AudibleSeriesId) ).ToList(); } diff --git a/Source/FileManager/FileSystemTest.cs b/Source/FileManager/FileSystemTest.cs new file mode 100644 index 00000000..eef4ea28 --- /dev/null +++ b/Source/FileManager/FileSystemTest.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; + +namespace FileManager +{ + public static class FileSystemTest + { + /// + /// Additional characters which are illegal for filenames in Windows environments. + /// Double quotes and slashes are already illegal filename characters on all platforms, + /// so they are not included here. + /// + public static string AdditionalInvalidWindowsFilenameCharacters { get; } = "<>|:*?"; + + /// + /// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, <, >, |). + /// + public static bool CanWriteWindowsInvalidChars(LongPath directoryName) + { + var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString()); + return CanWriteFile(testFile); + } + + /// + /// Test if the directory supports filenames with 255 unicode characters. + /// + public static bool CanWrite255UnicodeChars(LongPath directoryName) + { + const char unicodeChar = 'ü'; + var testFileName = new string(unicodeChar, 255); + var testFile = Path.Combine(directoryName, testFileName); + return CanWriteFile(testFile); + } + + /// + /// Test if a directory has write access by attempting to create an empty file in it. + /// Returns true even if the temporary file can not be deleted. + /// + public static bool CanWriteDirectory(LongPath directoryName) + { + if (!Directory.Exists(directoryName)) + return false; + + Serilog.Log.Logger.Debug("Testing write permissions for directory: {@DirectoryName}", directoryName); + var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString()); + return CanWriteFile(testFilePath); + } + + private static bool CanWriteFile(LongPath filename) + { + try + { + Serilog.Log.Logger.Debug("Testing ability to write filename: {@filename}", filename); + File.WriteAllBytes(filename, []); + Serilog.Log.Logger.Debug("Deleting test file after successful write: {@filename}", filename); + try + { + FileUtility.SaferDelete(filename); + } + catch (Exception ex) + { + //An error deleting the file doesn't constitute a write failure. + Serilog.Log.Logger.Debug(ex, "Error deleting test file: {@filename}", filename); + } + return true; + } + catch (Exception ex) + { + Serilog.Log.Logger.Debug(ex, "Error writing test file: {@filename}", filename); + return false; + } + } + } +} diff --git a/Source/FileManager/LogArchiver.cs b/Source/FileManager/LogArchiver.cs index 04342220..e0041da5 100644 --- a/Source/FileManager/LogArchiver.cs +++ b/Source/FileManager/LogArchiver.cs @@ -56,7 +56,7 @@ namespace FileManager { ArgumentValidator.EnsureNotNull(name, nameof(name)); - name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name); + name = ReplacementCharacters.Barebones(true).ReplaceFilenameChars(name); return Task.Run(() => AddFileInternal(name, contents.Span, comment)); } diff --git a/Source/FileManager/ReplacementCharacters.cs b/Source/FileManager/ReplacementCharacters.cs index 3cf55dd8..476299fb 100644 --- a/Source/FileManager/ReplacementCharacters.cs +++ b/Source/FileManager/ReplacementCharacters.cs @@ -74,12 +74,14 @@ namespace FileManager } public override int GetHashCode() => Replacements.GetHashCode(); - public static readonly ReplacementCharacters Default - = IsWindows - ? new() - { - Replacements = new Replacement[] - { + public static ReplacementCharacters Default(bool ntfs) => ntfs ? HiFi_NTFS : HiFi_Other; + public static ReplacementCharacters LoFiDefault(bool ntfs) => ntfs ? LoFi_NTFS : LoFi_Other; + public static ReplacementCharacters Barebones(bool ntfs) => ntfs ? BareBones_NTFS : BareBones_Other; + + #region Defaults + private static readonly ReplacementCharacters HiFi_NTFS = new() + { + Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("∕"), Replacement.FilenameBackSlash(""), @@ -91,28 +93,23 @@ namespace FileManager Replacement.Colon("_"), Replacement.Asterisk("✱"), Replacement.QuestionMark("?"), - Replacement.Pipe("⏐"), - } - } - : new() - { - Replacements = new Replacement[] - { + Replacement.Pipe("⏐")] + }; + + private static readonly ReplacementCharacters HiFi_Other = new() + { + Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("∕"), Replacement.FilenameBackSlash("\\"), Replacement.OpenQuote("“"), Replacement.CloseQuote("”"), - Replacement.OtherQuote("\"") - } - }; + Replacement.OtherQuote("\"")] + }; - public static readonly ReplacementCharacters LoFiDefault - = IsWindows - ? new() - { - Replacements = new Replacement[] - { + private static readonly ReplacementCharacters LoFi_NTFS = new() + { + Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("_"), @@ -121,56 +118,54 @@ namespace FileManager Replacement.OtherQuote("'"), Replacement.OpenAngleBracket("{"), Replacement.CloseAngleBracket("}"), - Replacement.Colon("-"), - } - } - : new () - { - Replacements = new Replacement[] - { + Replacement.Colon("-")] + }; + + private static readonly ReplacementCharacters LoFi_Other = new() + { + Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("\\"), Replacement.OpenQuote("\""), Replacement.CloseQuote("\""), - Replacement.OtherQuote("\"") - } - }; + Replacement.OtherQuote("\"")] + }; - public static readonly ReplacementCharacters Barebones - = IsWindows - ? new () - { - Replacements = new Replacement[] - { + private static readonly ReplacementCharacters BareBones_NTFS = new() + { + Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("_"), Replacement.OpenQuote("_"), Replacement.CloseQuote("_"), - Replacement.OtherQuote("_") - } - } - : new () - { - Replacements = new Replacement[] - { + Replacement.OtherQuote("_")] + }; + + private static readonly ReplacementCharacters BareBones_Other = new() + { + Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("\\"), Replacement.OpenQuote("\""), Replacement.CloseQuote("\""), - Replacement.OtherQuote("\"") - } - }; + Replacement.OtherQuote("\"")] + }; + #endregion + /// + /// Characters to consider invalid in filenames in addition to those returned by + /// + public static char[] AdditionalInvalidFilenameCharacters { get; set; } = []; - private static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT; + internal static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT; - private static readonly char[] invalidPathChars = Path.GetInvalidFileNameChars().Except(new[] { + private static char[] invalidPathChars { get; } = Path.GetInvalidFileNameChars().Except(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }).ToArray(); - private static readonly char[] invalidSlashes = Path.GetInvalidFileNameChars().Intersect(new[] { + private static char[] invalidSlashes { get; } = Path.GetInvalidFileNameChars().Intersect(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }).ToArray(); @@ -229,8 +224,11 @@ namespace FileManager return DefaultReplacement; } + private static bool CharIsPathInvalid(char c) + => invalidPathChars.Contains(c) || AdditionalInvalidFilenameCharacters.Contains(c); + public static bool ContainsInvalidPathChar(string path) - => path.Any(c => invalidPathChars.Contains(c)); + => path.Any(CharIsPathInvalid); public static bool ContainsInvalidFilenameChar(string path) => ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c)); @@ -242,7 +240,7 @@ namespace FileManager { var c = fileName[i]; - if (invalidPathChars.Contains(c) + if (CharIsPathInvalid(c) || invalidSlashes.Contains(c) || Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ ) { @@ -267,14 +265,14 @@ namespace FileManager if ( ( - invalidPathChars.Contains(c) - || ( // Replace any other legal characters that they user wants. + CharIsPathInvalid(c) + || ( // Replace any other legal characters that they user wants. c != Path.DirectorySeparatorChar && c != Path.AltDirectorySeparatorChar && Replacements.Any(r => r.CharacterToReplace == c) ) ) - && !( // replace all colons except drive letter designator on Windows + && !( // replace all colons except drive letter designator on Windows c == ':' && i == 1 && Path.IsPathRooted(pathStr) @@ -282,9 +280,9 @@ namespace FileManager ) ) { - char preceding = i > 0 ? pathStr[i - 1] : default; - char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default; - builder.Append(GetPathCharReplacement(c, preceding, succeeding)); + char preceding = i > 0 ? pathStr[i - 1] : default; + char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default; + builder.Append(GetPathCharReplacement(c, preceding, succeeding)); } else builder.Append(c); @@ -301,23 +299,21 @@ namespace FileManager public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { + var defaults = ReplacementCharacters.Default(ReplacementCharacters.IsWindows).Replacements; + var jObj = JObject.Load(reader); var replaceArr = jObj[nameof(Replacement)]; - var dict - = replaceArr?.ToObject()?.ToList() - ?? ReplacementCharacters.Default.Replacements; - + var dict = replaceArr?.ToObject()?.ToList() ?? defaults; //Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid. //If not, reset to default. - for (int i = 0; i < Replacement.FIXED_COUNT; i++) { if (dict.Count < Replacement.FIXED_COUNT - || dict[i].CharacterToReplace != ReplacementCharacters.Barebones.Replacements[i].CharacterToReplace - || dict[i].Description != ReplacementCharacters.Barebones.Replacements[i].Description) + || dict[i].CharacterToReplace != defaults[i].CharacterToReplace + || dict[i].Description != defaults[i].Description) { - dict = ReplacementCharacters.Default.Replacements; + dict = defaults; break; } diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index 9c89e841..bbff098b 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -111,7 +111,7 @@ namespace LibationAvalonia if (setupDialog.Config.LibationSettingsAreValid) { string? theme = setupDialog.SelectedTheme.Content as string; - + setupDialog.Config.SetString(theme, nameof(ThemeVariant)); await RunMigrationsAsync(setupDialog.Config); @@ -120,7 +120,10 @@ namespace LibationAvalonia ShowMainWindow(desktop); } else - await CancelInstallation(); + { + e.Cancel = true; + await CancelInstallation(setupDialog); + } } else if (setupDialog.IsReturningUser) { @@ -128,7 +131,8 @@ namespace LibationAvalonia } else { - await CancelInstallation(); + e.Cancel = true; + await CancelInstallation(setupDialog); return; } @@ -139,11 +143,11 @@ namespace LibationAvalonia var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; try { - await MessageBox.ShowAdminAlert(null, body, title, ex); + await MessageBox.ShowAdminAlert(setupDialog, body, title, ex); } catch { - await MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); + await MessageBox.Show(setupDialog, $"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); } return; } @@ -190,6 +194,7 @@ namespace LibationAvalonia { // path did not result in valid settings var continueResult = await MessageBox.Show( + libationFilesDialog, $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", "New install?", MessageBoxButtons.YesNo, @@ -207,18 +212,18 @@ namespace LibationAvalonia ShowMainWindow(desktop); } else - await CancelInstallation(); + await CancelInstallation(libationFilesDialog); } else - await CancelInstallation(); + await CancelInstallation(libationFilesDialog); } libationFilesDialog.Close(); } - static async Task CancelInstallation() + static async Task CancelInstallation(Window window) { - await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); Environment.Exit(0); } diff --git a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs index b0dff8ea..b65ed7b6 100644 --- a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs +++ b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs @@ -92,13 +92,11 @@ namespace LibationAvalonia.Controls base.UpdateDataValidation(property, state, error); if (property == CommandProperty) { - if (state == BindingValueType.BindingError) + var canExecure = !state.HasFlag(BindingValueType.HasError); + if (canExecure != _commandCanExecute) { - if (_commandCanExecute) - { - _commandCanExecute = false; - UpdateIsEffectivelyEnabled(); - } + _commandCanExecute = canExecure; + UpdateIsEffectivelyEnabled(); } } } diff --git a/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml b/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml index d04f1161..f60f2f16 100644 --- a/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml +++ b/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml @@ -6,6 +6,8 @@ MinWidth="500" MinHeight="450" Width="500" Height="450" x:Class="LibationAvalonia.Dialogs.EditReplacementChars" + xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs" + x:DataType="dialogs:EditReplacementChars" Title="Illegal Character Replacement"> + ItemsSource="{CompiledBinding replacements}"> - - - + + - - + + - - + + @@ -55,21 +56,31 @@ - - -