mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-06-27 00:36:20 -04:00
Merge pull request #1869 from rmcrackan/rmcrackan/1867-duplicate-asin
#1867 - Fix duplicate-ASIN crashes. New Hangover recovery
This commit is contained in:
@@ -132,9 +132,9 @@ public static class DbContexts
|
||||
return context.GetDeletedLibraryBooks();
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
|
||||
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative, account);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
@@ -23,7 +24,12 @@ public static class LibraryCommands
|
||||
public static event EventHandler<int>? ScanEnd;
|
||||
|
||||
public static bool Scanning { get; private set; }
|
||||
private static object _lock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Serializes library scan and import operations so only one path reads/writes
|
||||
/// <see cref="Book"/> / <see cref="LibraryBook"/> rows at a time (prevents duplicate ASIN inserts).
|
||||
/// </summary>
|
||||
private static readonly SemaphoreSlim ImportGate = new(1, 1);
|
||||
|
||||
static LibraryCommands()
|
||||
{
|
||||
@@ -35,67 +41,69 @@ public static class LibraryCommands
|
||||
{
|
||||
logRestart();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return new();
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||
};
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return new List<LibraryBook>();
|
||||
|
||||
await ImportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
|
||||
return missingBookList;
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, 0);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, 0);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,79 +115,93 @@ public static class LibraryCommands
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return (0, 0);
|
||||
|
||||
await ImportGate.WaitAsync();
|
||||
int newCount = 0;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return (0, 0);
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryOptions = new LibraryOptions
|
||||
try
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(ImportIntoDbAsync)}");
|
||||
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
|
||||
logTime($"post {nameof(ImportIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(ImportIntoDbAsync)}");
|
||||
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
|
||||
logTime($"post {nameof(ImportIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, newCount);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, newCount);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
|
||||
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
{
|
||||
await ImportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
return importSingleToDb(item, accountId, localeName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(item, nameof(item));
|
||||
|
||||
@@ -33,7 +33,7 @@ public static class LibraryBookQueries
|
||||
.AsEnumerable()
|
||||
.ToList();
|
||||
|
||||
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
|
||||
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
|
||||
{
|
||||
var libraryQuery
|
||||
= context
|
||||
@@ -41,8 +41,14 @@ public static class LibraryBookQueries
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibrary();
|
||||
|
||||
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
|
||||
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
|
||||
var matches = caseSensative
|
||||
? libraryQuery.Where(lb => lb.Book.AudibleProductId == productId)
|
||||
: libraryQuery.Where(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
|
||||
|
||||
if (account is not null)
|
||||
matches = matches.Where(lb => lb.Account == account);
|
||||
|
||||
return matches.FirstOrDefault();
|
||||
}
|
||||
|
||||
public List<LibraryBook> GetUnLiberated_Flat_NoTracking()
|
||||
|
||||
@@ -26,7 +26,9 @@ public static class AudioFileStorageExt
|
||||
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
|
||||
if (series is not null)
|
||||
{
|
||||
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
|
||||
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(
|
||||
series.Series.AudibleSeriesId,
|
||||
account: libraryBook.Account);
|
||||
if (seriesParent is not null)
|
||||
{
|
||||
return Templates.Folder.GetFilename(seriesParent.ToDto(), books, "");
|
||||
|
||||
61
Source/HangoverAvalonia/HangoverMutationConfirm.cs
Normal file
61
Source/HangoverAvalonia/HangoverMutationConfirm.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using HangoverBase;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia;
|
||||
|
||||
internal static class HangoverMutationConfirm
|
||||
{
|
||||
public static async Task<bool> ConfirmAsync(Window owner, string actionDescription)
|
||||
{
|
||||
bool? result = null;
|
||||
|
||||
var yesButton = new Button { Content = "Yes", MinWidth = 75 };
|
||||
var noButton = new Button { Content = "No", MinWidth = 75 };
|
||||
|
||||
var buttonPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 10,
|
||||
Margin = new Thickness(0, 15, 0, 0),
|
||||
Children = { noButton, yesButton },
|
||||
};
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = HangoverDbMutation.ConfirmTitle,
|
||||
Width = 420,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
CanResize = false,
|
||||
Content = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("*,Auto"),
|
||||
Margin = new Thickness(10),
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
[Grid.RowProperty] = 0,
|
||||
Text = HangoverDbMutation.BuildConfirmMessage(actionDescription),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
},
|
||||
buttonPanel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Grid.SetRow(buttonPanel, 1);
|
||||
|
||||
yesButton.Click += (_, _) => { result = true; dialog.Close(); };
|
||||
noButton.Click += (_, _) => { result = false; dialog.Close(); };
|
||||
dialog.Closed += (_, _) => result ??= false;
|
||||
|
||||
await dialog.ShowDialog(owner);
|
||||
return result == true;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using HangoverBase;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
@@ -10,26 +12,78 @@ public partial class MainVM
|
||||
private string _databaseFileText;
|
||||
private bool _databaseFound;
|
||||
private string _sqlResults;
|
||||
private string _duplicateResults;
|
||||
private string _duplicateAsinStatusText;
|
||||
private bool _canRemoveDuplicateAsins;
|
||||
private bool _confirmRemoveDuplicateAsins;
|
||||
|
||||
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
|
||||
public string SqlQuery { get; set; }
|
||||
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
|
||||
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
|
||||
public string DuplicateResults { get => _duplicateResults; set => this.RaiseAndSetIfChanged(ref _duplicateResults, value); }
|
||||
public string DuplicateAsinStatusText { get => _duplicateAsinStatusText; set => this.RaiseAndSetIfChanged(ref _duplicateAsinStatusText, value); }
|
||||
public bool CanRemoveDuplicateAsins { get => _canRemoveDuplicateAsins; set => this.RaiseAndSetIfChanged(ref _canRemoveDuplicateAsins, value); }
|
||||
public bool ConfirmRemoveDuplicateAsins
|
||||
{
|
||||
get => _confirmRemoveDuplicateAsins;
|
||||
set => this.RaiseAndSetIfChanged(ref _confirmRemoveDuplicateAsins, value);
|
||||
}
|
||||
|
||||
private void Load_databaseVM()
|
||||
{
|
||||
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s));
|
||||
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s, s => DuplicateResults = s));
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
DatabaseFileText = $"Database file not found";
|
||||
DuplicateAsinStatusText = "Duplicate ASIN cleanup unavailable (database not found).";
|
||||
DatabaseFound = false;
|
||||
CanRemoveDuplicateAsins = false;
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseFileText = $"Database file: {_tab.DbFile}";
|
||||
DatabaseFound = true;
|
||||
RefreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
public void ExecuteQuery() => _tab.ExecuteQuery();
|
||||
public async Task ExecuteQueryAsync()
|
||||
{
|
||||
if (HangoverDbMutation.IsMutatingSql(SqlQuery)
|
||||
&& ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverDbMutation.SqlMutatingDescription))
|
||||
return;
|
||||
|
||||
_tab.ExecuteQuery();
|
||||
}
|
||||
|
||||
public void RefreshDuplicateAsinStatus()
|
||||
{
|
||||
DuplicateAsinStatusText = _tab.GetDuplicateAsinStatusText();
|
||||
CanRemoveDuplicateAsins = _tab.CanRemoveDuplicateAsins();
|
||||
if (!CanRemoveDuplicateAsins)
|
||||
ConfirmRemoveDuplicateAsins = false;
|
||||
}
|
||||
|
||||
public void ScanDuplicateAsins()
|
||||
{
|
||||
_tab.ScanDuplicateAsins();
|
||||
RefreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
public async Task RemoveDuplicateAsinsAsync()
|
||||
{
|
||||
if (!ConfirmRemoveDuplicateAsins)
|
||||
return;
|
||||
|
||||
if (ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverDbMutation.RemoveDuplicateAsinsDescription))
|
||||
return;
|
||||
|
||||
_tab.RemoveDuplicateAsins();
|
||||
ConfirmRemoveDuplicateAsins = false;
|
||||
RefreshDuplicateAsinStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
public partial class MainVM
|
||||
{
|
||||
public TrashBinViewModel TrashBinViewModel { get; } = new();
|
||||
|
||||
private void Load_deletedVM() { }
|
||||
private void Load_deletedVM()
|
||||
{
|
||||
TrashBinViewModel.ConfirmDbMutationAsync = action
|
||||
=> ConfirmDbMutationAsync is { } confirm
|
||||
? confirm(action)
|
||||
: Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
public partial class MainVM : ViewModelBase
|
||||
{
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public MainVM()
|
||||
{
|
||||
Load_databaseVM();
|
||||
|
||||
@@ -77,8 +77,17 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
item.IsChecked = false;
|
||||
}
|
||||
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public async Task RestoreCheckedAsync()
|
||||
{
|
||||
if (!CheckedBooks.Any())
|
||||
return;
|
||||
|
||||
if (ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
|
||||
return;
|
||||
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await CheckedBooks.RestoreBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
@@ -88,6 +97,13 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public async Task PermanentlyDeleteCheckedAsync()
|
||||
{
|
||||
if (!CheckedBooks.Any())
|
||||
return;
|
||||
|
||||
if (ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.PermanentlyDeleteBooksDescription))
|
||||
return;
|
||||
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await CheckedBooks.PermanentlyDeleteBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace HangoverAvalonia.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void cliTab_VisibleChanged(bool isVisible)
|
||||
{
|
||||
if (!isVisible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void databaseTab_VisibleChanged(bool isVisible)
|
||||
{
|
||||
if (!isVisible)
|
||||
return;
|
||||
}
|
||||
|
||||
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.ExecuteQuery();
|
||||
}
|
||||
public async void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.ExecuteQueryAsync();
|
||||
}
|
||||
|
||||
18
Source/HangoverAvalonia/Views/MainWindow.FixDuplicates.cs
Normal file
18
Source/HangoverAvalonia/Views/MainWindow.FixDuplicates.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace HangoverAvalonia.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void fixDuplicatesTab_VisibleChanged(bool isVisible)
|
||||
{
|
||||
if (!isVisible)
|
||||
return;
|
||||
|
||||
_viewModel.RefreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
public void ScanDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> _viewModel.ScanDuplicateAsins();
|
||||
|
||||
public async void RemoveDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.RemoveDuplicateAsinsAsync();
|
||||
}
|
||||
@@ -1,140 +1,362 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
|
||||
xmlns:vm="using:HangoverAvalonia.ViewModels"
|
||||
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
|
||||
|
||||
xmlns:vms="clr-namespace:HangoverAvalonia.ViewModels"
|
||||
|
||||
x:DataType="vms:MainVM"
|
||||
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
|
||||
|
||||
Width="800" Height="500"
|
||||
|
||||
x:Class="HangoverAvalonia.Views.MainWindow"
|
||||
|
||||
Icon="/Assets/hangover.ico "
|
||||
|
||||
Title="Hangover: Libation debug and recovery tool">
|
||||
|
||||
<Design.DataContext>
|
||||
|
||||
<vm:MainVM/>
|
||||
|
||||
</Design.DataContext>
|
||||
|
||||
|
||||
|
||||
<TabControl Name="tabControl1" Grid.Row="0">
|
||||
|
||||
<TabControl.Styles>
|
||||
|
||||
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
|
||||
|
||||
<Setter Property="Height" Value="33"/>
|
||||
|
||||
</Style>
|
||||
|
||||
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
|
||||
|
||||
<Setter Property="Height" Value="33"/>
|
||||
|
||||
</Style>
|
||||
|
||||
<Style Selector="TabItem#Header TextBlock">
|
||||
|
||||
<Setter Property="MinHeight" Value="5"/>
|
||||
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button">
|
||||
|
||||
<Setter Property="Padding" Value="20,5,20,5"/>
|
||||
|
||||
</Style>
|
||||
|
||||
</TabControl.Styles>
|
||||
|
||||
|
||||
|
||||
<!-- Database Tab -->
|
||||
|
||||
<TabItem Name="databaseTab">
|
||||
|
||||
<TabItem.Header>
|
||||
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
|
||||
|
||||
</TabItem.Header>
|
||||
|
||||
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto,2*">
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Margin="0,10,0,5"
|
||||
|
||||
Grid.Row="0"
|
||||
|
||||
Text="{Binding DatabaseFileText}" />
|
||||
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Margin="0,5,0,5"
|
||||
|
||||
Grid.Row="1"
|
||||
|
||||
Text="SQL (database command)" />
|
||||
|
||||
|
||||
|
||||
<TextBox
|
||||
|
||||
Margin="0,5,0,5"
|
||||
|
||||
AcceptsReturn="True"
|
||||
|
||||
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
Grid.Row="3"
|
||||
|
||||
Content="Execute"
|
||||
|
||||
IsEnabled="{Binding DatabaseFound}"
|
||||
|
||||
Click="Execute_Click" />
|
||||
|
||||
|
||||
|
||||
<TextBox
|
||||
|
||||
Margin="0,5,0,10"
|
||||
|
||||
IsReadOnly="True"
|
||||
|
||||
Grid.Row="4"
|
||||
|
||||
Text="{Binding SqlResults}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
</TabItem>
|
||||
|
||||
|
||||
|
||||
<!-- Deleted Books Tab -->
|
||||
|
||||
<TabItem Name="deletedTab">
|
||||
|
||||
<TabItem.Header>
|
||||
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
|
||||
|
||||
</TabItem.Header>
|
||||
|
||||
<Grid
|
||||
|
||||
DataContext="{Binding TrashBinViewModel}"
|
||||
|
||||
RowDefinitions="Auto,*,Auto">
|
||||
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Grid.Row="0"
|
||||
|
||||
Margin="5"
|
||||
|
||||
Text="Check books you want to permanently delete from or restore to Libation" />
|
||||
|
||||
|
||||
|
||||
<controls:CheckedListBox
|
||||
|
||||
Grid.Row="1"
|
||||
|
||||
Margin="5,0,5,0"
|
||||
|
||||
BorderThickness="1"
|
||||
|
||||
BorderBrush="Gray"
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
Items="{Binding DeletedBooks}" />
|
||||
|
||||
|
||||
|
||||
<Grid
|
||||
|
||||
Grid.Row="2"
|
||||
|
||||
Margin="5"
|
||||
|
||||
ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
|
||||
|
||||
<CheckBox
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
IsThreeState="True"
|
||||
|
||||
Margin="0,0,20,0"
|
||||
|
||||
IsChecked="{Binding EverythingChecked}"
|
||||
|
||||
Content="Everything" />
|
||||
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Grid.Column="1"
|
||||
|
||||
VerticalAlignment="Center"
|
||||
|
||||
Text="{Binding CheckedCountText}" />
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
Grid.Column="2"
|
||||
|
||||
Margin="0,0,20,0"
|
||||
|
||||
HorizontalAlignment="Right"
|
||||
|
||||
VerticalAlignment="Stretch"
|
||||
|
||||
VerticalContentAlignment="Center"
|
||||
|
||||
Content="Restore"
|
||||
|
||||
Command="{Binding RestoreCheckedAsync}"/>
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
Grid.Column="3"
|
||||
|
||||
Command="{Binding PermanentlyDeleteCheckedAsync}" >
|
||||
|
||||
<TextBlock
|
||||
|
||||
TextAlignment="Center"
|
||||
|
||||
Text="Permanently Delete
from Libation" />
|
||||
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</TabItem>
|
||||
|
||||
<!-- Command Line Interface Tab -->
|
||||
<TabItem Name="cliTab">
|
||||
|
||||
|
||||
<!-- Fix Duplicates Tab -->
|
||||
|
||||
<TabItem Name="fixDuplicatesTab">
|
||||
|
||||
<TabItem.Header>
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
|
||||
</TabItem.Header>
|
||||
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Fix Duplicates</TextBlock>
|
||||
|
||||
</TabItem.Header>
|
||||
|
||||
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<TextBlock
|
||||
|
||||
Margin="0,10,0,10"
|
||||
|
||||
Grid.Row="0"
|
||||
|
||||
Text="{Binding DuplicateAsinStatusText}"
|
||||
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
|
||||
|
||||
<Grid
|
||||
|
||||
Grid.Row="1"
|
||||
|
||||
ColumnDefinitions="Auto,Auto,*">
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
Grid.Column="0"
|
||||
|
||||
Margin="0,0,10,0"
|
||||
|
||||
Content="Scan Duplicate ASINs"
|
||||
|
||||
IsEnabled="{Binding DatabaseFound}"
|
||||
|
||||
Click="ScanDuplicateAsins_Click" />
|
||||
|
||||
|
||||
|
||||
<CheckBox
|
||||
|
||||
Grid.Column="1"
|
||||
|
||||
Margin="0,0,10,0"
|
||||
|
||||
VerticalAlignment="Center"
|
||||
|
||||
IsEnabled="{Binding CanRemoveDuplicateAsins}"
|
||||
|
||||
IsChecked="{Binding ConfirmRemoveDuplicateAsins}"
|
||||
|
||||
Content="Confirm removal" />
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
Grid.Column="2"
|
||||
|
||||
HorizontalAlignment="Left"
|
||||
|
||||
Content="Remove Duplicates"
|
||||
|
||||
IsEnabled="{Binding ConfirmRemoveDuplicateAsins}"
|
||||
|
||||
Click="RemoveDuplicateAsins_Click" />
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
|
||||
<TextBox
|
||||
|
||||
Margin="0,10,0,10"
|
||||
|
||||
AcceptsReturn="True"
|
||||
|
||||
IsReadOnly="True"
|
||||
|
||||
Grid.Row="2"
|
||||
|
||||
Text="{Binding DuplicateResults}"
|
||||
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
|
||||
</Window>
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ public partial class MainWindow : Window
|
||||
|
||||
public void OnLoad()
|
||||
{
|
||||
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
|
||||
_viewModel.ConfirmDbMutationAsync = action => HangoverMutationConfirm.ConfirmAsync(this, action);
|
||||
|
||||
fixDuplicatesTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) fixDuplicatesTab_VisibleChanged(fixDuplicatesTab.IsSelected); };
|
||||
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
|
||||
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,19 @@ public class DatabaseTabCommands
|
||||
public Func<string> SqlInput { get; }
|
||||
public Action<string> SqlOutputAppend { get; }
|
||||
public Action<string> SqlOutputOverwrite { get; }
|
||||
public Action<string> DuplicateOutputOverwrite { get; }
|
||||
|
||||
public DatabaseTabCommands() { }
|
||||
public DatabaseTabCommands(
|
||||
Func<string> sqlInput,
|
||||
Action<string> sqlDisplayAppend,
|
||||
Action<string> sqlDisplayOverwrite)
|
||||
Action<string> sqlDisplayOverwrite,
|
||||
Action<string> duplicateOutputOverwrite)
|
||||
{
|
||||
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
|
||||
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
|
||||
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
|
||||
DuplicateOutputOverwrite = ArgumentValidator.EnsureNotNull(duplicateOutputOverwrite, nameof(duplicateOutputOverwrite));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +39,7 @@ public class DatabaseTab
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.DuplicateOutputOverwrite));
|
||||
}
|
||||
|
||||
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||
@@ -151,4 +155,40 @@ public class DatabaseTab
|
||||
if (results != 1) _commands.SqlOutputAppend("s");
|
||||
_commands.SqlOutputAppend(" affected");
|
||||
}
|
||||
|
||||
public string GetDuplicateAsinStatusText()
|
||||
{
|
||||
if (DuplicateAsinCleanup.IsCompleted())
|
||||
return "Duplicate ASIN cleanup: completed (one-time migration already run).";
|
||||
|
||||
var scan = DuplicateAsinCleanup.Scan();
|
||||
return scan.DuplicateGroupCount == 0
|
||||
? "Duplicate ASIN cleanup: no duplicates found."
|
||||
: $"Duplicate ASIN cleanup: {scan.DuplicateGroupCount} duplicate ASIN group(s) found. Scan for details, then remove.";
|
||||
}
|
||||
|
||||
public bool CanRemoveDuplicateAsins()
|
||||
=> !DuplicateAsinCleanup.IsCompleted() && DuplicateAsinCleanup.Scan().DuplicateGroupCount > 0;
|
||||
|
||||
public void ScanDuplicateAsins()
|
||||
{
|
||||
var scan = DuplicateAsinCleanup.Scan();
|
||||
_commands.DuplicateOutputOverwrite(scan.Report);
|
||||
}
|
||||
|
||||
public string RemoveDuplicateAsins()
|
||||
{
|
||||
EnsureBackup();
|
||||
|
||||
try
|
||||
{
|
||||
var result = DuplicateAsinCleanup.Execute();
|
||||
_commands.DuplicateOutputOverwrite(result.Report);
|
||||
return result.Report;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteUnneededBackups();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
214
Source/HangoverBase/DuplicateAsinCleanup.cs
Normal file
214
Source/HangoverBase/DuplicateAsinCleanup.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using ApplicationServices;
|
||||
using AppScaffolding;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text;
|
||||
|
||||
namespace HangoverBase;
|
||||
|
||||
/// <summary>
|
||||
/// One-time utility to find and remove duplicate <see cref="Book"/> + <see cref="LibraryBook"/> rows
|
||||
/// that share the same <see cref="Book.AudibleProductId"/>.
|
||||
/// </summary>
|
||||
public static class DuplicateAsinCleanup
|
||||
{
|
||||
public const string SettingsKey = "HangoverDuplicateAsinCleanupCompleted";
|
||||
|
||||
public static bool IsCompleted()
|
||||
=> UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out var value) && value == "true";
|
||||
|
||||
public static DuplicateAsinScanResult Scan()
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var groups = GetDuplicateGroups(context);
|
||||
return new DuplicateAsinScanResult(groups.Count, BuildReport(groups, previewOnly: true));
|
||||
}
|
||||
|
||||
public static DuplicateAsinCleanupResult Execute()
|
||||
{
|
||||
if (IsCompleted())
|
||||
return new DuplicateAsinCleanupResult(0, 0, "Duplicate ASIN cleanup has already been completed for this library.");
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
var groups = GetDuplicateGroups(context);
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
MarkCompleted();
|
||||
return new DuplicateAsinCleanupResult(0, 0, "No duplicate ASIN rows found. Nothing to do.");
|
||||
}
|
||||
|
||||
var removedBooks = 0;
|
||||
var removedLibraryBooks = 0;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var keeper = SelectKeeper(group);
|
||||
var duplicates = group.Where(lb => !ReferenceEquals(lb, keeper)).ToList();
|
||||
|
||||
foreach (var duplicate in duplicates)
|
||||
{
|
||||
MergeIntoKeeper(context, keeper, duplicate);
|
||||
|
||||
context.LibraryBooks.Remove(duplicate);
|
||||
context.Books.Remove(duplicate.Book);
|
||||
|
||||
removedLibraryBooks++;
|
||||
removedBooks++;
|
||||
}
|
||||
}
|
||||
|
||||
var changes = context.SaveChanges();
|
||||
MarkCompleted();
|
||||
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine($"Removed {removedBooks} duplicate Book row(s) and {removedLibraryBooks} LibraryBook row(s).");
|
||||
summary.AppendLine($"SaveChanges reported {changes} database change(s).");
|
||||
summary.AppendLine();
|
||||
summary.Append(BuildReport(groups, previewOnly: false));
|
||||
|
||||
return new DuplicateAsinCleanupResult(groups.Count, removedBooks, summary.ToString());
|
||||
}
|
||||
|
||||
private static List<List<LibraryBook>> GetDuplicateGroups(LibationContext context)
|
||||
{
|
||||
var libraryBooks = context.LibraryBooks
|
||||
.Include(lb => lb.Book).ThenInclude(b => b.UserDefinedItem)
|
||||
.Include(lb => lb.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||
.ToList();
|
||||
|
||||
return libraryBooks
|
||||
.GroupBy(lb => lb.Book.AudibleProductId, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).ToList())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static LibraryBook SelectKeeper(List<LibraryBook> group)
|
||||
=> group.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).First();
|
||||
|
||||
private static int Score(LibraryBook libraryBook)
|
||||
{
|
||||
var book = libraryBook.Book;
|
||||
var udi = book.UserDefinedItem;
|
||||
|
||||
var score = 0;
|
||||
if (!libraryBook.IsDeleted)
|
||||
score += 1000;
|
||||
|
||||
score += bookStatusRank(udi.BookStatus) * 10;
|
||||
score += bookStatusRank(udi.PdfStatus ?? LiberatedStatus.NotLiberated);
|
||||
|
||||
score += book.ContentType switch
|
||||
{
|
||||
ContentType.Parent => 15,
|
||||
ContentType.Episode => 15,
|
||||
ContentType.Product => 10,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (book.SeriesLink.Any())
|
||||
score += 5;
|
||||
if (!string.IsNullOrWhiteSpace(book.PictureId))
|
||||
score += 2;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int bookStatusRank(LiberatedStatus status) => status switch
|
||||
{
|
||||
LiberatedStatus.Liberated => 10,
|
||||
LiberatedStatus.PartialDownload => 8,
|
||||
LiberatedStatus.NotLiberated => 3,
|
||||
LiberatedStatus.Error => 1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static void MergeIntoKeeper(LibationContext context, LibraryBook keeper, LibraryBook duplicate)
|
||||
{
|
||||
var keeperBook = keeper.Book;
|
||||
var duplicateBook = duplicate.Book;
|
||||
var keeperUdi = keeperBook.UserDefinedItem;
|
||||
var duplicateUdi = duplicateBook.UserDefinedItem;
|
||||
|
||||
if (bookStatusRank(duplicateUdi.BookStatus) > bookStatusRank(keeperUdi.BookStatus))
|
||||
keeperUdi.BookStatus = duplicateUdi.BookStatus;
|
||||
|
||||
if (duplicateUdi.PdfStatus is LiberatedStatus duplicatePdf
|
||||
&& bookStatusRank(duplicatePdf) > bookStatusRank(keeperUdi.PdfStatus ?? LiberatedStatus.NotLiberated))
|
||||
keeperUdi.SetPdfStatus(duplicatePdf);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(duplicateUdi.Tags))
|
||||
{
|
||||
var mergedTags = keeperUdi.TagsEnumerated
|
||||
.Concat(duplicateUdi.TagsEnumerated)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
keeperUdi.Tags = string.Join(" ", mergedTags);
|
||||
}
|
||||
|
||||
if (duplicateUdi.LastDownloaded is DateTime duplicateLast
|
||||
&& (keeperUdi.LastDownloaded is null || duplicateLast > keeperUdi.LastDownloaded))
|
||||
keeperUdi.SetLastDownloaded(duplicateUdi.LastDownloadedVersion, duplicateUdi.LastDownloadedFormat, duplicateUdi.LastDownloadedFileVersion);
|
||||
|
||||
if (duplicateUdi.Rating.OverallRating > keeperUdi.Rating.OverallRating)
|
||||
keeperUdi.UpdateRating(duplicateUdi.Rating.OverallRating, duplicateUdi.Rating.PerformanceRating, duplicateUdi.Rating.StoryRating);
|
||||
|
||||
foreach (var seriesBook in duplicateBook.SeriesLink)
|
||||
{
|
||||
if (keeperBook.SeriesLink.Any(sb => sb.Series.AudibleSeriesId == seriesBook.Series.AudibleSeriesId))
|
||||
continue;
|
||||
keeperBook.UpsertSeries(seriesBook.Series, seriesBook.Order, context);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildReport(List<List<LibraryBook>> groups, bool previewOnly)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
builder.Append("No duplicate ASIN rows found.");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
var duplicateRowCount = groups.Sum(g => g.Count - 1);
|
||||
builder.Append(previewOnly
|
||||
? $"Found {groups.Count} duplicate ASIN group(s) ({duplicateRowCount} extra row(s) would be removed)."
|
||||
: $"Processed {groups.Count} duplicate ASIN group(s).");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine();
|
||||
|
||||
foreach (var group in groups.OrderBy(g => g[0].Book.AudibleProductId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var keeper = SelectKeeper(group);
|
||||
var asin = keeper.Book.AudibleProductId;
|
||||
var title = keeper.Book.TitleWithSubtitle;
|
||||
var removing = group.Where(lb => !ReferenceEquals(lb, keeper))
|
||||
.Select(lb => $"{lb.Book.AudibleProductId} ({lb.DateAdded:d}, {lb.Account})")
|
||||
.ToList();
|
||||
|
||||
builder.Append(asin);
|
||||
builder.Append(" | ");
|
||||
builder.Append(title);
|
||||
builder.AppendLine();
|
||||
builder.Append(" Keep ");
|
||||
builder.Append($"{keeper.DateAdded:d}, {keeper.Account}");
|
||||
builder.Append(previewOnly ? "; would remove " : "; removed ");
|
||||
builder.AppendLine(string.Join("; ", removing));
|
||||
}
|
||||
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static void MarkCompleted()
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out _))
|
||||
UNSAFE_MigrationHelper.Settings_Update(SettingsKey, "true");
|
||||
else
|
||||
UNSAFE_MigrationHelper.Settings_Insert(SettingsKey, "true");
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct DuplicateAsinScanResult(int DuplicateGroupCount, string Report);
|
||||
|
||||
public readonly record struct DuplicateAsinCleanupResult(int DuplicateGroupCount, int RemovedBookCount, string Report);
|
||||
82
Source/HangoverBase/HangoverDbMutation.cs
Normal file
82
Source/HangoverBase/HangoverDbMutation.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace HangoverBase;
|
||||
|
||||
public static class HangoverDbMutation
|
||||
{
|
||||
public const string ConfirmTitle = "Modify Database";
|
||||
|
||||
public static string RemoveDuplicateAsinsDescription { get; }
|
||||
= "This one-time cleanup will back up the database, merge useful data into the kept row, and permanently delete duplicate Book + LibraryBook pairs.";
|
||||
|
||||
public static string SqlMutatingDescription { get; }
|
||||
= "This will run a SQL command that modifies the database.";
|
||||
|
||||
public static string RestoreDeletedBooksDescription { get; }
|
||||
= "This will restore the selected book(s) to your Libation library.";
|
||||
|
||||
public static string PermanentlyDeleteBooksDescription { get; }
|
||||
= "This will permanently delete the selected book(s) from Libation.";
|
||||
|
||||
public static bool IsLibationRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentPid = Environment.ProcessId;
|
||||
foreach (var process in Process.GetProcessesByName("Libation"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (process.Id != currentPid && !process.HasExited)
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process enumeration can fail on some platforms; treat as unknown.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsMutatingSql(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
return false;
|
||||
|
||||
var trimmed = sql.Trim();
|
||||
while (trimmed.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var lineEnd = trimmed.IndexOf('\n');
|
||||
if (lineEnd < 0)
|
||||
return false;
|
||||
trimmed = trimmed[(lineEnd + 1)..].TrimStart();
|
||||
}
|
||||
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
return lower.StartsWith("update")
|
||||
|| lower.StartsWith("insert")
|
||||
|| lower.StartsWith("delete");
|
||||
}
|
||||
|
||||
public static string BuildConfirmMessage(string actionDescription)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(actionDescription);
|
||||
builder.AppendLine();
|
||||
|
||||
if (IsLibationRunning())
|
||||
builder.AppendLine("Libation is currently running.");
|
||||
|
||||
builder.AppendLine("Close Libation before continuing to avoid database conflicts and a stale library display.");
|
||||
builder.AppendLine();
|
||||
builder.Append("Proceed?");
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace HangoverWinForms;
|
||||
|
||||
public partial class Form1
|
||||
{
|
||||
private void Load_cliTab()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!cliTab.Visible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,59 @@
|
||||
using HangoverBase;
|
||||
|
||||
|
||||
|
||||
namespace HangoverWinForms;
|
||||
|
||||
|
||||
|
||||
public partial class Form1
|
||||
|
||||
{
|
||||
|
||||
private DatabaseTab _tab;
|
||||
|
||||
|
||||
|
||||
private void Load_databaseTab()
|
||||
|
||||
{
|
||||
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
|
||||
|
||||
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s, s => duplicateResultsTb.Text = s));
|
||||
|
||||
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
|
||||
if (_tab.DbFile is null)
|
||||
|
||||
{
|
||||
|
||||
databaseFileLbl.Text = $"Database file not found";
|
||||
|
||||
Load_fixDuplicatesTab();
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
|
||||
|
||||
Load_fixDuplicatesTab();
|
||||
|
||||
}
|
||||
|
||||
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
||||
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
if (HangoverBase.HangoverDbMutation.IsMutatingSql(sqlTb.Text)
|
||||
&& !HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.SqlMutatingDescription))
|
||||
return;
|
||||
|
||||
_tab.ExecuteQuery();
|
||||
}
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ public partial class Form1
|
||||
private async void saveBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
||||
if (libraryBooksToRestore.Count == 0)
|
||||
return;
|
||||
|
||||
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
|
||||
return;
|
||||
|
||||
saveBtn.Enabled = false;
|
||||
var qtyChanges = await libraryBooksToRestore.RestoreBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
|
||||
550
Source/HangoverWinForms/Form1.Designer.cs
generated
550
Source/HangoverWinForms/Form1.Designer.cs
generated
@@ -1,256 +1,628 @@
|
||||
namespace HangoverWinForms
|
||||
|
||||
{
|
||||
|
||||
partial class Form1
|
||||
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
|
||||
/// Required designer variable.
|
||||
|
||||
/// </summary>
|
||||
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
||||
/// Clean up any resources being used.
|
||||
|
||||
/// </summary>
|
||||
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
{
|
||||
|
||||
if (disposing && (components != null))
|
||||
|
||||
{
|
||||
|
||||
components.Dispose();
|
||||
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
||||
/// Required method for Designer support - do not modify
|
||||
|
||||
/// the contents of this method with the code editor.
|
||||
|
||||
/// </summary>
|
||||
|
||||
private void InitializeComponent()
|
||||
|
||||
{
|
||||
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
|
||||
|
||||
this.tabControl1 = new System.Windows.Forms.TabControl();
|
||||
|
||||
this.databaseTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.sqlExecuteBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.sqlResultsTb = new System.Windows.Forms.TextBox();
|
||||
|
||||
this.sqlTb = new System.Windows.Forms.TextBox();
|
||||
|
||||
this.sqlLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.databaseFileLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.fixDuplicatesTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.removeDuplicateAsinsBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.scanDuplicateAsinsBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.duplicateAsinStatusLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.duplicateResultsTb = new System.Windows.Forms.TextBox();
|
||||
|
||||
this.deletedTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.deletedCheckedLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.label1 = new System.Windows.Forms.Label();
|
||||
|
||||
this.saveBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.uncheckAllBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.checkAllBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.deletedCbl = new System.Windows.Forms.CheckedListBox();
|
||||
this.cliTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.tabControl1.SuspendLayout();
|
||||
|
||||
this.databaseTab.SuspendLayout();
|
||||
|
||||
this.fixDuplicatesTab.SuspendLayout();
|
||||
|
||||
this.deletedTab.SuspendLayout();
|
||||
|
||||
this.SuspendLayout();
|
||||
|
||||
//
|
||||
|
||||
// tabControl1
|
||||
|
||||
//
|
||||
|
||||
this.tabControl1.Controls.Add(this.databaseTab);
|
||||
|
||||
this.tabControl1.Controls.Add(this.deletedTab);
|
||||
this.tabControl1.Controls.Add(this.cliTab);
|
||||
|
||||
this.tabControl1.Controls.Add(this.fixDuplicatesTab);
|
||||
|
||||
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
|
||||
this.tabControl1.Location = new System.Drawing.Point(0, 0);
|
||||
|
||||
this.tabControl1.Name = "tabControl1";
|
||||
|
||||
this.tabControl1.SelectedIndex = 0;
|
||||
|
||||
this.tabControl1.Size = new System.Drawing.Size(800, 450);
|
||||
|
||||
this.tabControl1.TabIndex = 0;
|
||||
|
||||
//
|
||||
|
||||
// databaseTab
|
||||
|
||||
//
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlExecuteBtn);
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlResultsTb);
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlTb);
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlLbl);
|
||||
|
||||
this.databaseTab.Controls.Add(this.databaseFileLbl);
|
||||
|
||||
this.databaseTab.Location = new System.Drawing.Point(4, 24);
|
||||
|
||||
this.databaseTab.Name = "databaseTab";
|
||||
|
||||
this.databaseTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
|
||||
this.databaseTab.Size = new System.Drawing.Size(792, 422);
|
||||
|
||||
this.databaseTab.TabIndex = 0;
|
||||
|
||||
this.databaseTab.Text = "Database";
|
||||
|
||||
this.databaseTab.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
|
||||
// sqlExecuteBtn
|
||||
|
||||
//
|
||||
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 153);
|
||||
|
||||
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 165);
|
||||
|
||||
this.sqlExecuteBtn.Name = "sqlExecuteBtn";
|
||||
|
||||
this.sqlExecuteBtn.Size = new System.Drawing.Size(75, 23);
|
||||
|
||||
this.sqlExecuteBtn.TabIndex = 3;
|
||||
|
||||
this.sqlExecuteBtn.Text = "Execute";
|
||||
|
||||
this.sqlExecuteBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.sqlExecuteBtn.Click += new System.EventHandler(this.sqlExecuteBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// sqlResultsTb
|
||||
|
||||
//
|
||||
|
||||
this.sqlResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlResultsTb.Location = new System.Drawing.Point(8, 182);
|
||||
|
||||
this.sqlResultsTb.Location = new System.Drawing.Point(8, 194);
|
||||
|
||||
this.sqlResultsTb.Multiline = true;
|
||||
|
||||
this.sqlResultsTb.Name = "sqlResultsTb";
|
||||
|
||||
this.sqlResultsTb.ReadOnly = true;
|
||||
|
||||
this.sqlResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlResultsTb.Size = new System.Drawing.Size(776, 234);
|
||||
|
||||
this.sqlResultsTb.Size = new System.Drawing.Size(776, 222);
|
||||
|
||||
this.sqlResultsTb.TabIndex = 4;
|
||||
|
||||
//
|
||||
|
||||
// sqlTb
|
||||
|
||||
//
|
||||
|
||||
this.sqlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlTb.Location = new System.Drawing.Point(8, 48);
|
||||
|
||||
this.sqlTb.Location = new System.Drawing.Point(8, 40);
|
||||
|
||||
this.sqlTb.Multiline = true;
|
||||
|
||||
this.sqlTb.Name = "sqlTb";
|
||||
|
||||
this.sqlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlTb.Size = new System.Drawing.Size(778, 99);
|
||||
|
||||
this.sqlTb.Size = new System.Drawing.Size(778, 119);
|
||||
|
||||
this.sqlTb.TabIndex = 2;
|
||||
|
||||
//
|
||||
|
||||
// sqlLbl
|
||||
|
||||
//
|
||||
|
||||
this.sqlLbl.AutoSize = true;
|
||||
this.sqlLbl.Location = new System.Drawing.Point(6, 30);
|
||||
|
||||
this.sqlLbl.Location = new System.Drawing.Point(6, 22);
|
||||
|
||||
this.sqlLbl.Name = "sqlLbl";
|
||||
|
||||
this.sqlLbl.Size = new System.Drawing.Size(144, 15);
|
||||
|
||||
this.sqlLbl.TabIndex = 1;
|
||||
|
||||
this.sqlLbl.Text = "SQL (database command)";
|
||||
|
||||
//
|
||||
|
||||
// databaseFileLbl
|
||||
|
||||
//
|
||||
|
||||
this.databaseFileLbl.AutoSize = true;
|
||||
|
||||
this.databaseFileLbl.Location = new System.Drawing.Point(6, 3);
|
||||
|
||||
this.databaseFileLbl.Name = "databaseFileLbl";
|
||||
|
||||
this.databaseFileLbl.Size = new System.Drawing.Size(80, 15);
|
||||
|
||||
this.databaseFileLbl.TabIndex = 0;
|
||||
|
||||
this.databaseFileLbl.Text = "Database file: ";
|
||||
|
||||
//
|
||||
// deletedTab
|
||||
|
||||
// fixDuplicatesTab
|
||||
|
||||
//
|
||||
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
|
||||
this.deletedTab.Controls.Add(this.label1);
|
||||
this.deletedTab.Controls.Add(this.saveBtn);
|
||||
this.deletedTab.Controls.Add(this.uncheckAllBtn);
|
||||
this.deletedTab.Controls.Add(this.checkAllBtn);
|
||||
this.deletedTab.Controls.Add(this.deletedCbl);
|
||||
this.deletedTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.deletedTab.Name = "deletedTab";
|
||||
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.deletedTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.deletedTab.TabIndex = 2;
|
||||
this.deletedTab.Text = "Deleted Books";
|
||||
this.deletedTab.UseVisualStyleBackColor = true;
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.duplicateResultsTb);
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.removeDuplicateAsinsBtn);
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.scanDuplicateAsinsBtn);
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.duplicateAsinStatusLbl);
|
||||
|
||||
this.fixDuplicatesTab.Location = new System.Drawing.Point(4, 24);
|
||||
|
||||
this.fixDuplicatesTab.Name = "fixDuplicatesTab";
|
||||
|
||||
this.fixDuplicatesTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
|
||||
this.fixDuplicatesTab.Size = new System.Drawing.Size(792, 422);
|
||||
|
||||
this.fixDuplicatesTab.TabIndex = 2;
|
||||
|
||||
this.fixDuplicatesTab.Text = "Fix Duplicates";
|
||||
|
||||
this.fixDuplicatesTab.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
// deletedCheckedLbl
|
||||
|
||||
// removeDuplicateAsinsBtn
|
||||
|
||||
//
|
||||
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.deletedCheckedLbl.AutoSize = true;
|
||||
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
|
||||
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
|
||||
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
|
||||
this.deletedCheckedLbl.TabIndex = 6;
|
||||
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
|
||||
|
||||
this.removeDuplicateAsinsBtn.Location = new System.Drawing.Point(170, 48);
|
||||
|
||||
this.removeDuplicateAsinsBtn.Name = "removeDuplicateAsinsBtn";
|
||||
|
||||
this.removeDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
|
||||
|
||||
this.removeDuplicateAsinsBtn.TabIndex = 2;
|
||||
|
||||
this.removeDuplicateAsinsBtn.Text = "Remove Duplicates";
|
||||
|
||||
this.removeDuplicateAsinsBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.removeDuplicateAsinsBtn.Click += new System.EventHandler(this.removeDuplicateAsinsBtn_Click);
|
||||
|
||||
//
|
||||
// label1
|
||||
|
||||
// scanDuplicateAsinsBtn
|
||||
|
||||
//
|
||||
this.label1.AutoSize = true;
|
||||
this.label1.Location = new System.Drawing.Point(8, 3);
|
||||
this.label1.Name = "label1";
|
||||
this.label1.Size = new System.Drawing.Size(239, 15);
|
||||
this.label1.TabIndex = 0;
|
||||
this.label1.Text = "To restore deleted book, check box and save";
|
||||
|
||||
this.scanDuplicateAsinsBtn.Location = new System.Drawing.Point(8, 48);
|
||||
|
||||
this.scanDuplicateAsinsBtn.Name = "scanDuplicateAsinsBtn";
|
||||
|
||||
this.scanDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
|
||||
|
||||
this.scanDuplicateAsinsBtn.TabIndex = 1;
|
||||
|
||||
this.scanDuplicateAsinsBtn.Text = "Scan Duplicate ASINs";
|
||||
|
||||
this.scanDuplicateAsinsBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.scanDuplicateAsinsBtn.Click += new System.EventHandler(this.scanDuplicateAsinsBtn_Click);
|
||||
|
||||
//
|
||||
// saveBtn
|
||||
|
||||
// duplicateAsinStatusLbl
|
||||
|
||||
//
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.saveBtn.Location = new System.Drawing.Point(709, 391);
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
this.saveBtn.Size = new System.Drawing.Size(75, 23);
|
||||
this.saveBtn.TabIndex = 5;
|
||||
this.saveBtn.Text = "Save";
|
||||
this.saveBtn.UseVisualStyleBackColor = true;
|
||||
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
|
||||
//
|
||||
// uncheckAllBtn
|
||||
//
|
||||
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
|
||||
this.uncheckAllBtn.Name = "uncheckAllBtn";
|
||||
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
this.uncheckAllBtn.TabIndex = 4;
|
||||
this.uncheckAllBtn.Text = "Uncheck All";
|
||||
this.uncheckAllBtn.UseVisualStyleBackColor = true;
|
||||
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
|
||||
//
|
||||
// checkAllBtn
|
||||
//
|
||||
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
|
||||
this.checkAllBtn.Name = "checkAllBtn";
|
||||
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
this.checkAllBtn.TabIndex = 3;
|
||||
this.checkAllBtn.Text = "Check All";
|
||||
this.checkAllBtn.UseVisualStyleBackColor = true;
|
||||
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
|
||||
//
|
||||
// deletedCbl
|
||||
//
|
||||
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
this.duplicateAsinStatusLbl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.duplicateAsinStatusLbl.Location = new System.Drawing.Point(6, 18);
|
||||
|
||||
this.duplicateAsinStatusLbl.Name = "duplicateAsinStatusLbl";
|
||||
|
||||
this.duplicateAsinStatusLbl.Size = new System.Drawing.Size(778, 27);
|
||||
|
||||
this.duplicateAsinStatusLbl.TabIndex = 0;
|
||||
|
||||
this.duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup";
|
||||
|
||||
//
|
||||
|
||||
// duplicateResultsTb
|
||||
|
||||
//
|
||||
|
||||
this.duplicateResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.duplicateResultsTb.Location = new System.Drawing.Point(8, 77);
|
||||
|
||||
this.duplicateResultsTb.Multiline = true;
|
||||
|
||||
this.duplicateResultsTb.Name = "duplicateResultsTb";
|
||||
|
||||
this.duplicateResultsTb.ReadOnly = true;
|
||||
|
||||
this.duplicateResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
|
||||
this.duplicateResultsTb.Size = new System.Drawing.Size(776, 339);
|
||||
|
||||
this.duplicateResultsTb.TabIndex = 3;
|
||||
|
||||
//
|
||||
|
||||
// deletedTab
|
||||
|
||||
//
|
||||
|
||||
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
|
||||
|
||||
this.deletedTab.Controls.Add(this.label1);
|
||||
|
||||
this.deletedTab.Controls.Add(this.saveBtn);
|
||||
|
||||
this.deletedTab.Controls.Add(this.uncheckAllBtn);
|
||||
|
||||
this.deletedTab.Controls.Add(this.checkAllBtn);
|
||||
|
||||
this.deletedTab.Controls.Add(this.deletedCbl);
|
||||
|
||||
this.deletedTab.Location = new System.Drawing.Point(4, 24);
|
||||
|
||||
this.deletedTab.Name = "deletedTab";
|
||||
|
||||
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
|
||||
this.deletedTab.Size = new System.Drawing.Size(792, 422);
|
||||
|
||||
this.deletedTab.TabIndex = 1;
|
||||
|
||||
this.deletedTab.Text = "Deleted Books";
|
||||
|
||||
this.deletedTab.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
|
||||
// deletedCheckedLbl
|
||||
|
||||
//
|
||||
|
||||
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
this.deletedCheckedLbl.AutoSize = true;
|
||||
|
||||
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
|
||||
|
||||
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
|
||||
|
||||
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
|
||||
|
||||
this.deletedCheckedLbl.TabIndex = 6;
|
||||
|
||||
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
|
||||
|
||||
//
|
||||
|
||||
// label1
|
||||
|
||||
//
|
||||
|
||||
this.label1.AutoSize = true;
|
||||
|
||||
this.label1.Location = new System.Drawing.Point(8, 3);
|
||||
|
||||
this.label1.Name = "label1";
|
||||
|
||||
this.label1.Size = new System.Drawing.Size(239, 15);
|
||||
|
||||
this.label1.TabIndex = 0;
|
||||
|
||||
this.label1.Text = "To restore deleted book, check box and save";
|
||||
|
||||
//
|
||||
|
||||
// saveBtn
|
||||
|
||||
//
|
||||
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.saveBtn.Location = new System.Drawing.Point(709, 391);
|
||||
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
|
||||
this.saveBtn.Size = new System.Drawing.Size(75, 23);
|
||||
|
||||
this.saveBtn.TabIndex = 5;
|
||||
|
||||
this.saveBtn.Text = "Save";
|
||||
|
||||
this.saveBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// uncheckAllBtn
|
||||
|
||||
//
|
||||
|
||||
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
|
||||
|
||||
this.uncheckAllBtn.Name = "uncheckAllBtn";
|
||||
|
||||
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
|
||||
this.uncheckAllBtn.TabIndex = 4;
|
||||
|
||||
this.uncheckAllBtn.Text = "Uncheck All";
|
||||
|
||||
this.uncheckAllBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// checkAllBtn
|
||||
|
||||
//
|
||||
|
||||
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
|
||||
|
||||
this.checkAllBtn.Name = "checkAllBtn";
|
||||
|
||||
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
|
||||
this.checkAllBtn.TabIndex = 3;
|
||||
|
||||
this.checkAllBtn.Text = "Check All";
|
||||
|
||||
this.checkAllBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// deletedCbl
|
||||
|
||||
//
|
||||
|
||||
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.deletedCbl.FormattingEnabled = true;
|
||||
|
||||
this.deletedCbl.Location = new System.Drawing.Point(8, 21);
|
||||
|
||||
this.deletedCbl.Name = "deletedCbl";
|
||||
|
||||
this.deletedCbl.Size = new System.Drawing.Size(776, 364);
|
||||
|
||||
this.deletedCbl.TabIndex = 2;
|
||||
|
||||
this.deletedCbl.ItemCheck += new System.Windows.Forms.ItemCheckEventHandler(this.deletedCbl_ItemCheck);
|
||||
|
||||
//
|
||||
// cliTab
|
||||
//
|
||||
this.cliTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.cliTab.Name = "cliTab";
|
||||
this.cliTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.cliTab.TabIndex = 1;
|
||||
this.cliTab.Text = "Command Line Interface";
|
||||
this.cliTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
|
||||
// Form1
|
||||
|
||||
//
|
||||
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
|
||||
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
|
||||
this.ClientSize = new System.Drawing.Size(800, 450);
|
||||
|
||||
this.Controls.Add(this.tabControl1);
|
||||
|
||||
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
|
||||
|
||||
this.Name = "Form1";
|
||||
|
||||
this.Text = "Hangover: Libation debug and recovery tool";
|
||||
|
||||
this.tabControl1.ResumeLayout(false);
|
||||
|
||||
this.databaseTab.ResumeLayout(false);
|
||||
|
||||
this.databaseTab.PerformLayout();
|
||||
|
||||
this.fixDuplicatesTab.ResumeLayout(false);
|
||||
|
||||
this.deletedTab.ResumeLayout(false);
|
||||
|
||||
this.deletedTab.PerformLayout();
|
||||
|
||||
this.ResumeLayout(false);
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
|
||||
private TabControl tabControl1;
|
||||
|
||||
private TabPage databaseTab;
|
||||
|
||||
private Label databaseFileLbl;
|
||||
|
||||
private TextBox sqlResultsTb;
|
||||
|
||||
private TextBox sqlTb;
|
||||
|
||||
private Label sqlLbl;
|
||||
|
||||
private Button sqlExecuteBtn;
|
||||
private TabPage cliTab;
|
||||
|
||||
private TabPage fixDuplicatesTab;
|
||||
|
||||
private Button removeDuplicateAsinsBtn;
|
||||
|
||||
private Button scanDuplicateAsinsBtn;
|
||||
|
||||
private Label duplicateAsinStatusLbl;
|
||||
|
||||
private TextBox duplicateResultsTb;
|
||||
|
||||
private TabPage deletedTab;
|
||||
|
||||
private CheckedListBox deletedCbl;
|
||||
|
||||
private Label label1;
|
||||
|
||||
private Button saveBtn;
|
||||
|
||||
private Button uncheckAllBtn;
|
||||
|
||||
private Button checkAllBtn;
|
||||
|
||||
private Label deletedCheckedLbl;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
48
Source/HangoverWinForms/Form1.FixDuplicates.cs
Normal file
48
Source/HangoverWinForms/Form1.FixDuplicates.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace HangoverWinForms;
|
||||
|
||||
public partial class Form1
|
||||
{
|
||||
private void Load_fixDuplicatesTab()
|
||||
{
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup unavailable (database not found).";
|
||||
scanDuplicateAsinsBtn.Enabled = false;
|
||||
removeDuplicateAsinsBtn.Enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
private void refreshDuplicateAsinStatus()
|
||||
{
|
||||
duplicateAsinStatusLbl.Text = _tab.GetDuplicateAsinStatusText();
|
||||
scanDuplicateAsinsBtn.Enabled = true;
|
||||
removeDuplicateAsinsBtn.Enabled = _tab.CanRemoveDuplicateAsins();
|
||||
}
|
||||
|
||||
private void fixDuplicatesTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!fixDuplicatesTab.Visible)
|
||||
return;
|
||||
|
||||
if (_tab.DbFile is not null)
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
private void scanDuplicateAsinsBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
_tab.ScanDuplicateAsins();
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
private void removeDuplicateAsinsBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RemoveDuplicateAsinsDescription))
|
||||
return;
|
||||
|
||||
_tab.RemoveDuplicateAsins();
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,10 @@ public partial class Form1 : Form
|
||||
LibationScaffolding.RunPostConfigMigrations(config);
|
||||
LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config);
|
||||
|
||||
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
|
||||
cliTab.VisibleChanged += cliTab_VisibleChanged;
|
||||
fixDuplicatesTab.VisibleChanged += fixDuplicatesTab_VisibleChanged;
|
||||
deletedTab.VisibleChanged += deletedTab_VisibleChanged;
|
||||
|
||||
Load_databaseTab();
|
||||
Load_cliTab();
|
||||
Load_deletedTab();
|
||||
}
|
||||
}
|
||||
|
||||
14
Source/HangoverWinForms/HangoverMutationConfirm.cs
Normal file
14
Source/HangoverWinForms/HangoverMutationConfirm.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using HangoverBase;
|
||||
|
||||
namespace HangoverWinForms;
|
||||
|
||||
internal static class HangoverMutationConfirm
|
||||
{
|
||||
public static bool Confirm(IWin32Window owner, string actionDescription)
|
||||
=> MessageBox.Show(
|
||||
owner,
|
||||
HangoverDbMutation.BuildConfirmMessage(actionDescription),
|
||||
HangoverDbMutation.ConfirmTitle,
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning) == DialogResult.Yes;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ static class Program
|
||||
//We can do this because we're already executing inside the sandbox.
|
||||
//Any process created in the sandbox executes in the same sandbox.
|
||||
//Unfortunately, all sandbox files are read/execute, so no writing!
|
||||
Process.Start("Hangover");
|
||||
HangoverLauncher.Launch();
|
||||
return;
|
||||
}
|
||||
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "cli")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Reactive;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.ViewModels;
|
||||
@@ -10,8 +12,12 @@ partial class MainVM
|
||||
{
|
||||
public string FindBetterQualityBooksTip => Configuration.GetHelpText("FindBetterQualityBooks");
|
||||
public bool MenuBarVisible { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = !Configuration.IsMacOs;
|
||||
public ReactiveCommand<Unit, Unit> LaunchHangover { get; private set; } = null!;
|
||||
|
||||
private void Configure_Settings()
|
||||
{
|
||||
LaunchHangover = ReactiveCommand.CreateFromTask(LaunchHangoverAsync);
|
||||
|
||||
if (App.Current is Avalonia.Application app &&
|
||||
NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu)
|
||||
aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync);
|
||||
@@ -23,15 +29,21 @@ partial class MainVM
|
||||
public Task ShowTrashBinAsync() => new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
|
||||
public Task ShowFindBetterQualityBooksAsync() => new LibationAvalonia.Dialogs.FindBetterQualityBooksDialog().ShowDialog(MainWindow);
|
||||
|
||||
public void LaunchHangover()
|
||||
private async Task LaunchHangoverAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : ""));
|
||||
HangoverLauncher.Launch();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
HangoverLauncher.GetLaunchFailureMessage(ex),
|
||||
"Launch Hangover",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -271,7 +271,11 @@ public class ProductsDisplayViewModel : ViewModelBase
|
||||
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
|
||||
seriesEntry.Children.Add(episodeEntry);
|
||||
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
|
||||
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
|
||||
var seriesBook = dbBooks.FindSeriesParent(episodeBook)
|
||||
?? dbBooks.FirstOrDefault(lb =>
|
||||
lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId
|
||||
&& lb.Account == seriesEntry.LibraryBook.Account)
|
||||
?? seriesEntry.LibraryBook;
|
||||
seriesEntry.UpdateLibraryBook(seriesBook);
|
||||
}
|
||||
|
||||
|
||||
35
Source/LibationFileManager/HangoverLauncher.cs
Normal file
35
Source/LibationFileManager/HangoverLauncher.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace LibationFileManager;
|
||||
|
||||
public static class HangoverLauncher
|
||||
{
|
||||
public static string GetHangoverExecutablePath()
|
||||
{
|
||||
var fileName = Configuration.IsWindows ? "Hangover.exe" : "Hangover";
|
||||
return Path.Combine(Configuration.ProcessDirectory, fileName);
|
||||
}
|
||||
|
||||
public static void Launch()
|
||||
{
|
||||
var path = GetHangoverExecutablePath();
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException(
|
||||
$"Hangover was not found in Libation's folder.{Environment.NewLine}{Environment.NewLine}Expected:{Environment.NewLine}{path}",
|
||||
path);
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
WorkingDirectory = Configuration.ProcessDirectory,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
|
||||
public static string GetLaunchFailureMessage(Exception ex)
|
||||
=> ex is FileNotFoundException fileNotFound
|
||||
? fileNotFound.Message
|
||||
: $"Could not start Hangover.{Environment.NewLine}{Environment.NewLine}{ex.Message}";
|
||||
}
|
||||
@@ -177,7 +177,7 @@ public class GridContextMenu
|
||||
Configuration.Instance.SavePodcastsToParentFolder &&
|
||||
libraryBook.Book.SeriesLink.SingleOrDefault() is SeriesBook series)
|
||||
{
|
||||
var seriesParent = DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
|
||||
var seriesParent = DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId, account: libraryBook.Account);
|
||||
folderDto = seriesParent?.ToDto() ?? fileDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationWinForms.Dialogs;
|
||||
using LibationFileManager;
|
||||
using LibationWinForms.Dialogs;
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
@@ -42,11 +43,17 @@ public partial class Form1
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start("Hangover.exe");
|
||||
HangoverLauncher.Launch();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
|
||||
MessageBox.Show(
|
||||
this,
|
||||
HangoverLauncher.GetLaunchFailureMessage(ex),
|
||||
"Launch Hangover",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +468,11 @@ public partial class ProductsGrid : UserControl
|
||||
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
|
||||
seriesEntry.Children.Add(episodeEntry);
|
||||
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
|
||||
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
|
||||
var seriesBook = dbBooks.FindSeriesParent(episodeBook)
|
||||
?? dbBooks.FirstOrDefault(lb =>
|
||||
lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId
|
||||
&& lb.Account == seriesEntry.LibraryBook.Account)
|
||||
?? seriesEntry.LibraryBook;
|
||||
seriesEntry.UpdateLibraryBook(seriesBook);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user