Libation/Source/LibationAvalonia/Views/MainWindow.axaml.cs
Michael Bucari-Tovo ae012548bd 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).
2025-07-30 16:04:48 -06:00

229 lines
8.4 KiB
C#

using AudibleUtilities;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
using DataLayer;
using FileManager;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.Forms;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Views
{
public partial class MainWindow : ReactiveWindow<MainVM>
{
public MainWindow()
{
if (Design.IsDesignMode)
_ = Configuration.Instance.LibationFiles;
DataContext = new MainVM(this);
ApiExtended.LoginChoiceFactory = account => Dispatcher.UIThread.Invoke(() => new Dialogs.Login.AvaloniaLoginChoiceEager(account));
AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
InitializeComponent();
Configure_Upgrade();
Opened += MainWindow_Opened;
Closing += MainWindow_Closing;
KeyBindings.Add(new KeyBinding { Command = ReactiveCommand.Create(selectAndFocusSearchBox), Gesture = new KeyGesture(Key.F, Configuration.IsMacOs ? KeyModifiers.Meta : KeyModifiers.Control) });
if (!Configuration.IsMacOs)
{
KeyBindings.Add(new KeyBinding { Command = ReactiveCommand.Create(ViewModel.ShowSettingsAsync), Gesture = new KeyGesture(Key.P, KeyModifiers.Control) });
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)
{
try
{
//Backup AccountSettings.json and create a new, empty file.
var backupFile =
FileUtility.SaferMoveToValidPath(
e.SettingsFilePath,
e.SettingsFilePath,
Configuration.Instance.ReplacementCharacters,
"bak");
AudibleApiStorage.EnsureAccountsSettingsFileExists();
e.Handled = true;
showAccountSettingsRecoveredMessage(backupFile);
}
catch
{
showAccountSettingsUnrecoveredMessage();
}
async void showAccountSettingsRecoveredMessage(LongPath backupFile)
=> await MessageBox.Show(this, $"""
Libation could not load your account settings, so it had created a new, empty account settings file.
You will need to re-add you Audible account(s) before scanning or downloading.
The old account settings file has been archived at '{backupFile.PathWithoutPrefix}'
{e.GetException().ToString()}
""",
"Error Loading Account Settings",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
void showAccountSettingsUnrecoveredMessage()
{
var messageBoxWindow = MessageBox.Show(this, $"""
Libation could not load your account settings. The file may be corrupted, but Libation is unable to delete it.
Please move or delete the account settings file '{e.SettingsFilePath}'
{e.GetException().ToString()}
""",
"Error Loading Account Settings",
MessageBoxButtons.OK);
//Force the message box to show synchronously because we're not handling the exception
//and libation will crash after the event handler returns
var frame = new DispatcherFrame();
_ = messageBoxWindow.ContinueWith(static (_, s) => ((DispatcherFrame)s).Continue = false, frame);
Dispatcher.UIThread.PushFrame(frame);
messageBoxWindow.GetAwaiter().GetResult();
}
}
private async void MainWindow_Opened(object sender, EventArgs e)
{
if (Configuration.Instance.FirstLaunch)
{
var result = await MessageBox.Show(this, "Would you like a guided tour to get started?", "Libation Walkthrough", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
if (result is DialogResult.Yes)
{
await new Walkthrough(this).RunAsync();
}
Configuration.Instance.FirstLaunch = false;
}
}
private void MainWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
productsDisplay?.CloseImageDisplay();
this.SaveSizeAndLocation(Configuration.Instance);
}
private void selectAndFocusSearchBox()
{
filterSearchTb.SelectAll();
filterSearchTb.Focus();
}
public async Task OnLibraryLoadedAsync(List<LibraryBook> initialLibrary)
{
//Get the ViewModel before crossing the await boundary
var vm = ViewModel;
if (QuickFilters.UseDefault)
await vm.PerformFilter(QuickFilters.Filters.FirstOrDefault());
await Task.WhenAll(
vm.SetBackupCountsAsync(initialLibrary),
Task.Run(() => vm.ProductsDisplay.BindToGridAsync(initialLibrary)));
}
public void ProductsDisplay_LiberateClicked(object _, LibraryBook[] libraryBook) => ViewModel.LiberateClicked(libraryBook);
public void ProductsDisplay_LiberateSeriesClicked(object _, SeriesEntry series) => ViewModel.LiberateSeriesClicked(series);
public void ProductsDisplay_ConvertToMp3Clicked(object _, LibraryBook[] libraryBook) => ViewModel.ConvertToMp3Clicked(libraryBook);
BookDetailsDialog bookDetailsForm;
public void ProductsDisplay_TagsButtonClicked(object _, LibraryBook libraryBook)
{
if (bookDetailsForm is null || !bookDetailsForm.IsVisible)
{
bookDetailsForm = new BookDetailsDialog(libraryBook);
bookDetailsForm.Show(this);
}
else
bookDetailsForm.LibraryBook = libraryBook;
}
public async void filterSearchTb_KeyPress(object _, KeyEventArgs e)
{
if (e.Key == Key.Return)
{
await ViewModel.FilterBtn(filterSearchTb.Text);
// silence the 'ding'
e.Handled = true;
}
}
private void Configure_Upgrade()
{
setProgressVisible(false);
#pragma warning disable CS8321 // Local function is declared but never used
async Task upgradeAvailable(LibationUiBase.UpgradeEventArgs e)
{
var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties, e.CapUpgrade).ShowDialogAsync(this);
e.Ignore = notificationResult == DialogResult.Ignore;
e.InstallUpgrade = notificationResult == DialogResult.OK;
}
#pragma warning restore CS8321 // Local function is declared but never used
var upgrader = new LibationUiBase.Upgrader();
upgrader.DownloadProgress += async (_, e) => await Dispatcher.UIThread.InvokeAsync(() => ViewModel.DownloadProgress = e.ProgressPercentage);
upgrader.DownloadBegin += async (_, _) => await Dispatcher.UIThread.InvokeAsync(() => setProgressVisible(true));
upgrader.DownloadCompleted += async (_, _) => await Dispatcher.UIThread.InvokeAsync(() => setProgressVisible(false));
upgrader.UpgradeFailed += async (_, message) => await Dispatcher.UIThread.InvokeAsync(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); });
#if !DEBUG
Opened += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable);
#endif
}
private void setProgressVisible(bool visible) => ViewModel.DownloadProgress = visible ? 0 : null;
}
}