Smart handling of filename limitations cross platform

Automatically determine if filename lengths in the Books directory are limited to 255 UTF-16 characters (NTFS) or 255 UTF-8 bytes (pretty much every other file system) (#1260)

In non-Windows environments, determine if the Books directory supports filenames containing characters which are illegal in Windows environments (<>|:*?). If it doesn't, then ensure those characters are included in the user's ReplacementCharacters settings (#1258).
This commit is contained in:
Michael Bucari-Tovo 2025-07-30 14:49:28 -06:00
parent 7024bbf823
commit ae012548bd
5 changed files with 148 additions and 2 deletions

View File

@ -0,0 +1,74 @@
using System;
using System.IO;
namespace FileManager
{
public static class FileSystemTest
{
/// <summary>
/// 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.
/// </summary>
public static string AdditionalInvalidWindowsFilenameCharacters { get; } = "<>|:*?";
/// <summary>
/// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, &lt;, &gt;, |).
/// </summary>
public static bool CanWriteWindowsInvalidChars(LongPath directoryName)
{
var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString());
return CanWriteFile(testFile);
}
/// <summary>
/// Test if the directory supports filenames with 255 unicode characters.
/// </summary>
public static bool CanWrite255UnicodeChars(LongPath directoryName)
{
const char unicodeChar = 'ü';
var testFileName = new string(unicodeChar, 255);
var testFile = Path.Combine(directoryName, testFileName);
return CanWriteFile(testFile);
}
/// <summary>
/// Test if a directory has write access by attempting to create an empty file in it.
/// <para/>Returns true even if the temporary file can not be deleted.
/// </summary>
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;
}
}
}
}

View File

@ -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)

View File

@ -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;
/// <summary>
/// True if the Books directory can be written to with 255 unicode character filenames
/// <para/> Does not persist. Check and set this value at runtime and whenever Books is changed.
/// </summary>
public bool BooksCanWrite255UnicodeChars => m_BooksCanWrite255UnicodeChars ??= FileSystemTest.CanWrite255UnicodeChars(Books);
/// <summary>
/// True if the Books directory can be written to with filenames containing characters invalid on Windows (:, *, ?, &lt;, &gt;, |)
/// <para/> Always false on Windows platforms.
/// <para/> Does not persist. Check and set this value at runtime and whenever Books is changed.
/// </summary>
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); }

View File

@ -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);
/// <summary>
/// Organize template parts into directories. Any Extra slashes will be
/// returned as empty directories and are taken care of by Path.Combine()

View File

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