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")]);