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/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 63a35da1..f36d8cf7 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -43,6 +43,38 @@ namespace LibationAvalonia.Views KeyBindings.Add(new KeyBinding { Command = ReactiveCommand.Create(ViewModel.ShowAccountsAsync), Gesture = new KeyGesture(Key.A, KeyModifiers.Control | KeyModifiers.Shift) }); KeyBindings.Add(new KeyBinding { Command = ReactiveCommand.Create(ViewModel.ExportLibraryAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Control) }); } + + Configuration.Instance.PropertyChanged += Settings_PropertyChanged; + Settings_PropertyChanged(this, null); + } + + [Dinah.Core.PropertyChangeFilter(nameof(Configuration.Books))] + private void Settings_PropertyChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e) + { + if (!Configuration.IsWindows && !Configuration.Instance.BooksCanWriteWindowsInvalidChars) + { + //The books directory does not support filenames with windows' invalid characters. + //Ensure that the ReplacementCharacters configuration has replacements for all invalid characters. + //We can't rely on the "other invalid characters" replacement because that is only used by + //ReplacementCharacters for platform-specific illegal characters, whereas for the Books directory + //we are concerned with the ultimate destination directory's capabilities. + var defaults = ReplacementCharacters.Default(true).Replacements; + var replacements = Configuration.Instance.ReplacementCharacters.Replacements.ToList(); + bool changed = false; + foreach (var c in FileSystemTest.AdditionalInvalidWindowsFilenameCharacters) + { + if (!replacements.Any(r => r.CharacterToReplace == c)) + { + var replacement = defaults.FirstOrDefault(r => r.CharacterToReplace == c) ?? defaults[0]; + replacements.Add(replacement); + changed = true; + } + } + if (changed) + { + Configuration.Instance.ReplacementCharacters = new ReplacementCharacters { Replacements = replacements }; + } + } } private void AudibleApiStorage_LoadError(object sender, AccountSettingsLoadErrorEventArgs e) diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index eb648b3d..be4a5a7e 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -111,7 +111,34 @@ namespace LibationFileManager public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); } [Description("Location for book storage. Includes destination of newly liberated books")] - public LongPath? Books { get => GetString(); set => SetString(value); } + public LongPath? Books { + get => GetString(); + set + { + if (value != Books) + { + OnPropertyChanging(nameof(Books), Books, value); + Settings.SetString(nameof(Books), value); + m_BooksCanWrite255UnicodeChars = null; + m_BooksCanWriteWindowsInvalidChars = null; + OnPropertyChanged(nameof(Books), value); + } + } + } + + private bool? m_BooksCanWrite255UnicodeChars; + private bool? m_BooksCanWriteWindowsInvalidChars; + /// + /// True if the Books directory can be written to with 255 unicode character filenames + /// Does not persist. Check and set this value at runtime and whenever Books is changed. + /// + public bool BooksCanWrite255UnicodeChars => m_BooksCanWrite255UnicodeChars ??= FileSystemTest.CanWrite255UnicodeChars(Books); + /// + /// True if the Books directory can be written to with filenames containing characters invalid on Windows (:, *, ?, <, >, |) + /// Always false on Windows platforms. + /// Does not persist. Check and set this value at runtime and whenever Books is changed. + /// + public bool BooksCanWriteWindowsInvalidChars => !IsWindows && (m_BooksCanWriteWindowsInvalidChars ??= FileSystemTest.CanWriteWindowsInvalidChars(Books)); [Description("Overwrite existing files if they already exist?")] public bool OverwriteExisting { get => GetNonString(defaultValue: false); set => SetNonString(value); } diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index ac0af0fa..5b36ae1a 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -157,7 +157,7 @@ namespace LibationFileManager.Templates var maxFilenameLength = LongPath.MaxFilenameLength - (i < pathParts.Count - 1 || string.IsNullOrEmpty(fileExtension) ? 0 : fileExtension.Length + 5); - while (part.Sum(LongPath.GetFilesystemStringLength) > maxFilenameLength) + while (part.Sum(GetFilenameLength) > maxFilenameLength) { int maxLength = part.Max(p => p.Length); var maxEntry = part.First(p => p.Length == maxLength); @@ -173,6 +173,10 @@ namespace LibationFileManager.Templates return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting); } + private static int GetFilenameLength(string filename) + => Configuration.Instance.BooksCanWrite255UnicodeChars ? filename.Length + : System.Text.Encoding.UTF8.GetByteCount(filename); + /// /// Organize template parts into directories. Any Extra slashes will be /// returned as empty directories and are taken care of by Path.Combine() diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 4d8aa485..cb853d15 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -23,6 +23,15 @@ namespace TemplatesTests public static class Shared { + [System.Runtime.CompilerServices.ModuleInitializer] + public static void Init() + { + var thisDir = Path.GetDirectoryName(Environment.ProcessPath); + LibationFileManager.Configuration.SetLibationFiles(thisDir); + if (!LibationFileManager.Configuration.Instance.LibationSettingsAreValid) + LibationFileManager.Configuration.Instance.Books = Path.Combine(thisDir, "Books"); + } + public static LibraryBookDto GetLibraryBook() => GetLibraryBook([new SeriesDto("Sherlock Holmes", "1", "B08376S3R2")]);