diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index fab0d390..bd043b8a 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -2,6 +2,7 @@ net10.0 + enable diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index 5d5806f3..7915d8cf 100644 --- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs +++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs @@ -5,148 +5,146 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -#nullable enable -namespace AaxDecrypter -{ - public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase +namespace AaxDecrypter; + +public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase +{ + public event EventHandler? RetrievedMetadata; + + public Mp4File? AaxFile { get; private set; } + protected Mp4Operation? AaxConversion { get; set; } + + protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { } + + /// Setting cover art by this method will insert the art into the audiobook metadata + public override void SetCoverArt(byte[] coverArt) { - public event EventHandler? RetrievedMetadata; - - public Mp4File? AaxFile { get; private set; } - protected Mp4Operation? AaxConversion { get; set; } - - protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) - : base(outDirectory, cacheDirectory, dlOptions) { } - - /// Setting cover art by this method will insert the art into the audiobook metadata - public override void SetCoverArt(byte[] coverArt) - { - base.SetCoverArt(coverArt); - if (coverArt is not null && AaxFile?.MetadataItems is not null) - AaxFile.MetadataItems.Cover = coverArt; - } - - public override async Task CancelAsync() - { - await base.CancelAsync(); - await (AaxConversion?.CancelAsync() ?? Task.CompletedTask); - } - - private Mp4File Open() - { - if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0) - throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file."); - else if (DownloadOptions.InputType is FileType.Dash) - { - //We may have multiple keys , so use the key whose key ID matches - //the dash files default Key ID. - var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); - - var dash = new DashFile(InputFileStream); - if (dash.Tenc is null) - throw new InvalidOperationException("The DASH file does not contain 'tenc' box, indicating that it is unencrypted."); - - var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID); - if (kidIndex == -1) - throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}"); - - keys[0] = keys[kidIndex]; - var keyId = keys[kidIndex].KeyPart1; - var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2)."); - dash.SetDecryptionKey(keyId, key); - WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}"); - - //Remove meta box containing DRM info - if (DownloadOptions.FixupFile && dash.Moov.GetChild() is { } meta) - dash.Moov.Children.Remove(meta); - - return dash; - } - else if (DownloadOptions.InputType is FileType.Aax) - { - var aax = new AaxFile(InputFileStream); - var key = keys[0].KeyPart1; - aax.SetDecryptionKey(keys[0].KeyPart1); - WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}"); - return aax; - } - else if (DownloadOptions.InputType is FileType.Aaxc) - { - var aax = new AaxFile(InputFileStream); - var key = keys[0].KeyPart1; - var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2)."); - aax.SetDecryptionKey(keys[0].KeyPart1, iv); - WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}"); - return aax; - } - else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown."); - - void WriteKeyFile(string contents) - { - var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key")); - File.WriteAllText(keyFile, contents + Environment.NewLine); - OnTempFileCreated(new(keyFile)); - } - } - - protected bool Step_GetMetadata() - { - AaxFile = Open(); - - RetrievedMetadata?.Invoke(this, AaxFile.MetadataItems); - - if (DownloadOptions.StripUnabridged) - { - AaxFile.MetadataItems.Title = AaxFile.MetadataItems.TitleSansUnabridged; - AaxFile.MetadataItems.Album = AaxFile.MetadataItems.Album?.Replace(" (Unabridged)", ""); - } - - if (DownloadOptions.FixupFile) - { - if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Narrator)) - AaxFile.MetadataItems.AppleListBox.EditOrAddTag("©wrt", AaxFile.MetadataItems.Narrator); - - if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Copyright)) - AaxFile.MetadataItems.Copyright = AaxFile.MetadataItems.Copyright.Replace("(P)", "℗").Replace("©", "©"); - - //Add audiobook shelf tags - //https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213 - const string tagDomain = "com.pilabor.tone"; - - AaxFile.MetadataItems.Title = DownloadOptions.Title; - - if (DownloadOptions.Subtitle is string subtitle) - AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle); - - if (DownloadOptions.Publisher is string publisher) - AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher); - - if (DownloadOptions.Language is string language) - AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language); - - if (DownloadOptions.AudibleProductId is string asin) - { - AaxFile.MetadataItems.Asin = asin; - AaxFile.MetadataItems.AppleListBox.EditOrAddTag("asin", asin); - AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin); - } - - if (DownloadOptions.SeriesName is string series) - AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series); - - if (DownloadOptions.SeriesNumber is string part) - AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part); - } - - OnRetrievedTitle(AaxFile.MetadataItems.TitleSansUnabridged); - OnRetrievedAuthors(AaxFile.MetadataItems.FirstAuthor); - OnRetrievedNarrators(AaxFile.MetadataItems.Narrator); - OnRetrievedCoverArt(AaxFile.MetadataItems.Cover); - OnInitialized(); - - return !IsCanceled; - } - - protected virtual void OnInitialized() { } + base.SetCoverArt(coverArt); + if (coverArt is not null && AaxFile?.MetadataItems is not null) + AaxFile.MetadataItems.Cover = coverArt; } + + public override async Task CancelAsync() + { + await base.CancelAsync(); + await (AaxConversion?.CancelAsync() ?? Task.CompletedTask); + } + + private Mp4File Open() + { + if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0) + throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file."); + else if (DownloadOptions.InputType is FileType.Dash) + { + //We may have multiple keys , so use the key whose key ID matches + //the dash files default Key ID. + var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); + + var dash = new DashFile(InputFileStream); + if (dash.Tenc is null) + throw new InvalidOperationException("The DASH file does not contain 'tenc' box, indicating that it is unencrypted."); + + var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID); + if (kidIndex == -1) + throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}"); + + keys[0] = keys[kidIndex]; + var keyId = keys[kidIndex].KeyPart1; + var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2)."); + dash.SetDecryptionKey(keyId, key); + WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}"); + + //Remove meta box containing DRM info + if (DownloadOptions.FixupFile && dash.Moov.GetChild() is { } meta) + dash.Moov.Children.Remove(meta); + + return dash; + } + else if (DownloadOptions.InputType is FileType.Aax) + { + var aax = new AaxFile(InputFileStream); + var key = keys[0].KeyPart1; + aax.SetDecryptionKey(keys[0].KeyPart1); + WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}"); + return aax; + } + else if (DownloadOptions.InputType is FileType.Aaxc) + { + var aax = new AaxFile(InputFileStream); + var key = keys[0].KeyPart1; + var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2)."); + aax.SetDecryptionKey(keys[0].KeyPart1, iv); + WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}"); + return aax; + } + else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown."); + + void WriteKeyFile(string contents) + { + var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key")); + File.WriteAllText(keyFile, contents + Environment.NewLine); + OnTempFileCreated(new(keyFile)); + } + } + + protected bool Step_GetMetadata() + { + AaxFile = Open(); + + RetrievedMetadata?.Invoke(this, AaxFile.MetadataItems); + + if (DownloadOptions.StripUnabridged) + { + AaxFile.MetadataItems.Title = AaxFile.MetadataItems.TitleSansUnabridged; + AaxFile.MetadataItems.Album = AaxFile.MetadataItems.Album?.Replace(" (Unabridged)", ""); + } + + if (DownloadOptions.FixupFile) + { + if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Narrator)) + AaxFile.MetadataItems.AppleListBox.EditOrAddTag("©wrt", AaxFile.MetadataItems.Narrator); + + if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Copyright)) + AaxFile.MetadataItems.Copyright = AaxFile.MetadataItems.Copyright.Replace("(P)", "℗").Replace("©", "©"); + + //Add audiobook shelf tags + //https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213 + const string tagDomain = "com.pilabor.tone"; + + AaxFile.MetadataItems.Title = DownloadOptions.Title; + + if (DownloadOptions.Subtitle is string subtitle) + AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle); + + if (DownloadOptions.Publisher is string publisher) + AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher); + + if (DownloadOptions.Language is string language) + AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language); + + if (DownloadOptions.AudibleProductId is string asin) + { + AaxFile.MetadataItems.Asin = asin; + AaxFile.MetadataItems.AppleListBox.EditOrAddTag("asin", asin); + AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin); + } + + if (DownloadOptions.SeriesName is string series) + AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series); + + if (DownloadOptions.SeriesNumber is string part) + AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part); + } + + OnRetrievedTitle(AaxFile.MetadataItems.TitleSansUnabridged); + OnRetrievedAuthors(AaxFile.MetadataItems.FirstAuthor); + OnRetrievedNarrators(AaxFile.MetadataItems.Narrator); + OnRetrievedCoverArt(AaxFile.MetadataItems.Cover); + OnInitialized(); + + return !IsCanceled; + } + + protected virtual void OnInitialized() { } } diff --git a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs index c6984031..8c9f317e 100644 --- a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs @@ -6,116 +6,114 @@ using System; using System.IO; using System.Threading.Tasks; -#nullable enable -namespace AaxDecrypter +namespace AaxDecrypter; + +public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase { - public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase + private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3); + private FileStream? workingFileStream; + + public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { - private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3); - private FileStream? workingFileStream; + AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split"; + AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata); + AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; + } - public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) - : base(outDirectory, cacheDirectory, dlOptions) + protected override void OnInitialized() + { + //Finishing configuring lame encoder. + if (DownloadOptions.OutputFormat == OutputFormat.Mp3) { - AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split"; - AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata); - AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; - } + if (AaxFile is null) + throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}."); + if (DownloadOptions.LameConfig is null) + throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}."); - protected override void OnInitialized() - { - //Finishing configuring lame encoder. - if (DownloadOptions.OutputFormat == OutputFormat.Mp3) - { - if (AaxFile is null) - throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}."); - if (DownloadOptions.LameConfig is null) - throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}."); - - MpegUtil.ConfigureLameOptions( - AaxFile, - DownloadOptions.LameConfig, - DownloadOptions.Downsample, - DownloadOptions.MatchSourceBitrate, - chapters: null); - } - } - - - protected async override Task Step_DownloadAndDecryptAudiobookAsync() - { - if (AaxFile is null) return false; - - try - { - await (AaxConversion = decryptMultiAsync(AaxFile, DownloadOptions.ChapterInfo)); - - if (AaxConversion.IsCompletedSuccessfully) - await moveMoovToBeginning(AaxFile, workingFileStream?.Name); - - return AaxConversion.IsCompletedSuccessfully; - } - finally - { - workingFileStream?.Dispose(); - FinalizeDownload(); - } - } - - private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters) - { - var chapterCount = 0; - return - DownloadOptions.OutputFormat == OutputFormat.M4b - ? aaxFile.ConvertToMultiMp4aAsync - ( - splitChapters, - newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback) - ) - : aaxFile.ConvertToMultiMp3Async - ( - splitChapters, - newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback), - DownloadOptions.LameConfig - ); - - void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback) - { - moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult(); - var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); - MultiConvertFileProperties props = new() - { - OutputFileName = newTempFile.FilePath, - PartsPosition = currentChapter, - PartsTotal = splitChapters.Count, - Title = newSplitCallback.Chapter?.Title, - }; - - newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props); - newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props); - newSplitCallback.TrackNumber = currentChapter; - newSplitCallback.TrackCount = splitChapters.Count; - - OnTempFileCreated(newTempFile with { PartProperties = props }); - } - - FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties) - { - FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName); - return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); - } - } - - private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename) - { - if (DownloadOptions.OutputFormat is OutputFormat.M4b - && DownloadOptions.MoveMoovToBeginning - && filename is not null - && File.Exists(filename)) - { - return Mp4File.RelocateMoovAsync(filename); - } - else return Mp4Operation.FromCompleted(aaxFile); + MpegUtil.ConfigureLameOptions( + AaxFile, + DownloadOptions.LameConfig, + DownloadOptions.Downsample, + DownloadOptions.MatchSourceBitrate, + chapters: null); } } + + + protected async override Task Step_DownloadAndDecryptAudiobookAsync() + { + if (AaxFile is null) return false; + + try + { + await (AaxConversion = decryptMultiAsync(AaxFile, DownloadOptions.ChapterInfo)); + + if (AaxConversion.IsCompletedSuccessfully) + await moveMoovToBeginning(AaxFile, workingFileStream?.Name); + + return AaxConversion.IsCompletedSuccessfully; + } + finally + { + workingFileStream?.Dispose(); + FinalizeDownload(); + } + } + + private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters) + { + var chapterCount = 0; + return + DownloadOptions.OutputFormat == OutputFormat.M4b + ? aaxFile.ConvertToMultiMp4aAsync + ( + splitChapters, + newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback) + ) + : aaxFile.ConvertToMultiMp3Async + ( + splitChapters, + newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback), + DownloadOptions.LameConfig + ); + + void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback) + { + moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult(); + var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); + MultiConvertFileProperties props = new() + { + OutputFileName = newTempFile.FilePath, + PartsPosition = currentChapter, + PartsTotal = splitChapters.Count, + Title = newSplitCallback.Chapter?.Title, + }; + + newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props); + newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props); + newSplitCallback.TrackNumber = currentChapter; + newSplitCallback.TrackCount = splitChapters.Count; + + OnTempFileCreated(newTempFile with { PartProperties = props }); + } + + FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties) + { + FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName); + return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); + } + } + + private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename) + { + if (DownloadOptions.OutputFormat is OutputFormat.M4b + && DownloadOptions.MoveMoovToBeginning + && filename is not null + && File.Exists(filename)) + { + return Mp4File.RelocateMoovAsync(filename); + } + else return Mp4Operation.FromCompleted(aaxFile); + } } diff --git a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs index 73c089f1..f626dfc1 100644 --- a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs @@ -6,110 +6,108 @@ using System; using System.IO; using System.Threading.Tasks; -#nullable enable -namespace AaxDecrypter +namespace AaxDecrypter; + +public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase { - public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase + private readonly AverageSpeed averageSpeed = new(); + private TempFile? outputTempFile; + + public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { - private readonly AverageSpeed averageSpeed = new(); - private TempFile? outputTempFile; + var step = 1; - public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) - : base(outDirectory, cacheDirectory, dlOptions) + AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}"; + AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata); + AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; + if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b) + AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov; + AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync; + } + + protected override void OnInitialized() + { + //Finishing configuring lame encoder. + if (DownloadOptions.OutputFormat == OutputFormat.Mp3) { - var step = 1; + if (AaxFile is null) + throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}."); + if (DownloadOptions.LameConfig is null) + throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}."); - AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}"; - AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata); - AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; - if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b) - AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov; - AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync; + MpegUtil.ConfigureLameOptions( + AaxFile, + DownloadOptions.LameConfig, + DownloadOptions.Downsample, + DownloadOptions.MatchSourceBitrate, + DownloadOptions.ChapterInfo); } + } - protected override void OnInitialized() + protected async override Task Step_DownloadAndDecryptAudiobookAsync() + { + if (AaxFile is null) return false; + outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); + FileUtility.SaferDelete(outputTempFile.FilePath); + + using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite); + OnTempFileCreated(outputTempFile); + + try { - //Finishing configuring lame encoder. - if (DownloadOptions.OutputFormat == OutputFormat.Mp3) - { - if (AaxFile is null) - throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}."); - if (DownloadOptions.LameConfig is null) - throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}."); + await (AaxConversion = decryptAsync(AaxFile, outputFile)); - MpegUtil.ConfigureLameOptions( - AaxFile, - DownloadOptions.LameConfig, - DownloadOptions.Downsample, - DownloadOptions.MatchSourceBitrate, - DownloadOptions.ChapterInfo); - } - } - - protected async override Task Step_DownloadAndDecryptAudiobookAsync() - { - if (AaxFile is null) return false; - outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); - FileUtility.SaferDelete(outputTempFile.FilePath); - - using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite); - OnTempFileCreated(outputTempFile); - - try - { - await (AaxConversion = decryptAsync(AaxFile, outputFile)); - - return AaxConversion.IsCompletedSuccessfully; - } - finally - { - FinalizeDownload(); - } - } - - private async Task Step_MoveMoov() - { - if (outputTempFile is null) return false; - AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath); - AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate; - await AaxConversion; - AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate; return AaxConversion.IsCompletedSuccessfully; } - - private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e) + finally { - averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds); - - var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds; - var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average; - - if (double.IsNormal(estTimeRemaining)) - OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining)); - - OnDecryptProgressUpdate( - new DownloadProgress - { - ProgressPercentage = 100 * e.FractionCompleted, - BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted), - TotalBytesToReceive = InputFileStream.Length - }); + FinalizeDownload(); } - - private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile) - => DownloadOptions.OutputFormat == OutputFormat.Mp3 - ? aaxFile.ConvertToMp3Async - ( - outputFile, - DownloadOptions.LameConfig, - DownloadOptions.ChapterInfo - ) - : DownloadOptions.FixupFile - ? aaxFile.ConvertToMp4aAsync - ( - outputFile, - DownloadOptions.ChapterInfo - ) - : aaxFile.ConvertToMp4aAsync(outputFile); } + + private async Task Step_MoveMoov() + { + if (outputTempFile is null) return false; + AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath); + AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate; + await AaxConversion; + AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate; + return AaxConversion.IsCompletedSuccessfully; + } + + private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e) + { + averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds); + + var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds; + var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average; + + if (double.IsNormal(estTimeRemaining)) + OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining)); + + OnDecryptProgressUpdate( + new DownloadProgress + { + ProgressPercentage = 100 * e.FractionCompleted, + BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted), + TotalBytesToReceive = InputFileStream.Length + }); + } + + private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile) + => DownloadOptions.OutputFormat == OutputFormat.Mp3 + ? aaxFile.ConvertToMp3Async + ( + outputFile, + DownloadOptions.LameConfig, + DownloadOptions.ChapterInfo + ) + : DownloadOptions.FixupFile + ? aaxFile.ConvertToMp4aAsync + ( + outputFile, + DownloadOptions.ChapterInfo + ) + : aaxFile.ConvertToMp4aAsync(outputFile); } diff --git a/Source/AaxDecrypter/AudiobookDownloadBase.cs b/Source/AaxDecrypter/AudiobookDownloadBase.cs index d807c901..feccf926 100644 --- a/Source/AaxDecrypter/AudiobookDownloadBase.cs +++ b/Source/AaxDecrypter/AudiobookDownloadBase.cs @@ -6,223 +6,221 @@ using System; using System.IO; using System.Threading.Tasks; -#nullable enable -namespace AaxDecrypter +namespace AaxDecrypter; + +public enum OutputFormat { M4b, Mp3 } + +public abstract class AudiobookDownloadBase { - public enum OutputFormat { M4b, Mp3 } + public event EventHandler? RetrievedTitle; + public event EventHandler? RetrievedAuthors; + public event EventHandler? RetrievedNarrators; + public event EventHandler? RetrievedCoverArt; + public event EventHandler? DecryptProgressUpdate; + public event EventHandler? DecryptTimeRemaining; + public event EventHandler? TempFileCreated; - public abstract class AudiobookDownloadBase + public bool IsCanceled { get; protected set; } + protected AsyncStepSequence AsyncSteps { get; } = new(); + protected string OutputDirectory { get; } + public IDownloadOptions DownloadOptions { get; } + protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream; + protected virtual long InputFilePosition { - public event EventHandler? RetrievedTitle; - public event EventHandler? RetrievedAuthors; - public event EventHandler? RetrievedNarrators; - public event EventHandler? RetrievedCoverArt; - public event EventHandler? DecryptProgressUpdate; - public event EventHandler? DecryptTimeRemaining; - public event EventHandler? TempFileCreated; - - public bool IsCanceled { get; protected set; } - protected AsyncStepSequence AsyncSteps { get; } = new(); - protected string OutputDirectory { get; } - public IDownloadOptions DownloadOptions { get; } - protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream; - protected virtual long InputFilePosition + get { - get - { - //Use try/catch instread of checking CanRead to avoid - //a race with the background download completing - //between the check and the Position call. - try { return InputFileStream.Position; } - catch { return InputFileStream.Length; } - } + //Use try/catch instread of checking CanRead to avoid + //a race with the background download completing + //between the check and the Position call. + try { return InputFileStream.Position; } + catch { return InputFileStream.Length; } } - private bool downloadFinished; + } + private bool downloadFinished; - private NetworkFileStreamPersister? m_nfsPersister; - private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream(); - private readonly DownloadProgress zeroProgress; - private readonly string jsonDownloadState; - private readonly string tempFilePath; + private NetworkFileStreamPersister? m_nfsPersister; + private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream(); + private readonly DownloadProgress zeroProgress; + private readonly string jsonDownloadState; + private readonly string tempFilePath; - protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) - { - OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory)); - DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions)); - DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed; + protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + { + OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory)); + DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions)); + DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed; - if (!Directory.Exists(OutputDirectory)) - Directory.CreateDirectory(OutputDirectory); + if (!Directory.Exists(OutputDirectory)) + Directory.CreateDirectory(OutputDirectory); - if (!Directory.Exists(cacheDirectory)) - Directory.CreateDirectory(cacheDirectory); + if (!Directory.Exists(cacheDirectory)) + Directory.CreateDirectory(cacheDirectory); - jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json"); - tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc"); + jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json"); + tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc"); - zeroProgress = new DownloadProgress + zeroProgress = new DownloadProgress + { + BytesReceived = 0, + ProgressPercentage = 0, + TotalBytesToReceive = 0 + }; + + OnDecryptProgressUpdate(zeroProgress); + } + + protected TempFile GetNewTempFilePath(string extension) + { + extension = FileUtility.GetStandardizedExtension(extension); + var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension); + return new(path, extension); + } + + public async Task RunAsync() + { + await InputFileStream.BeginDownloadingAsync(); + var progressTask = Task.Run(reportProgress); + + (bool success, var elapsed) = await AsyncSteps.RunAsync(); + + //Stop the downloader so it doesn't keep running in the background. + if (!success) + NfsPersister.Dispose(); + + await progressTask; + + var speedup = DownloadOptions.RuntimeLength / elapsed; + Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); + + NfsPersister.Dispose(); + return success; + + async Task reportProgress() + { + AverageSpeed averageSpeed = new(); + + while ( + InputFileStream.CanRead + && InputFileStream.Length > InputFilePosition + && !InputFileStream.IsCancelled + && !downloadFinished) { - BytesReceived = 0, - ProgressPercentage = 0, - TotalBytesToReceive = 0 - }; + averageSpeed.AddPosition(InputFilePosition); + var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average; + + if (double.IsNormal(estSecsRemaining)) + OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining)); + + var progressPercent = 100d * InputFilePosition / InputFileStream.Length; + + OnDecryptProgressUpdate( + new DownloadProgress + { + ProgressPercentage = progressPercent, + BytesReceived = InputFilePosition, + TotalBytesToReceive = InputFileStream.Length + }); + + await Task.Delay(200); + } + + OnDecryptTimeRemaining(TimeSpan.Zero); OnDecryptProgressUpdate(zeroProgress); } + } - protected TempFile GetNewTempFilePath(string extension) + public virtual Task CancelAsync() + { + IsCanceled = true; + FinalizeDownload(); + return Task.CompletedTask; + } + protected abstract Task Step_DownloadAndDecryptAudiobookAsync(); + + public virtual void SetCoverArt(byte[] coverArt) { } + protected void OnRetrievedTitle(string? title) + => RetrievedTitle?.Invoke(this, title); + protected void OnRetrievedAuthors(string? authors) + => RetrievedAuthors?.Invoke(this, authors); + protected void OnRetrievedNarrators(string? narrators) + => RetrievedNarrators?.Invoke(this, narrators); + protected void OnRetrievedCoverArt(byte[]? coverArt) + => RetrievedCoverArt?.Invoke(this, coverArt); + protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress) + => DecryptProgressUpdate?.Invoke(this, downloadProgress); + protected void OnDecryptTimeRemaining(TimeSpan timeRemaining) + => DecryptTimeRemaining?.Invoke(this, timeRemaining); + public void OnTempFileCreated(TempFile path) + => TempFileCreated?.Invoke(this, path); + + protected virtual void FinalizeDownload() + { + NfsPersister.Dispose(); + downloadFinished = true; + } + + protected async Task Step_CreateCueAsync() + { + if (!DownloadOptions.CreateCueSheet) return !IsCanceled; + + if (DownloadOptions.ChapterInfo.Count <= 1) { - extension = FileUtility.GetStandardizedExtension(extension); - var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension); - return new(path, extension); - } - - public async Task RunAsync() - { - await InputFileStream.BeginDownloadingAsync(); - var progressTask = Task.Run(reportProgress); - - (bool success, var elapsed) = await AsyncSteps.RunAsync(); - - //Stop the downloader so it doesn't keep running in the background. - if (!success) - NfsPersister.Dispose(); - - await progressTask; - - var speedup = DownloadOptions.RuntimeLength / elapsed; - Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); - - NfsPersister.Dispose(); - return success; - - async Task reportProgress() - { - AverageSpeed averageSpeed = new(); - - while ( - InputFileStream.CanRead - && InputFileStream.Length > InputFilePosition - && !InputFileStream.IsCancelled - && !downloadFinished) - { - averageSpeed.AddPosition(InputFilePosition); - - var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average; - - if (double.IsNormal(estSecsRemaining)) - OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining)); - - var progressPercent = 100d * InputFilePosition / InputFileStream.Length; - - OnDecryptProgressUpdate( - new DownloadProgress - { - ProgressPercentage = progressPercent, - BytesReceived = InputFilePosition, - TotalBytesToReceive = InputFileStream.Length - }); - - await Task.Delay(200); - } - - OnDecryptTimeRemaining(TimeSpan.Zero); - OnDecryptProgressUpdate(zeroProgress); - } - } - - public virtual Task CancelAsync() - { - IsCanceled = true; - FinalizeDownload(); - return Task.CompletedTask; - } - protected abstract Task Step_DownloadAndDecryptAudiobookAsync(); - - public virtual void SetCoverArt(byte[] coverArt) { } - protected void OnRetrievedTitle(string? title) - => RetrievedTitle?.Invoke(this, title); - protected void OnRetrievedAuthors(string? authors) - => RetrievedAuthors?.Invoke(this, authors); - protected void OnRetrievedNarrators(string? narrators) - => RetrievedNarrators?.Invoke(this, narrators); - protected void OnRetrievedCoverArt(byte[]? coverArt) - => RetrievedCoverArt?.Invoke(this, coverArt); - protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress) - => DecryptProgressUpdate?.Invoke(this, downloadProgress); - protected void OnDecryptTimeRemaining(TimeSpan timeRemaining) - => DecryptTimeRemaining?.Invoke(this, timeRemaining); - public void OnTempFileCreated(TempFile path) - => TempFileCreated?.Invoke(this, path); - - protected virtual void FinalizeDownload() - { - NfsPersister.Dispose(); - downloadFinished = true; - } - - protected async Task Step_CreateCueAsync() - { - if (!DownloadOptions.CreateCueSheet) return !IsCanceled; - - if (DownloadOptions.ChapterInfo.Count <= 1) - { - Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters."); - return !IsCanceled; - } - - // not a critical step. its failure should not prevent future steps from running - try - { - var tempFile = GetNewTempFilePath(".cue"); - await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo)); - OnTempFileCreated(tempFile); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed"); - } + Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters."); return !IsCanceled; } - private NetworkFileStreamPersister OpenNetworkFileStream() + // not a critical step. its failure should not prevent future steps from running + try { - NetworkFileStreamPersister? nfsp = default; - try - { - if (!File.Exists(jsonDownloadState)) - return nfsp = newNetworkFilePersister(); + var tempFile = GetNewTempFilePath(".cue"); + await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo)); + OnTempFileCreated(tempFile); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed"); + } + return !IsCanceled; + } - nfsp = new NetworkFileStreamPersister(jsonDownloadState); - // The download url expires after 1 hour. - // The new url points to the same file. - nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl)); - return nfsp; - } - catch - { - nfsp?.Target?.Dispose(); - FileUtility.SaferDelete(jsonDownloadState); - FileUtility.SaferDelete(tempFilePath); + private NetworkFileStreamPersister OpenNetworkFileStream() + { + NetworkFileStreamPersister? nfsp = default; + try + { + if (!File.Exists(jsonDownloadState)) return nfsp = newNetworkFilePersister(); - } - finally - { - //nfsp will only be null when an unhandled exception occurs. Let the caller handle it. - if (nfsp is not null) - { - nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent; - nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; - OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString())); - OnTempFileCreated(new(jsonDownloadState)); - } - } - NetworkFileStreamPersister newNetworkFilePersister() + nfsp = new NetworkFileStreamPersister(jsonDownloadState); + // The download url expires after 1 hour. + // The new url points to the same file. + nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl)); + return nfsp; + } + catch + { + nfsp?.Target?.Dispose(); + FileUtility.SaferDelete(jsonDownloadState); + FileUtility.SaferDelete(tempFilePath); + return nfsp = newNetworkFilePersister(); + } + finally + { + //nfsp will only be null when an unhandled exception occurs. Let the caller handle it. + if (nfsp is not null) { - var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } }); - return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState); + nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent; + nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; + OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString())); + OnTempFileCreated(new(jsonDownloadState)); } } + + NetworkFileStreamPersister newNetworkFilePersister() + { + var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } }); + return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState); + } } } diff --git a/Source/AaxDecrypter/AverageSpeed.cs b/Source/AaxDecrypter/AverageSpeed.cs index df12cb69..b0016e7b 100644 --- a/Source/AaxDecrypter/AverageSpeed.cs +++ b/Source/AaxDecrypter/AverageSpeed.cs @@ -125,7 +125,7 @@ public class AverageSpeed var time = now - start; - while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First.Value.Time > SlowWindow)) + while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First!.Value.Time > SlowWindow)) speeds.RemoveFirst(); if (!double.IsNaN(lastPosition)) @@ -145,7 +145,7 @@ public class AverageSpeed if (speeds.Count == 0) return 0; else if (speeds.Count == 1) - return speeds.Last.Value.Velocity; + return speeds.Last!.Value.Velocity; else { var n_newest = speeds.Count(s => s.Time > lastTime.Subtract(FastWindow)); diff --git a/Source/AaxDecrypter/Cue.cs b/Source/AaxDecrypter/Cue.cs index 9d55ae1e..7cac3e02 100644 --- a/Source/AaxDecrypter/Cue.cs +++ b/Source/AaxDecrypter/Cue.cs @@ -3,58 +3,57 @@ using Mpeg4Lib; using System.IO; using System.Text; -namespace AaxDecrypter +namespace AaxDecrypter; + +public static class Cue { - public static class Cue - { - public static string CreateContents(string filePath, ChapterInfo chapters) - { - var stringBuilder = new StringBuilder(); + public static string CreateContents(string filePath, ChapterInfo chapters) + { + var stringBuilder = new StringBuilder(); - stringBuilder.AppendLine(GetFileLine(filePath, "MP3")); + stringBuilder.AppendLine(GetFileLine(filePath, "MP3")); - var startOffset = chapters.StartOffset; + var startOffset = chapters.StartOffset; - var trackCount = 1; - foreach (var c in chapters.Chapters) - { - var startTime = c.StartOffset - startOffset; + var trackCount = 1; + foreach (var c in chapters.Chapters) + { + var startTime = c.StartOffset - startOffset; - stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO"); - stringBuilder.AppendLine($" TITLE \"{c.Title}\""); - stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}"); - } + stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO"); + stringBuilder.AppendLine($" TITLE \"{c.Title}\""); + stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}"); + } - return stringBuilder.ToString(); - } + return stringBuilder.ToString(); + } - public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath) - => UpdateFileName(cueFileInfo.FullName, audioFilePath); + public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath) + => UpdateFileName(cueFileInfo.FullName, audioFilePath); - public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo) - => UpdateFileName(cueFilePath, audioFileInfo.FullName); + public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo) + => UpdateFileName(cueFilePath, audioFileInfo.FullName); - public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo) - => UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName); + public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo) + => UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName); - public static void UpdateFileName(string cueFilePath, string audioFilePath) - { - var cueContents = File.ReadAllLines(cueFilePath); + public static void UpdateFileName(string cueFilePath, string audioFilePath) + { + var cueContents = File.ReadAllLines(cueFilePath); - for (var i = 0; i < cueContents.Length; i++) - { - var line = cueContents[i]; - if (!line.Trim().StartsWith("FILE") || !line.Contains(' ')) - continue; + for (var i = 0; i < cueContents.Length; i++) + { + var line = cueContents[i]; + if (!line.Trim().StartsWith("FILE") || !line.Contains(' ')) + continue; - var fileTypeBegins = line.LastIndexOf(" ") + 1; - cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]); - break; - } + var fileTypeBegins = line.LastIndexOf(" ") + 1; + cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]); + break; + } - File.WriteAllLines(cueFilePath, cueContents); - } + File.WriteAllLines(cueFilePath, cueContents); + } - private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}"; - } + private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}"; } diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index 7146c9b6..50790f32 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -2,55 +2,53 @@ using Mpeg4Lib; using System; -#nullable enable -namespace AaxDecrypter +namespace AaxDecrypter; + +public class KeyData { - public class KeyData - { - public byte[] KeyPart1 { get; } - public byte[]? KeyPart2 { get; } + public byte[] KeyPart1 { get; } + public byte[]? KeyPart2 { get; } - public KeyData(byte[] keyPart1, byte[]? keyPart2 = null) - { - KeyPart1 = keyPart1; - KeyPart2 = keyPart2; - } + public KeyData(byte[] keyPart1, byte[]? keyPart2 = null) + { + KeyPart1 = keyPart1; + KeyPart2 = keyPart2; + } - [Newtonsoft.Json.JsonConstructor] - public KeyData(string keyPart1, string? keyPart2 = null) - { - ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1)); - KeyPart1 = Convert.FromHexString(keyPart1); - if (keyPart2 != null) - KeyPart2 = Convert.FromHexString(keyPart2); - } - } - - public interface IDownloadOptions - { - event EventHandler DownloadSpeedChanged; - string DownloadUrl { get; } - string UserAgent { get; } - KeyData[]? DecryptionKeys { get; } - TimeSpan RuntimeLength { get; } - OutputFormat OutputFormat { get; } - bool StripUnabridged { get; } - bool CreateCueSheet { get; } - long DownloadSpeedBps { get; } - ChapterInfo ChapterInfo { get; } - bool FixupFile { get; } - string? AudibleProductId { get; } - string? Title { get; } - string? Subtitle { get; } - string? Publisher { get; } - string? Language { get; } - string? SeriesName { get; } - string? SeriesNumber { get; } - NAudio.Lame.LameConfig? LameConfig { get; } - bool Downsample { get; } - bool MatchSourceBitrate { get; } - bool MoveMoovToBeginning { get; } - string GetMultipartTitle(MultiConvertFileProperties props); - public FileType? InputType { get; } - } + [Newtonsoft.Json.JsonConstructor] + public KeyData(string keyPart1, string? keyPart2 = null) + { + ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1)); + KeyPart1 = Convert.FromHexString(keyPart1); + if (keyPart2 != null) + KeyPart2 = Convert.FromHexString(keyPart2); + } +} + +public interface IDownloadOptions +{ + event EventHandler DownloadSpeedChanged; + string DownloadUrl { get; } + string UserAgent { get; } + KeyData[]? DecryptionKeys { get; } + TimeSpan RuntimeLength { get; } + OutputFormat OutputFormat { get; } + bool StripUnabridged { get; } + bool CreateCueSheet { get; } + long DownloadSpeedBps { get; } + ChapterInfo ChapterInfo { get; } + bool FixupFile { get; } + string? AudibleProductId { get; } + string? Title { get; } + string? Subtitle { get; } + string? Publisher { get; } + string? Language { get; } + string? SeriesName { get; } + string? SeriesNumber { get; } + NAudio.Lame.LameConfig? LameConfig { get; } + bool Downsample { get; } + bool MatchSourceBitrate { get; } + bool MoveMoovToBeginning { get; } + string GetMultipartTitle(MultiConvertFileProperties props); + public FileType? InputType { get; } } diff --git a/Source/AaxDecrypter/MpegUtil.cs b/Source/AaxDecrypter/MpegUtil.cs index 18af6d33..ae3f3a7f 100644 --- a/Source/AaxDecrypter/MpegUtil.cs +++ b/Source/AaxDecrypter/MpegUtil.cs @@ -4,70 +4,68 @@ using NAudio.Lame; using System; using System.Linq; -#nullable enable -namespace AaxDecrypter +namespace AaxDecrypter; + +public static class MpegUtil { - public static class MpegUtil + private const string TagDomain = "com.pilabor.tone"; + public static void ConfigureLameOptions( + Mpeg4File mp4File, + LameConfig lameConfig, + bool downsample, + bool matchSourceBitrate, + ChapterInfo? chapters) { - private const string TagDomain = "com.pilabor.tone"; - public static void ConfigureLameOptions( - Mpeg4File mp4File, - LameConfig lameConfig, - bool downsample, - bool matchSourceBitrate, - ChapterInfo? chapters) + double bitrateMultiple = 1; + + if (mp4File.TimeScale < lameConfig.OutputSampleRate) { - double bitrateMultiple = 1; + lameConfig.OutputSampleRate = mp4File.TimeScale; + } + else if (mp4File.TimeScale > lameConfig.OutputSampleRate) + { + bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale; + } - if (mp4File.TimeScale < lameConfig.OutputSampleRate) - { - lameConfig.OutputSampleRate = mp4File.TimeScale; - } - else if (mp4File.TimeScale > lameConfig.OutputSampleRate) - { - bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale; - } + if (mp4File.AudioChannels == 2) + { + if (downsample) + bitrateMultiple /= 2; + else + lameConfig.Mode = MPEGMode.Stereo; + } - if (mp4File.AudioChannels == 2) - { - if (downsample) - bitrateMultiple /= 2; - else - lameConfig.Mode = MPEGMode.Stereo; - } + if (matchSourceBitrate) + { + int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024); - if (matchSourceBitrate) - { - int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024); + if (lameConfig.VBR is null) + lameConfig.BitRate = kbps; + else if (lameConfig.VBR == VBRMode.ABR) + lameConfig.ABRRateKbps = kbps; + } - if (lameConfig.VBR is null) - lameConfig.BitRate = kbps; - else if (lameConfig.VBR == VBRMode.ABR) - lameConfig.ABRRateKbps = kbps; - } + //Setup metadata tags + lameConfig.ID3 = mp4File.MetadataItems.ToIDTags(); - //Setup metadata tags - lameConfig.ID3 = mp4File.MetadataItems.ToIDTags(); + if (mp4File.MetadataItems.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle) + lameConfig.ID3.Subtitle = subtitle; - if (mp4File.MetadataItems.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle) - lameConfig.ID3.Subtitle = subtitle; + if (chapters?.Count > 0) + { + var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters); + lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue); + } - if (chapters?.Count > 0) - { - var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters); - lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue); - } - - //Copy over all other freeform tags - foreach (var t in mp4File.MetadataItems.AppleListBox.Tags.OfType()) - { - if (t.Name?.Name is string name && - t.Mean?.ReverseDnsDomain is string domain && - !lameConfig.ID3.UserDefinedText.ContainsKey(name) && - mp4File.MetadataItems.AppleListBox.GetFreeformTagString(domain, name) is string tagStr && - !string.IsNullOrWhiteSpace(tagStr)) - lameConfig.ID3.UserDefinedText.Add(name, tagStr); - } + //Copy over all other freeform tags + foreach (var t in mp4File.MetadataItems.AppleListBox.Tags.OfType()) + { + if (t.Name?.Name is string name && + t.Mean?.ReverseDnsDomain is string domain && + !lameConfig.ID3.UserDefinedText.ContainsKey(name) && + mp4File.MetadataItems.AppleListBox.GetFreeformTagString(domain, name) is string tagStr && + !string.IsNullOrWhiteSpace(tagStr)) + lameConfig.ID3.UserDefinedText.Add(name, tagStr); } } } diff --git a/Source/AaxDecrypter/MultiConvertFileProperties.cs b/Source/AaxDecrypter/MultiConvertFileProperties.cs index bfb9d9f7..e4bab59b 100644 --- a/Source/AaxDecrypter/MultiConvertFileProperties.cs +++ b/Source/AaxDecrypter/MultiConvertFileProperties.cs @@ -4,10 +4,10 @@ namespace AaxDecrypter { public class MultiConvertFileProperties { - public string OutputFileName { get; set; } + public required string OutputFileName { get; set; } public int PartsPosition { get; set; } public int PartsTotal { get; set; } - public string Title { get; set; } + public string? Title { get; set; } public DateTime FileDate { get; } = DateTime.Now; } } diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index 725018ca..a63e8b42 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -8,420 +8,419 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -namespace AaxDecrypter +namespace AaxDecrypter; + +/// A resumable, simultaneous file downloader and reader. +public class NetworkFileStream : Stream, IUpdatable { - /// A resumable, simultaneous file downloader and reader. - public class NetworkFileStream : Stream, IUpdatable + public event EventHandler? Updated; + + #region Public Properties + + /// Location to save the downloaded data. + [JsonProperty(Required = Required.Always)] + public string SaveFilePath { get; } + + /// Http(s) address of the file to download. + [JsonProperty(Required = Required.Always)] + public Uri Uri { get; private set; } + + /// Http headers to be sent to the server with the request. + [JsonProperty(Required = Required.Always)] + public Dictionary RequestHeaders { get; private set; } + + /// The position in that has been written and flushed to disk. + [JsonProperty(Required = Required.Always)] + public long WritePosition { get; private set; } + + /// The total length of the file to download. + [JsonProperty(Required = Required.Always)] + public long ContentLength { get; private set; } + + [JsonIgnore] + public bool IsCancelled => _cancellationSource.IsCancellationRequested; + + [JsonIgnore] + public Task? DownloadTask { get; private set; } + + private long _speedLimit = 0; + /// bytes per second + public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); } + + #endregion + + #region Private Properties + private FileStream _writeFile { get; } + private FileStream _readFile { get; } + private CancellationTokenSource _cancellationSource { get; } = new(); + private EventWaitHandle? _downloadedPiece { get; set; } + + private DateTime NextUpdateTime { get; set; } + + #endregion + + #region Constants + + //Download memory buffer size + private const int DOWNLOAD_BUFF_SZ = 8 * 1024; + + //NetworkFileStream will flush all data in _writeFile to disk after every + //DATA_FLUSH_SZ bytes are written to the file stream. + private const int DATA_FLUSH_SZ = 1024 * 1024; + + //Number of times per second the download rate is checked and throttled + private const int THROTTLE_FREQUENCY = 8; + + //Minimum throttle rate. The minimum amount of data that can be throttled + //on each iteration of the download loop is DOWNLOAD_BUFF_SZ. + public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY; + + #endregion + + #region Constructor + + /// A resumable, simultaneous file downloader and reader. + /// Path to a location on disk to save the downloaded data from + /// Http(s) address of the file to download. + /// The position in to begin downloading. + /// Http headers to be sent to the server with the . + public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary? requestHeaders = null) { - public event EventHandler Updated; + SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath)); + Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri)); + WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1); - #region Public Properties + if (!Directory.Exists(Path.GetDirectoryName(saveFilePath))) + throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist."); - /// Location to save the downloaded data. - [JsonProperty(Required = Required.Always)] - public string SaveFilePath { get; } + RequestHeaders = requestHeaders ?? new(); - /// Http(s) address of the file to download. - [JsonProperty(Required = Required.Always)] - public Uri Uri { get; private set; } - - /// Http headers to be sent to the server with the request. - [JsonProperty(Required = Required.Always)] - public Dictionary RequestHeaders { get; private set; } - - /// The position in that has been written and flushed to disk. - [JsonProperty(Required = Required.Always)] - public long WritePosition { get; private set; } - - /// The total length of the file to download. - [JsonProperty(Required = Required.Always)] - public long ContentLength { get; private set; } - - [JsonIgnore] - public bool IsCancelled => _cancellationSource.IsCancellationRequested; - - [JsonIgnore] - public Task DownloadTask { get; private set; } - - private long _speedLimit = 0; - /// bytes per second - public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); } - - #endregion - - #region Private Properties - private FileStream _writeFile { get; } - private FileStream _readFile { get; } - private CancellationTokenSource _cancellationSource { get; } = new(); - private EventWaitHandle _downloadedPiece { get; set; } - - private DateTime NextUpdateTime { get; set; } - - #endregion - - #region Constants - - //Download memory buffer size - private const int DOWNLOAD_BUFF_SZ = 8 * 1024; - - //NetworkFileStream will flush all data in _writeFile to disk after every - //DATA_FLUSH_SZ bytes are written to the file stream. - private const int DATA_FLUSH_SZ = 1024 * 1024; - - //Number of times per second the download rate is checked and throttled - private const int THROTTLE_FREQUENCY = 8; - - //Minimum throttle rate. The minimum amount of data that can be throttled - //on each iteration of the download loop is DOWNLOAD_BUFF_SZ. - public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY; - - #endregion - - #region Constructor - - /// A resumable, simultaneous file downloader and reader. - /// Path to a location on disk to save the downloaded data from - /// Http(s) address of the file to download. - /// The position in to begin downloading. - /// Http headers to be sent to the server with the . - public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary requestHeaders = null) + _writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite) { - SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath)); - Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri)); - WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1); + Position = WritePosition + }; - if (!Directory.Exists(Path.GetDirectoryName(saveFilePath))) - throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist."); - - RequestHeaders = requestHeaders ?? new(); - - _writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite) - { - Position = WritePosition - }; - - if (_writeFile.Length < WritePosition) - { - _writeFile.Dispose(); - throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}"); - } - - _readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - SetUriForSameFile(uri); + if (_writeFile.Length < WritePosition) + { + _writeFile.Dispose(); + throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}"); } - #endregion + _readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - #region Downloader + SetUriForSameFile(uri); + } - /// Update the . - private void OnUpdate(bool waitForWrite = false) + #endregion + + #region Downloader + + /// Update the . + private void OnUpdate(bool waitForWrite = false) + { + try { - try + if (waitForWrite || DateTime.UtcNow > NextUpdateTime) { - if (waitForWrite || DateTime.UtcNow > NextUpdateTime) + Updated?.Invoke(this, EventArgs.Empty); + //JsonFilePersister Will not allow update intervals shorter than 100 milliseconds + //If an update is called less than 100 ms since the last update, persister will + //sleep the thread until 100 ms has elapsed. + NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110); + } + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON"); + } + } + + /// Set a different to the same file targeted by this instance of + /// New host must match existing host. + public void SetUriForSameFile(Uri uriToSameFile) + { + ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile)); + + if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath)) + throw new ArgumentException($"New uri to the same file must have the same file name."); + if (uriToSameFile.Host != Uri.Host) + throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}"); + if (DownloadTask is not null) + throw new InvalidOperationException("Cannot change Uri after download has started."); + + Uri = uriToSameFile; + } + + /// Begins downloading to in a background thread. + /// The downloader + public async Task BeginDownloadingAsync() + { + if (ContentLength != 0 && WritePosition == ContentLength) + { + DownloadTask = Task.CompletedTask; + return; + } + + if (ContentLength != 0 && WritePosition > ContentLength) + throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10})."); + + //Initiate connection with the first request block and + //get the total content length before returning. + var client = new HttpClient(); + var response = await RequestNextByteRangeAsync(client); + + if (ContentLength != 0 && ContentLength != response.FileSize) + throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}"); + + ContentLength = response.FileSize; + + _downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset); + //Hand off the client and the open request to the downloader to download and write data to file. + DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token); + } + + private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse) + { + try + { + long startPosition = WritePosition; + + while (WritePosition < ContentLength && !IsCancelled) + { + try { - Updated?.Invoke(this, EventArgs.Empty); - //JsonFilePersister Will not allow update intervals shorter than 100 milliseconds - //If an update is called less than 100 ms since the last update, persister will - //sleep the thread until 100 ms has elapsed. - NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110); + await DownloadToFile(blockResponse); + } + catch (HttpIOException e) + when (e.HttpRequestError is HttpRequestError.ResponseEnded + && WritePosition != startPosition + && WritePosition < ContentLength && !IsCancelled) + { + Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes"); + + //the download made *some* progress since the last attempt. + //Try again to complete the download from where it left off. + //Make sure to rewind file to last flush position. + _writeFile.Position = startPosition = WritePosition; + blockResponse.Dispose(); + blockResponse = await RequestNextByteRangeAsync(client); + + Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}."); } } - catch (Exception ex) - { - Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON"); - } } - - /// Set a different to the same file targeted by this instance of - /// New host must match existing host. - public void SetUriForSameFile(Uri uriToSameFile) + catch (Exception ex) { - ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile)); - - if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath)) - throw new ArgumentException($"New uri to the same file must have the same file name."); - if (uriToSameFile.Host != Uri.Host) - throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}"); - if (DownloadTask is not null) - throw new InvalidOperationException("Cannot change Uri after download has started."); - - Uri = uriToSameFile; + //Don't throw from DownloadTask. + //This task gets awaited in Dispose() and we don't want to have an unhandled exception there. + Serilog.Log.Error(ex, "An error was encountered during the download process."); } - - /// Begins downloading to in a background thread. - /// The downloader - public async Task BeginDownloadingAsync() + finally { - if (ContentLength != 0 && WritePosition == ContentLength) - { - DownloadTask = Task.CompletedTask; - return; - } - - if (ContentLength != 0 && WritePosition > ContentLength) - throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10})."); - - //Initiate connection with the first request block and - //get the total content length before returning. - var client = new HttpClient(); - var response = await RequestNextByteRangeAsync(client); - - if (ContentLength != 0 && ContentLength != response.FileSize) - throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}"); - - ContentLength = response.FileSize; - - _downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset); - //Hand off the client and the open request to the downloader to download and write data to file. - DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token); + _writeFile.Dispose(); + blockResponse.Dispose(); + client.Dispose(); } + } - private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse) + private async Task RequestNextByteRangeAsync(HttpClient client) + { + using var request = new HttpRequestMessage(HttpMethod.Get, Uri); + + //Just in case it snuck in the saved json (Issue #1232) + RequestHeaders.Remove("Range"); + + foreach (var header in RequestHeaders) + request.Headers.Add(header.Key, header.Value); + + request.Headers.Add("Range", $"bytes={WritePosition}-"); + + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token); + + if (response.StatusCode != HttpStatusCode.PartialContent) + throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}."); + + var totalSize = response.Content.Headers.ContentRange?.Length ?? + throw new WebException("The response did not contain a total content length."); + + var rangeSize = response.Content.Headers.ContentLength ?? + throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};"); + + return new BlockResponse(response, rangeSize, totalSize); + } + + private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable + { + public void Dispose() => Response?.Dispose(); + } + + /// Download to . + private async Task DownloadToFile(BlockResponse block) + { + var endPosition = WritePosition + block.BlockSize; + using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token); + + var downloadPosition = WritePosition; + var nextFlush = downloadPosition + DATA_FLUSH_SZ; + var buff = new byte[DOWNLOAD_BUFF_SZ]; + + try { - try + DateTime startTime = DateTime.UtcNow; + long bytesReadSinceThrottle = 0; + int bytesRead; + do { - long startPosition = WritePosition; + bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token); + await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token); - while (WritePosition < ContentLength && !IsCancelled) + downloadPosition += bytesRead; + + if (downloadPosition > nextFlush) { - try - { - await DownloadToFile(blockResponse); - } - catch (HttpIOException e) - when (e.HttpRequestError is HttpRequestError.ResponseEnded - && WritePosition != startPosition - && WritePosition < ContentLength && !IsCancelled) - { - Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes"); - - //the download made *some* progress since the last attempt. - //Try again to complete the download from where it left off. - //Make sure to rewind file to last flush position. - _writeFile.Position = startPosition = WritePosition; - blockResponse.Dispose(); - blockResponse = await RequestNextByteRangeAsync(client); - - Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}."); - } + await _writeFile.FlushAsync(_cancellationSource.Token); + WritePosition = downloadPosition; + OnUpdate(); + nextFlush = downloadPosition + DATA_FLUSH_SZ; + _downloadedPiece?.Set(); } - } - catch (Exception ex) - { - //Don't throw from DownloadTask. - //This task gets awaited in Dispose() and we don't want to have an unhandled exception there. - Serilog.Log.Error(ex, "An error was encountered during the download process."); - } - finally - { - _writeFile.Dispose(); - blockResponse.Dispose(); - client.Dispose(); - } - } - private async Task RequestNextByteRangeAsync(HttpClient client) - { - using var request = new HttpRequestMessage(HttpMethod.Get, Uri); + #region throttle - //Just in case it snuck in the saved json (Issue #1232) - RequestHeaders.Remove("Range"); + bytesReadSinceThrottle += bytesRead; - foreach (var header in RequestHeaders) - request.Headers.Add(header.Key, header.Value); - - request.Headers.Add("Range", $"bytes={WritePosition}-"); - - var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token); - - if (response.StatusCode != HttpStatusCode.PartialContent) - throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}."); - - var totalSize = response.Content.Headers.ContentRange?.Length ?? - throw new WebException("The response did not contain a total content length."); - - var rangeSize = response.Content.Headers.ContentLength ?? - throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};"); - - return new BlockResponse(response, rangeSize, totalSize); - } - - private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable - { - public void Dispose() => Response?.Dispose(); - } - - /// Download to . - private async Task DownloadToFile(BlockResponse block) - { - var endPosition = WritePosition + block.BlockSize; - using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token); - - var downloadPosition = WritePosition; - var nextFlush = downloadPosition + DATA_FLUSH_SZ; - var buff = new byte[DOWNLOAD_BUFF_SZ]; - - try - { - DateTime startTime = DateTime.UtcNow; - long bytesReadSinceThrottle = 0; - int bytesRead; - do + if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY) { - bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token); - await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token); + var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds; + if (delayMS > 0) + await Task.Delay(delayMS, _cancellationSource.Token); - downloadPosition += bytesRead; + startTime = DateTime.UtcNow; + bytesReadSinceThrottle = 0; + } - if (downloadPosition > nextFlush) - { - await _writeFile.FlushAsync(_cancellationSource.Token); - WritePosition = downloadPosition; - OnUpdate(); - nextFlush = downloadPosition + DATA_FLUSH_SZ; - _downloadedPiece.Set(); - } + #endregion - #region throttle + } while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0); - bytesReadSinceThrottle += bytesRead; + await _writeFile.FlushAsync(_cancellationSource.Token); + WritePosition = downloadPosition; - if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY) - { - var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds; - if (delayMS > 0) - await Task.Delay(delayMS, _cancellationSource.Token); + if (!IsCancelled && WritePosition < endPosition) + throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10})."); - startTime = DateTime.UtcNow; - bytesReadSinceThrottle = 0; - } - - #endregion - - } while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0); - - await _writeFile.FlushAsync(_cancellationSource.Token); - WritePosition = downloadPosition; - - if (!IsCancelled && WritePosition < endPosition) - throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10})."); - - if (WritePosition > endPosition) - throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10})."); - } - catch (OperationCanceledException) - { - Serilog.Log.Information("Download was cancelled"); - } - finally - { - _downloadedPiece.Set(); - OnUpdate(waitForWrite: true); - } + if (WritePosition > endPosition) + throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10})."); } - - #endregion - - #region Download Stream Reader - - [JsonIgnore] - public override bool CanRead => _readFile.CanRead; - - [JsonIgnore] - public override bool CanSeek => _readFile.CanSeek; - - [JsonIgnore] - public override bool CanWrite => false; - - [JsonIgnore] - public override long Length + catch (OperationCanceledException) { - get - { - if (DownloadTask is null) - throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}"); - return ContentLength; - } + Serilog.Log.Information("Download was cancelled"); } + finally + { + _downloadedPiece?.Set(); + OnUpdate(waitForWrite: true); + } + } - [JsonIgnore] - public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); } + #endregion - [JsonIgnore] - public override bool CanTimeout => false; + #region Download Stream Reader - [JsonIgnore] - public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; } + [JsonIgnore] + public override bool CanRead => _readFile.CanRead; - [JsonIgnore] - public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; } + [JsonIgnore] + public override bool CanSeek => _readFile.CanSeek; - public override void Flush() => throw new InvalidOperationException(); - public override void SetLength(long value) => throw new InvalidOperationException(); - public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException(); + [JsonIgnore] + public override bool CanWrite => false; - public override int Read(byte[] buffer, int offset, int count) + [JsonIgnore] + public override long Length + { + get { if (DownloadTask is null) throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}"); - - var toRead = Math.Min(count, Length - Position); - WaitToPosition(Position + toRead); - return IsCancelled ? 0 : _readFile.Read(buffer, offset, count); + return ContentLength; } - - public override long Seek(long offset, SeekOrigin origin) - { - var newPosition = origin switch - { - SeekOrigin.Current => Position + offset, - SeekOrigin.End => ContentLength + offset, - _ => offset, - }; - - WaitToPosition(newPosition); - return _readFile.Position = newPosition; - } - - /// Blocks until the file has downloaded to at least , then returns. - /// The minimum required flushed data length in . - private void WaitToPosition(long requiredPosition) - { - while (WritePosition < requiredPosition - && DownloadTask?.IsCompleted is false - && !IsCancelled) - { - _downloadedPiece.WaitOne(50); - } - } - - private bool disposed = false; - - /* - * https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0 - * - * In derived classes, do not override the Close() method, instead, put all of the - * Stream cleanup logic in the Dispose(Boolean) method. - */ - protected override void Dispose(bool disposing) - { - if (disposing && !Interlocked.CompareExchange(ref disposed, true, false)) - { - _cancellationSource.Cancel(); - DownloadTask?.GetAwaiter().GetResult(); - _downloadedPiece?.Dispose(); - _cancellationSource?.Dispose(); - _readFile.Dispose(); - _writeFile.Dispose(); - OnUpdate(waitForWrite: true); - } - - base.Dispose(disposing); - } - - #endregion } + + [JsonIgnore] + public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); } + + [JsonIgnore] + public override bool CanTimeout => false; + + [JsonIgnore] + public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; } + + [JsonIgnore] + public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; } + + public override void Flush() => throw new InvalidOperationException(); + public override void SetLength(long value) => throw new InvalidOperationException(); + public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException(); + + public override int Read(byte[] buffer, int offset, int count) + { + if (DownloadTask is null) + throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}"); + + var toRead = Math.Min(count, Length - Position); + WaitToPosition(Position + toRead); + return IsCancelled ? 0 : _readFile.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) + { + var newPosition = origin switch + { + SeekOrigin.Current => Position + offset, + SeekOrigin.End => ContentLength + offset, + _ => offset, + }; + + WaitToPosition(newPosition); + return _readFile.Position = newPosition; + } + + /// Blocks until the file has downloaded to at least , then returns. + /// The minimum required flushed data length in . + private void WaitToPosition(long requiredPosition) + { + while (WritePosition < requiredPosition + && DownloadTask?.IsCompleted is false + && !IsCancelled) + { + _downloadedPiece?.WaitOne(50); + } + } + + private bool disposed = false; + + /* + * https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0 + * + * In derived classes, do not override the Close() method, instead, put all of the + * Stream cleanup logic in the Dispose(Boolean) method. + */ + protected override void Dispose(bool disposing) + { + if (disposing && !Interlocked.CompareExchange(ref disposed, true, false)) + { + _cancellationSource.Cancel(); + DownloadTask?.GetAwaiter().GetResult(); + _downloadedPiece?.Dispose(); + _cancellationSource?.Dispose(); + _readFile.Dispose(); + _writeFile.Dispose(); + OnUpdate(waitForWrite: true); + } + + base.Dispose(disposing); + } + + #endregion } diff --git a/Source/AaxDecrypter/NetworkFileStreamPersister.cs b/Source/AaxDecrypter/NetworkFileStreamPersister.cs index fed3ad7c..58724ba4 100644 --- a/Source/AaxDecrypter/NetworkFileStreamPersister.cs +++ b/Source/AaxDecrypter/NetworkFileStreamPersister.cs @@ -1,25 +1,24 @@ using Dinah.Core.IO; -namespace AaxDecrypter +namespace AaxDecrypter; + +internal class NetworkFileStreamPersister : JsonFilePersister { - internal class NetworkFileStreamPersister : JsonFilePersister + /// Alias for Target + public NetworkFileStream NetworkFileStream => Target; + + /// uses path. create file if doesn't yet exist + public NetworkFileStreamPersister(NetworkFileStream networkFileStream, string path, string? jsonPath = null) + : base(networkFileStream, path, jsonPath) { } + + /// load from existing file + public NetworkFileStreamPersister(string path, string? jsonPath = null) + : base(path, jsonPath) { } + + protected override void Dispose(bool disposing) { - /// Alias for Target - public NetworkFileStream NetworkFileStream => Target; - - /// uses path. create file if doesn't yet exist - public NetworkFileStreamPersister(NetworkFileStream networkFileStream, string path, string jsonPath = null) - : base(networkFileStream, path, jsonPath) { } - - /// load from existing file - public NetworkFileStreamPersister(string path, string jsonPath = null) - : base(path, jsonPath) { } - - protected override void Dispose(bool disposing) - { - if (disposing) - NetworkFileStream?.Dispose(); - base.Dispose(disposing); - } + if (disposing) + NetworkFileStream?.Dispose(); + base.Dispose(disposing); } } diff --git a/Source/AaxDecrypter/TempFile.cs b/Source/AaxDecrypter/TempFile.cs index f6b37ee4..34cfe093 100644 --- a/Source/AaxDecrypter/TempFile.cs +++ b/Source/AaxDecrypter/TempFile.cs @@ -1,6 +1,5 @@ using FileManager; -#nullable enable namespace AaxDecrypter; public record TempFile diff --git a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs index 9b9a4b4e..cab25152 100644 --- a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs +++ b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs @@ -1,34 +1,33 @@ using FileManager; using System.Threading.Tasks; -#nullable enable -namespace AaxDecrypter + +namespace AaxDecrypter; + +public class UnencryptedAudiobookDownloader : AudiobookDownloadBase { - public class UnencryptedAudiobookDownloader : AudiobookDownloadBase + protected override long InputFilePosition => InputFileStream.WritePosition; + + public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic) + : base(outDirectory, cacheDirectory, dlLic) { - protected override long InputFilePosition => InputFileStream.WritePosition; + AsyncSteps.Name = "Download Unencrypted Audiobook"; + AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; + AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync; + } - public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic) - : base(outDirectory, cacheDirectory, dlLic) + protected override async Task Step_DownloadAndDecryptAudiobookAsync() + { + await (InputFileStream.DownloadTask ?? Task.CompletedTask); + + if (IsCanceled) + return false; + else { - AsyncSteps.Name = "Download Unencrypted Audiobook"; - AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; - AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync; - } - - protected override async Task Step_DownloadAndDecryptAudiobookAsync() - { - await InputFileStream.DownloadTask; - - if (IsCanceled) - return false; - else - { - FinalizeDownload(); - var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); - FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath); - OnTempFileCreated(tempFile); - return true; - } + FinalizeDownload(); + var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); + FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath); + OnTempFileCreated(tempFile); + return true; } } } diff --git a/Source/AppScaffolding/AppScaffolding.csproj b/Source/AppScaffolding/AppScaffolding.csproj index 315dc9ed..e335f63b 100644 --- a/Source/AppScaffolding/AppScaffolding.csproj +++ b/Source/AppScaffolding/AppScaffolding.csproj @@ -3,6 +3,7 @@ net10.0 13.1.8.1 + enable diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 72f3de07..fbbb94ec 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -46,21 +46,19 @@ namespace AppScaffolding public static Variety Variety { get; private set; } // AppScaffolding - private static Assembly _executingAssembly; + private static Assembly? _executingAssembly; private static Assembly ExecutingAssembly => _executingAssembly ??= Assembly.GetExecutingAssembly(); // LibationWinForms or LibationCli - private static Assembly _entryAssembly; - private static Assembly EntryAssembly + private static Assembly? _entryAssembly; + private static Assembly? EntryAssembly => _entryAssembly ??= Assembly.GetEntryAssembly(); // previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; - private static Version _buildVersion; - public static Version BuildVersion - => _buildVersion - ??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() } - .Max(a => a.Version); + private static Version? _buildVersion; + public static Version? BuildVersion + => _buildVersion ??= new[] { ExecutingAssembly.GetName(), EntryAssembly?.GetName() }.Max(a => a?.Version); /// Run migrations before loading Configuration for the first time. Then load and return Configuration public static Configuration RunPreConfigMigrations() @@ -305,8 +303,8 @@ namespace AppScaffolding Log.Logger.Information("Begin. {@DebugInfo}", new { - AppName = EntryAssembly.GetName().Name, - Version = BuildVersion.ToString(), + AppName = EntryAssembly?.GetName().Name, + Version = BuildVersion?.ToString(), ReleaseIdentifier, Configuration.OS, Environment.OSVersion, @@ -342,14 +340,14 @@ namespace AppScaffolding #nullable restore private static void wireUpSystemEvents(Configuration configuration) { - LibraryCommands.LibrarySizeChanged += (object _, List libraryBooks) + LibraryCommands.LibrarySizeChanged += (object? _, List libraryBooks) => SearchEngineCommands.FullReIndex(libraryBooks); LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books); } - public static UpgradeProperties GetLatestRelease() + public static UpgradeProperties? GetLatestRelease() { // timed out (var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10)); @@ -358,7 +356,7 @@ namespace AppScaffolding return null; // we have an update - var zipUrl = zip?.BrowserDownloadUrl; + var zipUrl = zip.BrowserDownloadUrl; Log.Logger.Information("Update available: {@DebugInfo}", new { @@ -369,7 +367,7 @@ namespace AppScaffolding return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body); } - private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout) + private static (Version? releaseVersion, Octokit.Release?, Octokit.ReleaseAsset?) getLatestRelease(TimeSpan timeout) { try { @@ -385,7 +383,7 @@ namespace AppScaffolding } return (null, null, null); } - private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease() + private static async System.Threading.Tasks.Task<(Version? releaseVersion, Octokit.Release?, Octokit.ReleaseAsset?)> getLatestRelease() { const string ownerAccount = "rmcrackan"; const string repoName = "Libation"; @@ -404,7 +402,7 @@ namespace AppScaffolding var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json"); var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts)); - string regexPattern; + string? regexPattern; try { @@ -414,6 +412,8 @@ namespace AppScaffolding { regexPattern = releaseIndex.Value(ReleaseIdentifier.ToString()); } + if (regexPattern is null) + return (null, null, null); var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); @@ -516,7 +516,7 @@ namespace AppScaffolding .Select(i => new JObject { { "Id", i.Id }, - { "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) }, + { "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path!) }, { "Path", new JObject{ { "Path", i.Path } } } }) .ToArray(); diff --git a/Source/ApplicationServices/ApplicationServices.csproj b/Source/ApplicationServices/ApplicationServices.csproj index 91127f17..7c5dcd6e 100644 --- a/Source/ApplicationServices/ApplicationServices.csproj +++ b/Source/ApplicationServices/ApplicationServices.csproj @@ -2,6 +2,7 @@ net10.0 + enable diff --git a/Source/ApplicationServices/DbContexts.cs b/Source/ApplicationServices/DbContexts.cs index 8ae52402..9063914b 100644 --- a/Source/ApplicationServices/DbContexts.cs +++ b/Source/ApplicationServices/DbContexts.cs @@ -3,7 +3,6 @@ using LibationFileManager; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; -#nullable enable namespace ApplicationServices { public static class DbContexts diff --git a/Source/ApplicationServices/ExportDto.cs b/Source/ApplicationServices/ExportDto.cs index 7221deaf..f170f7cf 100644 --- a/Source/ApplicationServices/ExportDto.cs +++ b/Source/ApplicationServices/ExportDto.cs @@ -4,7 +4,6 @@ using Newtonsoft.Json; using System; using System.Linq; -#nullable enable namespace ApplicationServices; internal class ExportDto(LibraryBook libBook) @@ -46,7 +45,7 @@ internal class ExportDto(LibraryBook libBook) public string Description { get; } = libBook.Book.Description; [Name("Publisher")] - public string Publisher { get; } = libBook.Book.Publisher; + public string? Publisher { get; } = libBook.Book.Publisher; [Name("Has PDF")] public bool HasPdf { get; } = libBook.Book.HasPdf; @@ -67,10 +66,10 @@ internal class ExportDto(LibraryBook libBook) public float? CommunityRatingStory { get; } = ZeroIsNull(libBook.Book.Rating?.StoryRating); [Name("Cover Id")] - public string PictureId { get; } = libBook.Book.PictureId; + public string? PictureId { get; } = libBook.Book.PictureId; [Name("Cover Id Large")] - public string PictureLarge { get; } = libBook.Book.PictureLarge; + public string? PictureLarge { get; } = libBook.Book.PictureLarge; [Name("Is Abridged?")] public bool IsAbridged { get; } = libBook.Book.IsAbridged; @@ -103,7 +102,7 @@ internal class ExportDto(LibraryBook libBook) public string ContentType { get; } = libBook.Book.ContentType.ToString(); [Name("Language")] - public string Language { get; } = libBook.Book.Language; + public string? Language { get; } = libBook.Book.Language; [Name("Last Downloaded")] public DateTime? LastDownloaded { get; } = libBook.Book.UserDefinedItem.LastDownloaded; diff --git a/Source/ApplicationServices/ISearchEngine.cs b/Source/ApplicationServices/ISearchEngine.cs index d7e171e9..89e2b065 100644 --- a/Source/ApplicationServices/ISearchEngine.cs +++ b/Source/ApplicationServices/ISearchEngine.cs @@ -1,6 +1,5 @@ using LibationSearchEngine; -#nullable enable namespace ApplicationServices; public interface ISearchEngine diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index ae165e7e..8e8a491c 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -6,7 +6,6 @@ using Dinah.Core.Logging; using DtoImporterService; using FileManager; using LibationFileManager; -using Microsoft.Extensions.DependencyModel; using Newtonsoft.Json.Linq; using Serilog; using System; @@ -16,7 +15,6 @@ using System.Text; using System.Threading.Tasks; using static DtoImporterService.PerfLogger; -#nullable enable namespace ApplicationServices { public static class LibraryCommands @@ -184,16 +182,11 @@ namespace ApplicationServices public static Task ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName)); private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName) { - ArgumentValidator.EnsureNotNull(item, "item"); - ArgumentValidator.EnsureNotNull(accountId, "accountId"); - ArgumentValidator.EnsureNotNull(localeName, "localeName"); + ArgumentValidator.EnsureNotNull(item, nameof(item)); + ArgumentValidator.EnsureNotNull(accountId, nameof(accountId)); + ArgumentValidator.EnsureNotNull(localeName, nameof(localeName)); - var importItem = new ImportItem - { - DtoItem = item, - AccountId = accountId, - LocaleName = localeName - }; + var importItem = new ImportItem(item, accountId, localeName); var importItems = new List { importItem }; var validator = new LibraryValidator(); @@ -207,6 +200,9 @@ namespace ApplicationServices return DoDbSizeChangeOperation(ctx => { + if (importItem.DtoItem.ProductId is null) + return; + var bookImporter = new BookImporter(ctx); bookImporter.Import(importItems); var book = ctx.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId); @@ -291,6 +287,7 @@ namespace ApplicationServices private static async Task> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver) { ArgumentValidator.EnsureNotNull(account, nameof(account)); + var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new { @@ -307,7 +304,7 @@ namespace ApplicationServices await logDtoItemsAsync(dtoItems); - return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList(); + return dtoItems.Select(d => new ImportItem(d, account.AccountId, locale.Name)).ToList(); } catch(ImportValidationException ex) { diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index afa73620..497a1990 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -8,7 +8,6 @@ using System.Globalization; using System.Linq; using System.Reflection; -#nullable enable namespace ApplicationServices; public static class LibraryExporter diff --git a/Source/ApplicationServices/MainSearchEngine.cs b/Source/ApplicationServices/MainSearchEngine.cs index 52783801..e94e0fd6 100644 --- a/Source/ApplicationServices/MainSearchEngine.cs +++ b/Source/ApplicationServices/MainSearchEngine.cs @@ -1,6 +1,5 @@ using LibationSearchEngine; -#nullable enable namespace ApplicationServices; /// diff --git a/Source/ApplicationServices/SearchEngineCommands.cs b/Source/ApplicationServices/SearchEngineCommands.cs index 7b292e44..c3867cfe 100644 --- a/Source/ApplicationServices/SearchEngineCommands.cs +++ b/Source/ApplicationServices/SearchEngineCommands.cs @@ -29,7 +29,7 @@ namespace ApplicationServices } #endregion - public static event EventHandler SearchEngineUpdated; + public static event EventHandler? SearchEngineUpdated; #region Update private static bool isUpdating; @@ -85,7 +85,7 @@ namespace ApplicationServices action(new SearchEngine()); if (!prevIsUpdating) - SearchEngineUpdated?.Invoke(null, null); + SearchEngineUpdated?.Invoke(null, EventArgs.Empty); } finally { diff --git a/Source/ApplicationServices/TempSearchEngine.cs b/Source/ApplicationServices/TempSearchEngine.cs index 527f67fe..c345042b 100644 --- a/Source/ApplicationServices/TempSearchEngine.cs +++ b/Source/ApplicationServices/TempSearchEngine.cs @@ -3,7 +3,6 @@ using LibationFileManager; using LibationSearchEngine; using System.Collections.Generic; -#nullable enable namespace ApplicationServices; /// diff --git a/Source/AudibleUtilities/Account.cs b/Source/AudibleUtilities/Account.cs index 8fe9e3f6..b8895836 100644 --- a/Source/AudibleUtilities/Account.cs +++ b/Source/AudibleUtilities/Account.cs @@ -1,99 +1,98 @@ using System; -using System.Collections.Generic; -using System.Linq; +using System.Diagnostics.CodeAnalysis; using AudibleApi; using AudibleApi.Authorization; using Dinah.Core; using Newtonsoft.Json; -namespace AudibleUtilities +namespace AudibleUtilities; + +public class Account : IUpdatable { - public class Account : IUpdatable + public event EventHandler? Updated; + private void update(object? sender = null, EventArgs? e = null) + => Updated?.Invoke(this, EventArgs.Empty); + + // canonical. immutable. email or phone number + public string AccountId { get; } + + // user-friendly, non-canonical name. mutable + public string? AccountName { - public event EventHandler Updated; - private void update(object sender = null, EventArgs e = null) - => Updated?.Invoke(this, new EventArgs()); - - // canonical. immutable. email or phone number - public string AccountId { get; } - - // user-friendly, non-canonical name. mutable - public string AccountName + get => field; + set { - get => field; - set - { - if (string.IsNullOrWhiteSpace(value)) - return; - var v = value.Trim(); - if (v == field) - return; - field = v; - update(); - } + if (string.IsNullOrWhiteSpace(value)) + return; + var v = value.Trim(); + if (v == field) + return; + field = v; + update(); } - - // whether to include this account when scanning libraries. - // technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here - public bool LibraryScan - { - get => field; - set - { - if (value == field) - return; - field = value; - update(); - } - } - - /// aka: activation bytes - public string DecryptKey - { - get => field ?? ""; - set - { - var v = (value ?? "").Trim(); - if (v == field) - return; - field = v; - update(); - } - } - - public Identity IdentityTokens - { - get => field; - set - { - if (field is null && value is null) - return; - - if (field is not null) - field.Updated -= update; - - if (value is not null) - value.Updated += update; - - field = value; - update(); - } - } - - [JsonIgnore] - public Locale Locale => IdentityTokens?.Locale; - - public Account(string accountId) - { - AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim(); - } - - public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}"; - - public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}"; - private static string mask(string str) - => str is null ? "[null]" - : str == string.Empty ? "[empty]" - : str.ToMask(); } + + // whether to include this account when scanning libraries. + // technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here + public bool LibraryScan + { + get => field; + set + { + if (value == field) + return; + field = value; + update(); + } + } + + /// aka: activation bytes + [AllowNull] + public string? DecryptKey + { + get => field; + set + { + var v = (value ?? "").Trim(); + if (v == field) + return; + field = v; + update(); + } + } + + public Identity? IdentityTokens + { + get => field; + set + { + if (field is null && value is null) + return; + + if (field is not null) + field.Updated -= update; + + if (value is not null) + value.Updated += update; + + field = value; + update(); + } + } + + [JsonIgnore] + public Locale? Locale => IdentityTokens?.Locale; + + public Account(string accountId) + { + AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim(); + } + + public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}"; + + public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}"; + private static string mask(string? str) + => str is null ? "[null]" + : str == string.Empty ? "[empty]" + : str.ToMask(); } diff --git a/Source/AudibleUtilities/AccountsSettings.cs b/Source/AudibleUtilities/AccountsSettings.cs index 8d7c4ae7..c9ed0677 100644 --- a/Source/AudibleUtilities/AccountsSettings.cs +++ b/Source/AudibleUtilities/AccountsSettings.cs @@ -6,154 +6,152 @@ using AudibleApi.Authorization; using Dinah.Core; using Newtonsoft.Json; -#nullable enable -namespace AudibleUtilities +namespace AudibleUtilities; + +// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended +// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm): +// .NET : IList, IEnumerable, IList, Array +// JSON : Array (properties on the collection will not be serialized) +public class AccountsSettings : IUpdatable { - // 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended - // from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm): - // .NET : IList, IEnumerable, IList, Array - // JSON : Array (properties on the collection will not be serialized) - public class AccountsSettings : IUpdatable + public event EventHandler? Updated; + private void update(object? sender = null, EventArgs? e = null) { - public event EventHandler? Updated; - private void update(object? sender = null, EventArgs? e = null) - { - foreach (var account in Accounts) - validate(account); - update_no_validate(); - } - private void update_no_validate() => Updated?.Invoke(this, new EventArgs()); - - public AccountsSettings() { } - - // for some reason this will make the json instantiator use _accounts_json.set() - [JsonConstructor] - protected AccountsSettings(List accountsSettings) { } - - #region Accounts - private List _accounts_backing = new List(); - [JsonProperty(PropertyName = nameof(Accounts))] - private List _accounts_json - { - get => _accounts_backing; - // 'set' is only used by json deser - set - { - if (value is null) - return; - - foreach (var account in value) - _add(account); - - update_no_validate(); - } - } - - private string? _cdm; - [JsonProperty] - public string? Cdm - { - get => _cdm; - set - { - if (value is null) - return; - - _cdm = value; - update_no_validate(); - } - } - - [JsonIgnore] - public IReadOnlyList Accounts => _accounts_json.AsReadOnly(); - #endregion - - #region de/serialize - public static AccountsSettings? FromJson(string json) - => JsonConvert.DeserializeObject(json, Identity.GetJsonSerializerSettings()); - - public string ToJson(Formatting formatting = Formatting.Indented) - => JsonConvert.SerializeObject(this, formatting, Identity.GetJsonSerializerSettings()); - #endregion - - // more common naming convention alias for internal collection - public IReadOnlyList GetAll() => Accounts; - - public Account Upsert(string accountId, string? locale) - { - var acct = GetAccount(accountId, locale); - - if (acct is not null) - return acct; - - var l = Localization.Get(locale); - var id = new Identity(l); - - var account = new Account(accountId) { IdentityTokens = id }; - Add(account); - return account; - } - - public void Add(Account account) - { - _add(account); - update_no_validate(); - } - - public void _add(Account account) - { + foreach (var account in Accounts) validate(account); + update_no_validate(); + } + private void update_no_validate() => Updated?.Invoke(this, new EventArgs()); - _accounts_backing.Add(account); - account.Updated += update; - } + public AccountsSettings() { } - public Account? GetAccount(string accountId, string? locale) + // for some reason this will make the json instantiator use _accounts_json.set() + [JsonConstructor] + protected AccountsSettings(List accountsSettings) { } + + #region Accounts + private List _accounts_backing = new List(); + [JsonProperty(PropertyName = nameof(Accounts))] + private List _accounts_json + { + get => _accounts_backing; + // 'set' is only used by json deser + set { - if (locale is null) - return null; + if (value is null) + return; - return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.IdentityTokens.Locale.Name == locale); - } + foreach (var account in value) + _add(account); - public bool Delete(string accountId, string locale) - { - var acct = GetAccount(accountId, locale); - if (acct is null) - return false; - return Delete(acct); - } - - public bool Delete(Account account) - { - if (!_accounts_backing.Contains(account)) - return false; - - account.Updated -= update; - var result = _accounts_backing.Remove(account); update_no_validate(); - return result; - } - - private void validate(Account account) - { - ArgumentValidator.EnsureNotNull(account, nameof(account)); - - var accountId = account.AccountId; - var locale = account?.IdentityTokens?.Locale?.Name; - - var acct = GetAccount(accountId, locale); - - // new: ok - if (acct is null) - return; - - // same account instance: ok - if (acct == account) - return; - - // same account id + locale, different instance: bad - throw new InvalidOperationException("Cannot add an account with the same account Id and Locale"); } } + + private string? _cdm; + [JsonProperty] + public string? Cdm + { + get => _cdm; + set + { + if (value is null) + return; + + _cdm = value; + update_no_validate(); + } + } + + [JsonIgnore] + public IReadOnlyList Accounts => _accounts_json.AsReadOnly(); + #endregion + + #region de/serialize + public static AccountsSettings? FromJson(string json) + => JsonConvert.DeserializeObject(json, Identity.GetJsonSerializerSettings()); + + public string ToJson(Formatting formatting = Formatting.Indented) + => JsonConvert.SerializeObject(this, formatting, Identity.GetJsonSerializerSettings()); + #endregion + + // more common naming convention alias for internal collection + public IReadOnlyList GetAll() => Accounts; + + public Account Upsert(string accountId, string? locale) + { + var acct = GetAccount(accountId, locale); + + if (acct is not null) + return acct; + + var l = Localization.Get(locale); + var id = new Identity(l); + + var account = new Account(accountId) { IdentityTokens = id }; + Add(account); + return account; + } + + public void Add(Account account) + { + _add(account); + update_no_validate(); + } + + public void _add(Account account) + { + validate(account); + + _accounts_backing.Add(account); + account.Updated += update; + } + + public Account? GetAccount(string accountId, string? locale) + { + if (locale is null) + return null; + + return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.Locale?.Name == locale); + } + + public bool Delete(string accountId, string locale) + { + var acct = GetAccount(accountId, locale); + if (acct is null) + return false; + return Delete(acct); + } + + public bool Delete(Account account) + { + if (!_accounts_backing.Contains(account)) + return false; + + account.Updated -= update; + var result = _accounts_backing.Remove(account); + update_no_validate(); + return result; + } + + private void validate(Account account) + { + ArgumentValidator.EnsureNotNull(account, nameof(account)); + + var accountId = account.AccountId; + var locale = account?.IdentityTokens?.Locale?.Name; + + var acct = GetAccount(accountId, locale); + + // new: ok + if (acct is null) + return; + + // same account instance: ok + if (acct == account) + return; + + // same account id + locale, different instance: bad + throw new InvalidOperationException("Cannot add an account with the same account Id and Locale"); + } } diff --git a/Source/AudibleUtilities/AccountsSettingsPersister.cs b/Source/AudibleUtilities/AccountsSettingsPersister.cs index 914d2d8a..b1d09e53 100644 --- a/Source/AudibleUtilities/AccountsSettingsPersister.cs +++ b/Source/AudibleUtilities/AccountsSettingsPersister.cs @@ -3,28 +3,27 @@ using AudibleApi.Authorization; using Dinah.Core.IO; using Newtonsoft.Json; -namespace AudibleUtilities +namespace AudibleUtilities; + +public class AccountsSettingsPersister : JsonFilePersister { - public class AccountsSettingsPersister : JsonFilePersister - { - public static event EventHandler Saving; - public static event EventHandler Saved; + public static event EventHandler? Saving; + public static event EventHandler? Saved; - protected override void OnSaving() => Saving?.Invoke(null, null); - protected override void OnSaved() => Saved?.Invoke(null, null); + protected override void OnSaving() => Saving?.Invoke(null, EventArgs.Empty); + protected override void OnSaved() => Saved?.Invoke(null, EventArgs.Empty); - /// Alias for Target - public AccountsSettings AccountsSettings => Target; + /// Alias for Target + public AccountsSettings AccountsSettings => Target; - /// uses path. create file if doesn't yet exist - public AccountsSettingsPersister(AccountsSettings target, string path, string jsonPath = null) - : base(target, path, jsonPath) { } + /// uses path. create file if doesn't yet exist + public AccountsSettingsPersister(AccountsSettings target, string path, string? jsonPath = null) + : base(target, path, jsonPath) { } - /// load from existing file - public AccountsSettingsPersister(string path, string jsonPath = null) - : base(path, jsonPath) { } + /// load from existing file + public AccountsSettingsPersister(string path, string? jsonPath = null) + : base(path, jsonPath) { } - protected override JsonSerializerSettings GetSerializerSettings() - => Identity.GetJsonSerializerSettings(); - } + protected override JsonSerializerSettings GetSerializerSettings() + => Identity.GetJsonSerializerSettings(); } diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index f97a087e..ec966831 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -13,300 +13,300 @@ using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; -#nullable enable -namespace AudibleUtilities +namespace AudibleUtilities; + +/// USE THIS from within Libation. It wraps the call with correct JSONPath +public class ApiExtended { - /// USE THIS from within Libation. It wraps the call with correct JSONPath - public class ApiExtended + public static Func? LoginChoiceFactory { get; set; } + public Api Api { get; private set; } + + private const int MaxConcurrency = 10; + private const int BatchSize = 50; + + private ApiExtended(Api api) => Api = api; + + /// Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks. + public static async Task CreateAsync(Account account) { - public static Func? LoginChoiceFactory { get; set; } - public Api Api { get; private set; } + ArgumentValidator.EnsureNotNull(account, nameof(account)); + ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId)); + var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); - private const int MaxConcurrency = 10; - private const int BatchSize = 50; - - private ApiExtended(Api api) => Api = api; - - /// Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks. - public static async Task CreateAsync(Account account) + try { - ArgumentValidator.EnsureNotNull(account, nameof(account)); - ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId)); - ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); - - try + Serilog.Log.Logger.Information("{@DebugInfo}", new { - Serilog.Log.Logger.Information("{@DebugInfo}", new - { - AccountMaskedLogEntry = account.MaskedLogEntry - }); + AccountMaskedLogEntry = account.MaskedLogEntry + }); - var api = await EzApiCreator.GetApiAsync( - account.Locale, - AudibleApiStorage.AccountsSettingsFile, - account.GetIdentityTokensJsonPath()); - return new ApiExtended(api); - } - catch - { - if (LoginChoiceFactory is null) - throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api"); - - Serilog.Log.Logger.Information("{@DebugInfo}", new - { - LoginType = nameof(ILoginChoiceEager), - Account = account.MaskedLogEntry ?? "[null]", - LocaleName = account.Locale?.Name - }); - - var api = await EzApiCreator.GetApiAsync( - LoginChoiceFactory(account), - account.Locale, + var api = await EzApiCreator.GetApiAsync( + locale, AudibleApiStorage.AccountsSettingsFile, account.GetIdentityTokensJsonPath()); + return new ApiExtended(api); + } + catch + { + if (LoginChoiceFactory is null) + throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api"); - return new ApiExtended(api); + Serilog.Log.Logger.Information("{@DebugInfo}", new + { + LoginType = nameof(ILoginChoiceEager), + Account = account.MaskedLogEntry ?? "[null]", + LocaleName = locale.Name + }); + + var api = await EzApiCreator.GetApiAsync( + LoginChoiceFactory(account), + locale, + AudibleApiStorage.AccountsSettingsFile, + account.GetIdentityTokensJsonPath()); + + return new ApiExtended(api); + } + } + + private static AsyncRetryPolicy policy { get; } + = Policy.Handle() + // 2 retries == 3 total + .RetryAsync(2); + + public Task> GetLibraryValidatedAsync(LibraryOptions libraryOptions) + { + // bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed + // worse, this 1st dummy call doesn't seem to help: + // var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS }); + // i don't want to incur the cost of making a full dummy call every time because it fails sometimes + return policy.ExecuteAsync(() => getItemsAsync(libraryOptions)); + } + + /// + /// A debugging method used to simulate a library scan from a LibraryScans.zip json file. + /// Simply replace the Api call to GetLibraryItemsPagesAsync() with a call to this method. + /// + private static async IAsyncEnumerable GetItemsFromJsonFile() + { + var libraryScanJsonPath = @"Path/to/libraryscan.json"; + using var jsonFile = System.IO.File.OpenText(libraryScanJsonPath); + + var json = await JToken.ReadFromAsync(new Newtonsoft.Json.JsonTextReader(jsonFile)); + if (json?["Items"] is not JArray items) + yield break; + + foreach (var batch in items.OfType().Select(Item.FromJson).OfType().Chunk(BatchSize)) + yield return batch; + } + + private async Task> getItemsAsync(LibraryOptions libraryOptions) + { + Serilog.Log.Logger.Debug("Beginning library scan."); + + List items = new(); + var sw = Stopwatch.StartNew(); + var totalTime = TimeSpan.Zero; + using var semaphore = new SemaphoreSlim(MaxConcurrency); + + var episodeChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); + var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore); + + //Scan the library for all added books. + //Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried. + await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore)) + { + if (Configuration.Instance.ImportEpisodes) + { + var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList(); + var series = itemsBatch.Where(i => i.IsSeriesParent).ToList(); + + var parentAsins = episodes + .SelectMany(i => i.Relationships ?? []) + .Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent) + .Select(r => r.Asin) + .OfType(); + + var episodeAsins = series + .SelectMany(i => i.Relationships ?? []) + .Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode) + .Select(r => r.Asin) + .OfType(); + + foreach (var asin in parentAsins.Concat(episodeAsins)) + episodeChannel.Writer.TryWrite(asin); + + items.AddRange(episodes); + items.AddRange(series); } - } - private static AsyncRetryPolicy policy { get; } - = Policy.Handle() - // 2 retries == 3 total - .RetryAsync(2); - - public Task> GetLibraryValidatedAsync(LibraryOptions libraryOptions) - { - // bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed - // worse, this 1st dummy call doesn't seem to help: - // var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS }); - // i don't want to incur the cost of making a full dummy call every time because it fails sometimes - return policy.ExecuteAsync(() => getItemsAsync(libraryOptions)); + var booksInBatch + = itemsBatch + .Where(i => !i.IsSeriesParent && !i.IsEpisodes) + .Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles); + items.AddRange(booksInBatch); } - /// - /// A debugging method used to simulate a library scan from a LibraryScans.zip json file. - /// Simply replace the Api call to GetLibraryItemsPagesAsync() with a call to this method. - /// - private static async IAsyncEnumerable GetItemsFromJsonFile() + sw.Stop(); + totalTime += sw.Elapsed; + Serilog.Log.Logger.Debug("Library scan complete after {elappsed_ms} ms. Found {count} books and series. Waiting on series episode scans to complete.", sw.ElapsedMilliseconds, items.Count); + sw.Restart(); + + //Signal that we're done adding asins + episodeChannel.Writer.Complete(); + + //Wait for all episodes/parents to be retrived + var allEps = await batchReaderTask; + + sw.Stop(); + totalTime += sw.Elapsed; + Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count); + sw.Restart(); + + Serilog.Log.Logger.Debug("Begin indexing series episodes"); + items.AddRange(allEps); + + //Set the Item.Series info for episodes and parents. + foreach (var parent in items.Where(i => i.IsSeriesParent)) { - var libraryScanJsonPath = @"Path/to/libraryscan.json"; - using var jsonFile = System.IO.File.OpenText(libraryScanJsonPath); - - var json = await JToken.ReadFromAsync(new Newtonsoft.Json.JsonTextReader(jsonFile)); - if (json?["Items"] is not JArray items) - yield break; - - foreach (var batch in items.Select(i => Item.FromJson(i as JObject)).Chunk(BatchSize)) - yield return batch; + var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true); + SetSeries(parent, children); } - private async Task> getItemsAsync(LibraryOptions libraryOptions) - { - Serilog.Log.Logger.Debug("Beginning library scan."); + int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null); + if (orphansRemoved > 0) + Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved); - List items = new(); + sw.Stop(); + totalTime += sw.Elapsed; + Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds); + Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms."); + + Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items)); + var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList(); + if (allExceptions?.Count > 0) + throw new ImportValidationException(items, allExceptions); + + return items; + } + + #region episodes and podcasts + + /// + /// Read asins from the channel and request catalog item info in batches of . Blocks until is closed. + /// + /// Input asins to batch + /// Shared semaphore to limit concurrency + /// All s of asins written to the channel. + private async Task> readAllAsinsAsync(ChannelReader channelReader, SemaphoreSlim semaphore) + { + int batchNum = 1; + List>> getTasks = new(); + + while (await channelReader.WaitToReadAsync()) + { + List asins = new(); + + while (asins.Count < BatchSize && await channelReader.WaitToReadAsync()) + { + var asin = await channelReader.ReadAsync(); + + if (!asins.Contains(asin)) + asins.Add(asin); + } + await semaphore.WaitAsync(); + getTasks.Add(getProductsAsync(batchNum++, asins, semaphore)); + } + + var completed = await Task.WhenAll(getTasks); + //We only want Series parents and Series episodes. Exclude other relationship types (e.g. 'season') + return completed.SelectMany(l => l).Where(i => i.IsSeriesParent || i.IsEpisodes).ToList(); + } + + private async Task> getProductsAsync(int batchNum, List asins, SemaphoreSlim semaphore) + { + Serilog.Log.Logger.Debug($"Batch {batchNum} Begin: Fetching {asins.Count} asins"); + try + { var sw = Stopwatch.StartNew(); - var totalTime = TimeSpan.Zero; - using var semaphore = new SemaphoreSlim(MaxConcurrency); - - var episodeChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); - var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore); - - //Scan the library for all added books. - //Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried. - await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore)) - { - if (Configuration.Instance.ImportEpisodes) - { - var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList(); - var series = itemsBatch.Where(i => i.IsSeriesParent).ToList(); - - var parentAsins = episodes - .SelectMany(i => i.Relationships) - .Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent) - .Select(r => r.Asin); - - var episodeAsins = series - .SelectMany(i => i.Relationships) - .Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode) - .Select(r => r.Asin); - - foreach (var asin in parentAsins.Concat(episodeAsins)) - episodeChannel.Writer.TryWrite(asin); - - items.AddRange(episodes); - items.AddRange(series); - } - - var booksInBatch - = itemsBatch - .Where(i => !i.IsSeriesParent && !i.IsEpisodes) - .Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles); - items.AddRange(booksInBatch); - } - + var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media + | CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc + | CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview + | CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series + | CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs); sw.Stop(); - totalTime += sw.Elapsed; - Serilog.Log.Logger.Debug("Library scan complete after {elappsed_ms} ms. Found {count} books and series. Waiting on series episode scans to complete.", sw.ElapsedMilliseconds, items.Count); - sw.Restart(); - //Signal that we're done adding asins - episodeChannel.Writer.Complete(); - - //Wait for all episodes/parents to be retrived - var allEps = await batchReaderTask; - - sw.Stop(); - totalTime += sw.Elapsed; - Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count); - sw.Restart(); - - Serilog.Log.Logger.Debug("Begin indexing series episodes"); - items.AddRange(allEps); - - //Set the Item.Series info for episodes and parents. - foreach (var parent in items.Where(i => i.IsSeriesParent)) - { - var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin)); - SetSeries(parent, children); - } - - int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null); - if (orphansRemoved > 0) - Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved); - - sw.Stop(); - totalTime += sw.Elapsed; - Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds); - Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms."); - - Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items)); - var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList(); - if (allExceptions?.Count > 0) - throw new ImportValidationException(items, allExceptions); + Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms"); return items; } - - #region episodes and podcasts - - /// - /// Read asins from the channel and request catalog item info in batches of . Blocks until is closed. - /// - /// Input asins to batch - /// Shared semaphore to limit concurrency - /// All s of asins written to the channel. - private async Task> readAllAsinsAsync(ChannelReader channelReader, SemaphoreSlim semaphore) + catch (Exception ex) { - int batchNum = 1; - List>> getTasks = new(); + Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins }); + throw; + } + finally { semaphore.Release(); } + } - while (await channelReader.WaitToReadAsync()) + public static void SetSeries(Item parent, IEnumerable children) + { + ArgumentValidator.EnsureNotNull(parent, nameof(parent)); + ArgumentValidator.EnsureNotNull(children, nameof(children)); + + //A series parent will always have exactly 1 Series + parent.Series = new[] + { + new Series { - List asins = new(); - - while (asins.Count < BatchSize && await channelReader.WaitToReadAsync()) - { - var asin = await channelReader.ReadAsync(); - - if (!asins.Contains(asin)) - asins.Add(asin); - } - await semaphore.WaitAsync(); - getTasks.Add(getProductsAsync(batchNum++, asins, semaphore)); + Asin = parent.Asin, + Sequence = "-1", + Title = parent.TitleWithSubtitle } + }; - var completed = await Task.WhenAll(getTasks); - //We only want Series parents and Series episodes. Explude other relationship types (e.g. 'season') - return completed.SelectMany(l => l).Where(i => i.IsSeriesParent || i.IsEpisodes).ToList(); + if (parent.PurchaseDate == default) + { + parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default); + + if (parent.PurchaseDate == default) + { + Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent); + parent.PurchaseDate = DateTimeOffset.UtcNow; + } } - private async Task> getProductsAsync(int batchNum, List asins, SemaphoreSlim semaphore) + int lastEpNum = -1, dupeCount = 0; + foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime)) { - Serilog.Log.Logger.Debug($"Batch {batchNum} Begin: Fetching {asins.Count} asins"); - try + string sequence; + if (child.EpisodeNumber is null) { - var sw = Stopwatch.StartNew(); - var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media - | CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc - | CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview - | CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series - | CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs); - sw.Stop(); - - Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms"); - - return items; + // This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible + sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0"; } - catch (Exception ex) + else { - Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins }); - throw; + //multipart episodes may have the same episode number + if (child.EpisodeNumber == lastEpNum) + dupeCount++; + else + lastEpNum = child.EpisodeNumber.Value; + + sequence = (lastEpNum + dupeCount).ToString(); } - finally { semaphore.Release(); } - } - public static void SetSeries(Item parent, IEnumerable children) - { - ArgumentValidator.EnsureNotNull(parent, nameof(parent)); - ArgumentValidator.EnsureNotNull(children, nameof(children)); - - //A series parent will always have exactly 1 Series - parent.Series = new[] + // use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime + child.PurchaseDate = parent.PurchaseDate; + // parent is essentially a series + child.Series = new[] { new Series { Asin = parent.Asin, - Sequence = "-1", + Sequence = sequence, Title = parent.TitleWithSubtitle } }; - - if (parent.PurchaseDate == default) - { - parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default); - - if (parent.PurchaseDate == default) - { - Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent); - parent.PurchaseDate = DateTimeOffset.UtcNow; - } - } - - int lastEpNum = -1, dupeCount = 0; - foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime)) - { - string sequence; - if (child.EpisodeNumber is null) - { - // This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible - sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0"; - } - else - { - //multipart episodes may have the same episode number - if (child.EpisodeNumber == lastEpNum) - dupeCount++; - else - lastEpNum = child.EpisodeNumber.Value; - - sequence = (lastEpNum + dupeCount).ToString(); - } - - // use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime - child.PurchaseDate = parent.PurchaseDate; - // parent is essentially a series - child.Series = new[] - { - new Series - { - Asin = parent.Asin, - Sequence = sequence, - Title = parent.TitleWithSubtitle - } - }; - } } - #endregion } + #endregion } diff --git a/Source/AudibleUtilities/AudibleApiSanitizers.cs b/Source/AudibleUtilities/AudibleApiSanitizers.cs index 48e602b0..94248c33 100644 --- a/Source/AudibleUtilities/AudibleApiSanitizers.cs +++ b/Source/AudibleUtilities/AudibleApiSanitizers.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; -#nullable enable namespace AudibleUtilities; public interface ISanitizer diff --git a/Source/AudibleUtilities/AudibleApiStorage.cs b/Source/AudibleUtilities/AudibleApiStorage.cs index f0d07005..7e98486a 100644 --- a/Source/AudibleUtilities/AudibleApiStorage.cs +++ b/Source/AudibleUtilities/AudibleApiStorage.cs @@ -3,84 +3,83 @@ using System.IO; using LibationFileManager; using Newtonsoft.Json; -namespace AudibleUtilities +namespace AudibleUtilities; + +public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs { - public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs + /// + /// Create a new, empty file if true, otherwise throw + /// + public bool Handled { get; set; } + /// + /// The file path of the AccountsSettings.json file + /// + public string SettingsFilePath { get; } + + public AccountSettingsLoadErrorEventArgs(string path, Exception exception) + : base(exception) { - /// - /// Create a new, empty file if true, otherwise throw - /// - public bool Handled { get; set; } - /// - /// The file path of the AccountsSettings.json file - /// - public string SettingsFilePath { get; } - - public AccountSettingsLoadErrorEventArgs(string path, Exception exception) - : base(exception) - { - SettingsFilePath = path; - } - } - - public static class AudibleApiStorage - { - public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json"); - - public static event EventHandler LoadError; - - public static void EnsureAccountsSettingsFileExists() - { - // saves. BEWARE: this will overwrite an existing file - if (!File.Exists(AccountsSettingsFile)) - { - //Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved - //are not fired. There's no need to fire those events on an empty AccountsSettings file. - var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings(); - File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings)); - } - } - - /// If you use this, be a good citizen and DISPOSE of it - public static AccountsSettingsPersister GetAccountsSettingsPersister() - { - try - { - return new AccountsSettingsPersister(AccountsSettingsFile); - } - catch (Exception ex) - { - var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex); - LoadError?.Invoke(null, args); - if (args.Handled) - return GetAccountsSettingsPersister(); - throw; - } - } - - public static string GetIdentityTokensJsonPath(this Account account) - => GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name); - public static string GetIdentityTokensJsonPath(string username, string localeName) - { - var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username)); - var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName)); - - return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens"; - } - private static string trimSurroundingQuotes(string str) - { - // SubString algo is better than .Trim("\"") - // orig string " - // json string "\"" - // Eg: - // => str.Trim("\"") - // output \ - // vs - // => str.Substring(1, str.Length - 2) - // output \" - // also works with surrounding single quotes - - return str.Substring(1, str.Length - 2); - } + SettingsFilePath = path; + } +} + +public static class AudibleApiStorage +{ + public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json"); + + public static event EventHandler? LoadError; + + public static void EnsureAccountsSettingsFileExists() + { + // saves. BEWARE: this will overwrite an existing file + if (!File.Exists(AccountsSettingsFile)) + { + //Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved + //are not fired. There's no need to fire those events on an empty AccountsSettings file. + var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings(); + File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings)); + } + } + + /// If you use this, be a good citizen and DISPOSE of it + public static AccountsSettingsPersister GetAccountsSettingsPersister() + { + try + { + return new AccountsSettingsPersister(AccountsSettingsFile); + } + catch (Exception ex) + { + var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex); + LoadError?.Invoke(null, args); + if (args.Handled) + return GetAccountsSettingsPersister(); + throw; + } + } + + public static string GetIdentityTokensJsonPath(this Account account) + => GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name); + public static string GetIdentityTokensJsonPath(string username, string? localeName) + { + var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username)); + var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName)); + + return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens"; + } + private static string trimSurroundingQuotes(string str) + { + // SubString algo is better than .Trim("\"") + // orig string " + // json string "\"" + // Eg: + // => str.Trim("\"") + // output \ + // vs + // => str.Substring(1, str.Length - 2) + // output \" + // also works with surrounding single quotes + + return str.Substring(1, str.Length - 2); } } diff --git a/Source/AudibleUtilities/AudibleApiValidators.cs b/Source/AudibleUtilities/AudibleApiValidators.cs index 495083be..9e8d6e30 100644 --- a/Source/AudibleUtilities/AudibleApiValidators.cs +++ b/Source/AudibleUtilities/AudibleApiValidators.cs @@ -3,90 +3,89 @@ using System.Collections.Generic; using System.Linq; using AudibleApi.Common; -namespace AudibleUtilities +namespace AudibleUtilities; + +public interface IValidator { - public interface IValidator + IEnumerable Validate(IEnumerable items); + + public static IValidator[] GetAllValidators() => [ + new LibraryValidator(), + new BookValidator(), + new CategoryValidator(), + new SeriesValidator(), + ]; +} + +/// +/// To be used when no validation is desired +/// +public class ClearValidator : IValidator +{ + public IEnumerable Validate(IEnumerable items) => []; +} +public class LibraryValidator : IValidator +{ + public IEnumerable Validate(IEnumerable items) { - IEnumerable Validate(IEnumerable items); + var exceptions = new List(); - public static IValidator[] GetAllValidators() => [ - new LibraryValidator(), - new BookValidator(), - new CategoryValidator(), - new SeriesValidator(), - ]; - } + if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId))) + exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items))); + //// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date' + //if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1))) + // exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items))); - /// - /// To be used when no validation is desired - /// - public class ClearValidator : IValidator - { - public IEnumerable Validate(IEnumerable items) => []; - } - public class LibraryValidator : IValidator - { - public IEnumerable Validate(IEnumerable items) - { - var exceptions = new List(); - - if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId))) - exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items))); - //// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date' - //if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1))) - // exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items))); - - return exceptions; - } - } - public class BookValidator : IValidator - { - public IEnumerable Validate(IEnumerable items) - { - var exceptions = new List(); - - // a book having no authors is rare but allowed - - if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId))) - exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items))); - - // this can happen with podcast episodes - foreach (var i in items.Where(i => string.IsNullOrWhiteSpace(i.Title))) - i.Title = "[blank title]"; - - return exceptions; - } - } - public class CategoryValidator : IValidator - { - public IEnumerable Validate(IEnumerable items) - { - var exceptions = new List(); - - var distinct = items.GetCategoriesDistinct(); - if (distinct.Any(s => s.CategoryId is null)) - exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryId)}", nameof(items))); - if (distinct.Any(s => s.CategoryName is null)) - exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryName)}", nameof(items))); - - return exceptions; - } - } - public class SeriesValidator : IValidator - { - public IEnumerable Validate(IEnumerable items) - { - var exceptions = new List(); - - var distinct = items.GetSeriesDistinct(); - if (distinct.Any(s => s.SeriesId is null)) - exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items))); - - //// unfortunately, an actual user has a series with no name - //if (distinct.Any(s => s.SeriesName is null)) - // exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items))); - - return exceptions; - } + return exceptions; + } +} +public class BookValidator : IValidator +{ + public IEnumerable Validate(IEnumerable items) + { + var exceptions = new List(); + + // a book having no authors is rare but allowed + + if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId))) + exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items))); + + // this can happen with podcast episodes + foreach (var i in items.Where(i => string.IsNullOrWhiteSpace(i.Title))) + i.Title = "[blank title]"; + + return exceptions; + } +} +public class CategoryValidator : IValidator +{ + public IEnumerable Validate(IEnumerable items) + { + var exceptions = new List(); + + var distinct = items.GetCategoriesDistinct(); + if (distinct.Any(s => s.CategoryId is null)) + exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryId)}", nameof(items))); + if (distinct.Any(s => s.CategoryName is null)) + exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Categories)} with null {nameof(Ladder.CategoryName)}", nameof(items))); + + return exceptions; + } +} +public class SeriesValidator : IValidator +{ + public IEnumerable Validate(IEnumerable items) + { + var exceptions = new List(); + + var distinct = items.GetSeriesDistinct(); + if (distinct.Any(s => s.SeriesId is null)) + exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items))); + + //// unfortunately, an actual user has a series with no name + //if (distinct.Any(s => s.SeriesName is null)) + // exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items))); + + return exceptions; } } diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 243a0445..62adf3a0 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -2,11 +2,12 @@ net10.0 + enable - - + + diff --git a/Source/AudibleUtilities/ImportValidationException.cs b/Source/AudibleUtilities/ImportValidationException.cs index 66db8574..3f70600a 100644 --- a/Source/AudibleUtilities/ImportValidationException.cs +++ b/Source/AudibleUtilities/ImportValidationException.cs @@ -2,14 +2,13 @@ using System; using System.Collections.Generic; -namespace AudibleUtilities +namespace AudibleUtilities; + +public class ImportValidationException : AggregateException { - public class ImportValidationException : AggregateException + public List Items { get; } + public ImportValidationException(List items, IEnumerable exceptions) : base(exceptions) { - public List Items { get; } - public ImportValidationException(List items, IEnumerable exceptions) : base(exceptions) - { - Items = items; - } + Items = items; } } diff --git a/Source/AudibleUtilities/Mkb79Auth.cs b/Source/AudibleUtilities/Mkb79Auth.cs index 99f9aa82..3cbdb696 100644 --- a/Source/AudibleUtilities/Mkb79Auth.cs +++ b/Source/AudibleUtilities/Mkb79Auth.cs @@ -9,197 +9,202 @@ using Dinah.Core; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace AudibleUtilities +namespace AudibleUtilities; + +public partial class Mkb79Auth : IIdentityMaintainer { - public partial class Mkb79Auth : IIdentityMaintainer + [JsonProperty("website_cookies")] + private JObject? _websiteCookies { get; set; } + + [JsonProperty("adp_token")] + public string? AdpToken { get; private set; } + + [JsonProperty("access_token")] + public string? AccessToken { get; private set; } + + [JsonProperty("refresh_token")] + public string? RefreshToken { get; private set; } + + [JsonProperty("device_private_key")] + public string? DevicePrivateKey { get; private set; } + + [JsonProperty("store_authentication_cookie")] + private JObject? _storeAuthenticationCookie { get; set; } + + [JsonProperty("device_info")] + public DeviceInfo? DeviceInfo { get; private set; } + + [JsonProperty("customer_info")] + public CustomerInfo? CustomerInfo { get; private set; } + + [JsonProperty("expires")] + private double _expires { get; set; } + + [JsonProperty("locale_code")] + public string? LocaleCode { get; private set; } + + [JsonProperty("with_username")] + public bool WithUsername { get; private set; } + + [JsonProperty("activation_bytes")] + public string? ActivationBytes { get; private set; } + + [JsonIgnore] + public Dictionary? WebsiteCookies { - [JsonProperty("website_cookies")] - private JObject _websiteCookies { get; set; } - - [JsonProperty("adp_token")] - public string AdpToken { get; private set; } - - [JsonProperty("access_token")] - public string AccessToken { get; private set; } - - [JsonProperty("refresh_token")] - public string RefreshToken { get; private set; } - - [JsonProperty("device_private_key")] - public string DevicePrivateKey { get; private set; } - - [JsonProperty("store_authentication_cookie")] - private JObject _storeAuthenticationCookie { get; set; } - - [JsonProperty("device_info")] - public DeviceInfo DeviceInfo { get; private set; } - - [JsonProperty("customer_info")] - public CustomerInfo CustomerInfo { get; private set; } - - [JsonProperty("expires")] - private double _expires { get; set; } - - [JsonProperty("locale_code")] - public string LocaleCode { get; private set; } - - [JsonProperty("with_username")] - public bool WithUsername { get; private set; } - - [JsonProperty("activation_bytes")] - public string ActivationBytes { get; private set; } - - [JsonIgnore] - public Dictionary WebsiteCookies - { - get => _websiteCookies.ToObject>(); - private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings)); - } - - [JsonIgnore] - public string StoreAuthenticationCookie - { - get => _storeAuthenticationCookie.ToObject>()["cookie"]; - private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary() { { "cookie", value } }, Converter.Settings)); - } - - [JsonIgnore] - public DateTime AccessTokenExpires - { - get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime; - private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d; - } - - [JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime(); - [JsonIgnore] - public Locale Locale => Localization.Locales.Where(l => l.WithUsername == WithUsername).Single(l => l.CountryCode == LocaleCode); - [JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber; - [JsonIgnore] public string DeviceType => DeviceInfo.DeviceType; - [JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId; - - public Task GetAccessTokenAsync() - => Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires)); - - public Task GetAdpTokenAsync() - => Task.FromResult(new AdpToken(AdpToken)); - - public Task GetPrivateKeyAsync() - => Task.FromResult(new PrivateKey(DevicePrivateKey)); + get => _websiteCookies?.ToObject>(); + private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings)); } - public partial class CustomerInfo + [JsonIgnore] + public string? StoreAuthenticationCookie { - [JsonProperty("account_pool")] - public string AccountPool { get; set; } - - [JsonProperty("user_id")] - public string UserId { get; set; } - - [JsonProperty("home_region")] - public string HomeRegion { get; set; } - - [JsonProperty("name")] - public string Name { get; set; } - - [JsonProperty("given_name")] - public string GivenName { get; set; } + get => _storeAuthenticationCookie?.ToObject>()?["cookie"]; + private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary() { { "cookie", value ?? "" } }, Converter.Settings)); } - public partial class DeviceInfo + [JsonIgnore] + public DateTime AccessTokenExpires { - [JsonProperty("device_name")] - public string DeviceName { get; set; } - - [JsonProperty("device_serial_number")] - public string DeviceSerialNumber { get; set; } - - [JsonProperty("device_type")] - public string DeviceType { get; set; } + get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime; + private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d; } - public partial class Mkb79Auth - { - public static Mkb79Auth FromJson(string json) - => JsonConvert.DeserializeObject(json, Converter.Settings); + [JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime(); + [JsonIgnore] + public Locale Locale => Localization.Locales.Where(l => l.WithUsername == WithUsername).Single(l => l.CountryCode == LocaleCode); + [JsonIgnore] public string? DeviceSerialNumber => DeviceInfo?.DeviceSerialNumber; + [JsonIgnore] public string? DeviceType => DeviceInfo?.DeviceType; + [JsonIgnore] public string? AmazonAccountId => CustomerInfo?.UserId; - public string ToJson() - => JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented); + public Task GetAccessTokenAsync() + => AccessToken is null ? Task.FromResult((AccessToken?)null) : Task.FromResult((AccessToken?)new AccessToken(AccessToken, AccessTokenExpires)); - public async Task ToAccountAsync() - { - var refreshToken = new RefreshToken(RefreshToken); + public Task GetAdpTokenAsync() + => AdpToken is null ? Task.FromResult((AdpToken?)null) : Task.FromResult((AdpToken?)new AdpToken(AdpToken)); - var authorize = new Authorize(Locale); - var newToken = await authorize.RefreshAccessTokenAsync(refreshToken); - AccessToken = newToken.TokenValue; - AccessTokenExpires = newToken.Expires; - - var api = new Api(this); - var email = await api.GetEmailAsync(); - var account = new Account(email) - { - DecryptKey = ActivationBytes, - AccountName = $"{email} - {Locale.Name}", - IdentityTokens = new Identity(Locale) - }; - - account.IdentityTokens.Update( - await GetPrivateKeyAsync(), - await GetAdpTokenAsync(), - await GetAccessTokenAsync(), - refreshToken, - WebsiteCookies.Select(c => new KeyValuePair(c.Key, c.Value)), - DeviceSerialNumber, - DeviceType, - AmazonAccountId, - DeviceInfo.DeviceName, - StoreAuthenticationCookie); - - return account; - } - - public static Mkb79Auth FromAccount(Account account) - => new() - { - AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue, - ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey, - AdpToken = account.IdentityTokens.AdpToken.Value, - CustomerInfo = new CustomerInfo - { - AccountPool = "Amazon", - GivenName = string.Empty, - HomeRegion = "NA", - Name = string.Empty, - UserId = account.IdentityTokens.AmazonAccountId - }, - DeviceInfo = new DeviceInfo - { - DeviceName = account.IdentityTokens.DeviceName, - DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber, - DeviceType = account.IdentityTokens.DeviceType, - }, - DevicePrivateKey = account.IdentityTokens.PrivateKey, - AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires, - LocaleCode = account.Locale.CountryCode, - WithUsername = account.Locale.WithUsername, - RefreshToken = account.IdentityTokens.RefreshToken.Value, - StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie, - WebsiteCookies = new(account.IdentityTokens.Cookies), - }; - } - - public static class Serialize - { - public static string ToJson(this Mkb79Auth self) - => JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented); - } - - internal static class Converter - { - public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings - { - MetadataPropertyHandling = MetadataPropertyHandling.Ignore, - DateParseHandling = DateParseHandling.None, - }; - } + public Task GetPrivateKeyAsync() + => DevicePrivateKey is null ? Task.FromResult((PrivateKey?)null) : Task.FromResult((PrivateKey?)new PrivateKey(DevicePrivateKey)); +} + +public partial class CustomerInfo +{ + [JsonProperty("account_pool")] + public string? AccountPool { get; set; } + + [JsonProperty("user_id")] + public string? UserId { get; set; } + + [JsonProperty("home_region")] + public string? HomeRegion { get; set; } + + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("given_name")] + public string? GivenName { get; set; } +} + +public partial class DeviceInfo +{ + [JsonProperty("device_name")] + public string? DeviceName { get; set; } + + [JsonProperty("device_serial_number")] + public string? DeviceSerialNumber { get; set; } + + [JsonProperty("device_type")] + public string? DeviceType { get; set; } +} + +public partial class Mkb79Auth +{ + public static Mkb79Auth? FromJson(string json) + => JsonConvert.DeserializeObject(json, Converter.Settings); + + public string ToJson() + => JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented); + + public async Task ToAccountAsync() + { + if (RefreshToken is null) + throw new InvalidOperationException("Cannot create Account from Mkb79Auth without a Refresh Token."); + if (await GetAdpTokenAsync() is not { } adpToken) + throw new InvalidOperationException("Cannot create Account from Mkb79Auth without an ADP Token."); + if (await GetPrivateKeyAsync() is not { } privateKey) + throw new InvalidOperationException("Cannot create Account from Mkb79Auth without a Private Key."); + var refreshToken = new RefreshToken(RefreshToken); + + var authorize = new Authorize(Locale); + var newToken = await authorize.RefreshAccessTokenAsync(refreshToken); + AccessToken = newToken.TokenValue; + AccessTokenExpires = newToken.Expires; + + var api = new Api(this); + var email = await api.GetEmailAsync(); + var account = new Account(email) + { + DecryptKey = ActivationBytes, + AccountName = $"{email} - {Locale.Name}", + IdentityTokens = new Identity(Locale) + }; + + account.IdentityTokens.Update( + privateKey, + adpToken, + newToken, + refreshToken, + WebsiteCookies?.Select(c => new KeyValuePair(c.Key, c.Value)), + DeviceSerialNumber, + DeviceType, + AmazonAccountId, + DeviceInfo?.DeviceName, + StoreAuthenticationCookie); + + return account; + } + + public static Mkb79Auth FromAccount(Account account) + => new() + { + AccessToken = account.IdentityTokens?.ExistingAccessToken.TokenValue, + ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey, + AdpToken = account.IdentityTokens?.AdpToken?.Value, + CustomerInfo = new CustomerInfo + { + AccountPool = "Amazon", + GivenName = string.Empty, + HomeRegion = "NA", + Name = string.Empty, + UserId = account.IdentityTokens?.AmazonAccountId + }, + DeviceInfo = new DeviceInfo + { + DeviceName = account.IdentityTokens?.DeviceName, + DeviceSerialNumber = account.IdentityTokens?.DeviceSerialNumber, + DeviceType = account.IdentityTokens?.DeviceType, + }, + DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Value, + AccessTokenExpires = account.IdentityTokens?.ExistingAccessToken.Expires ?? default, + LocaleCode = account.Locale?.CountryCode, + WithUsername = account.Locale?.WithUsername ?? false, + RefreshToken = account.IdentityTokens?.RefreshToken?.Value, + StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie, + WebsiteCookies = new(account.IdentityTokens?.Cookies ?? []), + }; +} + +public static class Serialize +{ + public static string ToJson(this Mkb79Auth self) + => JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented); +} + +internal static class Converter +{ + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + }; } diff --git a/Source/AudibleUtilities/Widevine/Cdm.Api.cs b/Source/AudibleUtilities/Widevine/Cdm.Api.cs index 2ef9769c..0c887a92 100644 --- a/Source/AudibleUtilities/Widevine/Cdm.Api.cs +++ b/Source/AudibleUtilities/Widevine/Cdm.Api.cs @@ -23,7 +23,7 @@ public partial class Cdm using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); //Check if there are any Android accounts. If not, we can't use Widevine. - if (!persister.Target.Accounts.Any(a => a.IdentityTokens.DeviceType == Resources.DeviceType)) + if (!persister.Target.Accounts.Any(a => a.IdentityTokens?.DeviceType == Resources.DeviceType)) return null; if (!string.IsNullOrEmpty(persister.Target.Cdm)) @@ -49,7 +49,7 @@ public partial class Cdm //try to get a CDM file for any account that's registered as an android device. //CDMs are not account-specific, so it doesn't matter which account we're successful with. - foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens.DeviceType == Resources.DeviceType)) + foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens?.DeviceType == Resources.DeviceType)) { try { @@ -174,7 +174,13 @@ public partial class Cdm { const string ACCOUNT_INFO_PATH = "/1.0/account/information"; + if (account?.Locale is null) + throw new ArgumentException("Account does not have a valid locale.", nameof(account)); + if (account.IdentityTokens?.AdpToken is null || account.IdentityTokens.PrivateKey is null) + throw new ArgumentException("Account does not have valid identity tokens.", nameof(account)); + var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH); + message.SignRequest( DateTime.UtcNow, account.IdentityTokens.AdpToken, diff --git a/Source/AudibleUtilities/Widevine/Cdm.cs b/Source/AudibleUtilities/Widevine/Cdm.cs index 99eecf3e..631eb4c2 100644 --- a/Source/AudibleUtilities/Widevine/Cdm.cs +++ b/Source/AudibleUtilities/Widevine/Cdm.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Security.Cryptography; using System.Text; -#nullable enable namespace AudibleUtilities.Widevine; public enum KeyType @@ -40,7 +39,7 @@ public enum KeyType public interface ISession : IDisposable { - string? GetLicenseChallenge(MpegDash dash); + string GetLicenseChallenge(MpegDash dash); WidevineKey[] ParseLicense(string licenseMessage); } @@ -107,10 +106,10 @@ public partial class Cdm Cdm.Sessions.TryRemove(Id, out var session); } - public string? GetLicenseChallenge(MpegDash dash) + public string GetLicenseChallenge(MpegDash dash) { if (!dash.TryGetPssh(Cdm.WidevineContentProtection, out var pssh)) - return null; + throw new InvalidDataException("No Widevine PSSH found in DASH"); var licRequest = new LicenseRequest { diff --git a/Source/AudibleUtilities/Widevine/Device.cs b/Source/AudibleUtilities/Widevine/Device.cs index 9fa911c2..0fed4e8c 100644 --- a/Source/AudibleUtilities/Widevine/Device.cs +++ b/Source/AudibleUtilities/Widevine/Device.cs @@ -3,7 +3,6 @@ using System.IO; using System.Numerics; using System.Security.Cryptography; -#nullable enable namespace AudibleUtilities.Widevine; internal enum DeviceTypes : byte diff --git a/Source/AudibleUtilities/Widevine/Extensions.cs b/Source/AudibleUtilities/Widevine/Extensions.cs index 8b464382..ca40f524 100644 --- a/Source/AudibleUtilities/Widevine/Extensions.cs +++ b/Source/AudibleUtilities/Widevine/Extensions.cs @@ -1,6 +1,5 @@ using System; -#nullable enable namespace AudibleUtilities.Widevine; internal static class Extensions diff --git a/Source/AudibleUtilities/Widevine/MpegDash.cs b/Source/AudibleUtilities/Widevine/MpegDash.cs index 96bd0c42..4f279d90 100644 --- a/Source/AudibleUtilities/Widevine/MpegDash.cs +++ b/Source/AudibleUtilities/Widevine/MpegDash.cs @@ -6,7 +6,6 @@ using System.Xml; using System.Xml.Linq; using System.Xml.XPath; -#nullable enable namespace AudibleUtilities.Widevine; public class MpegDash diff --git a/Source/DataLayer.Postgres/Migrations/20260205171041_MakeDbNullable.Designer.cs b/Source/DataLayer.Postgres/Migrations/20260205171041_MakeDbNullable.Designer.cs new file mode 100644 index 00000000..5b312299 --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260205171041_MakeDbNullable.Designer.cs @@ -0,0 +1,508 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DataLayer.Postgres.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20260205171041_MakeDbNullable")] + partial class MakeDbNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.Property("_categoriesCategoryId") + .HasColumnType("integer"); + + b.Property("_categoryLaddersCategoryLadderId") + .HasColumnType("integer"); + + b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId"); + + b.HasIndex("_categoryLaddersCategoryLadderId"); + + b.ToTable("CategoryCategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BookId")); + + b.Property("AudibleProductId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .HasColumnType("integer"); + + b.Property("DatePublished") + .HasColumnType("timestamp without time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsAbridged") + .HasColumnType("boolean"); + + b.Property("IsSpatial") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LengthInMinutes") + .HasColumnType("integer"); + + b.Property("Locale") + .IsRequired() + .HasColumnType("text"); + + b.Property("PictureId") + .HasColumnType("text"); + + b.Property("PictureLarge") + .HasColumnType("text"); + + b.Property("Subtitle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.Property("BookId") + .HasColumnType("integer"); + + b.Property("CategoryLadderId") + .HasColumnType("integer"); + + b.HasKey("BookId", "CategoryLadderId"); + + b.HasIndex("BookId"); + + b.HasIndex("CategoryLadderId"); + + b.ToTable("BookCategory"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId") + .HasColumnType("integer"); + + b.Property("ContributorId") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("smallint"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("AudibleCategoryId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryLadderId")); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ContributorId")); + + b.Property("AudibleContributorId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId") + .HasColumnType("integer"); + + b.Property("AbsentFromLastScan") + .HasColumnType("boolean"); + + b.Property("Account") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone"); + + b.Property("IncludedUntil") + .HasColumnType("timestamp without time zone"); + + b.Property("IsAudiblePlus") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SeriesId")); + + b.Property("AudibleSeriesId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId") + .HasColumnType("integer"); + + b.Property("BookId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("text"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.HasOne("DataLayer.Category", null) + .WithMany() + .HasForeignKey("_categoriesCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", null) + .WithMany() + .HasForeignKey("_categoryLaddersCategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .HasColumnType("integer"); + + b1.Property("OverallRating") + .HasColumnType("real"); + + b1.Property("PerformanceRating") + .HasColumnType("real"); + + b1.Property("StoryRating") + .HasColumnType("real"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.WithOwner() + .HasForeignKey("BookId"); + }); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("SupplementId")); + + b1.Property("BookId") + .HasColumnType("integer"); + + b1.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.Navigation("Book"); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId") + .HasColumnType("integer"); + + b1.Property("BookStatus") + .HasColumnType("integer"); + + b1.Property("IsFinished") + .HasColumnType("boolean"); + + b1.Property("LastDownloaded") + .HasColumnType("timestamp without time zone"); + + b1.Property("LastDownloadedFileVersion") + .HasColumnType("text"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("bigint"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("text"); + + b1.Property("PdfStatus") + .HasColumnType("integer"); + + b1.Property("Tags") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem", (string)null); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId") + .HasColumnType("integer"); + + b2.Property("OverallRating") + .HasColumnType("real"); + + b2.Property("PerformanceRating") + .HasColumnType("real"); + + b2.Property("StoryRating") + .HasColumnType("real"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.WithOwner() + .HasForeignKey("UserDefinedItemBookId"); + }); + + b1.Navigation("Book"); + + b1.Navigation("Rating") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("CategoriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", "CategoryLadder") + .WithMany("BooksLink") + .HasForeignKey("CategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("CategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Navigation("CategoriesLink"); + + b.Navigation("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Navigation("BooksLink"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/20260205171041_MakeDbNullable.cs b/Source/DataLayer.Postgres/Migrations/20260205171041_MakeDbNullable.cs new file mode 100644 index 00000000..d6201385 --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260205171041_MakeDbNullable.cs @@ -0,0 +1,262 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Postgres.Migrations +{ + /// + public partial class MakeDbNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Name", + table: "Categories"); + + migrationBuilder.AlterColumn( + name: "Url", + table: "Supplement", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AudibleSeriesId", + table: "Series", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Account", + table: "LibraryBooks", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Contributors", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AudibleCategoryId", + table: "Categories", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Title", + table: "Books", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Subtitle", + table: "Books", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "Books", + type: "real", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "real", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "Books", + type: "real", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "real", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "Books", + type: "real", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "real", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Locale", + table: "Books", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Books", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AudibleProductId", + table: "Books", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Url", + table: "Supplement", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "AudibleSeriesId", + table: "Series", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Account", + table: "LibraryBooks", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Contributors", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "AudibleCategoryId", + table: "Categories", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddColumn( + name: "Name", + table: "Categories", + type: "text", + nullable: true); + + migrationBuilder.AlterColumn( + name: "Title", + table: "Books", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Subtitle", + table: "Books", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "Books", + type: "real", + nullable: true, + oldClrType: typeof(float), + oldType: "real"); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "Books", + type: "real", + nullable: true, + oldClrType: typeof(float), + oldType: "real"); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "Books", + type: "real", + nullable: true, + oldClrType: typeof(float), + oldType: "real"); + + migrationBuilder.AlterColumn( + name: "Locale", + table: "Books", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Books", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "AudibleProductId", + table: "Books", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs index 23733afe..913ad025 100644 --- a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.2") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -46,6 +46,7 @@ namespace DataLayer.Postgres.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BookId")); b.Property("AudibleProductId") + .IsRequired() .HasColumnType("text"); b.Property("ContentType") @@ -55,6 +56,7 @@ namespace DataLayer.Postgres.Migrations .HasColumnType("timestamp without time zone"); b.Property("Description") + .IsRequired() .HasColumnType("text"); b.Property("IsAbridged") @@ -70,6 +72,7 @@ namespace DataLayer.Postgres.Migrations .HasColumnType("integer"); b.Property("Locale") + .IsRequired() .HasColumnType("text"); b.Property("PictureId") @@ -79,9 +82,11 @@ namespace DataLayer.Postgres.Migrations .HasColumnType("text"); b.Property("Subtitle") + .IsRequired() .HasColumnType("text"); b.Property("Title") + .IsRequired() .HasColumnType("text"); b.HasKey("BookId"); @@ -140,9 +145,7 @@ namespace DataLayer.Postgres.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); b.Property("AudibleCategoryId") - .HasColumnType("text"); - - b.Property("Name") + .IsRequired() .HasColumnType("text"); b.HasKey("CategoryId"); @@ -177,6 +180,7 @@ namespace DataLayer.Postgres.Migrations .HasColumnType("text"); b.Property("Name") + .IsRequired() .HasColumnType("text"); b.HasKey("ContributorId"); @@ -202,6 +206,7 @@ namespace DataLayer.Postgres.Migrations .HasColumnType("boolean"); b.Property("Account") + .IsRequired() .HasColumnType("text"); b.Property("DateAdded") @@ -230,6 +235,7 @@ namespace DataLayer.Postgres.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SeriesId")); b.Property("AudibleSeriesId") + .IsRequired() .HasColumnType("text"); b.Property("Name") @@ -313,6 +319,7 @@ namespace DataLayer.Postgres.Migrations .HasColumnType("integer"); b1.Property("Url") + .IsRequired() .HasColumnType("text"); b1.HasKey("SupplementId"); @@ -392,11 +399,13 @@ namespace DataLayer.Postgres.Migrations .IsRequired(); }); - b.Navigation("Rating"); + b.Navigation("Rating") + .IsRequired(); b.Navigation("Supplements"); - b.Navigation("UserDefinedItem"); + b.Navigation("UserDefinedItem") + .IsRequired(); }); modelBuilder.Entity("DataLayer.BookCategory", b => diff --git a/Source/DataLayer.Sqlite/Migrations/20260205171044_MakeDbNullable.Designer.cs b/Source/DataLayer.Sqlite/Migrations/20260205171044_MakeDbNullable.Designer.cs new file mode 100644 index 00000000..89288098 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260205171044_MakeDbNullable.Designer.cs @@ -0,0 +1,491 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20260205171044_MakeDbNullable")] + partial class MakeDbNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.Property("_categoriesCategoryId") + .HasColumnType("INTEGER"); + + b.Property("_categoryLaddersCategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId"); + + b.HasIndex("_categoryLaddersCategoryLadderId"); + + b.ToTable("CategoryCategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentType") + .HasColumnType("INTEGER"); + + b.Property("DatePublished") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsAbridged") + .HasColumnType("INTEGER"); + + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LengthInMinutes") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PictureId") + .HasColumnType("TEXT"); + + b.Property("PictureLarge") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("CategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "CategoryLadderId"); + + b.HasIndex("BookId"); + + b.HasIndex("CategoryLadderId"); + + b.ToTable("BookCategory"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("ContributorId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleCategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleContributorId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property("Account") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IncludedUntil") + .HasColumnType("TEXT"); + + b.Property("IsAudiblePlus") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleSeriesId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.HasOne("DataLayer.Category", null) + .WithMany() + .HasForeignKey("_categoriesCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", null) + .WithMany() + .HasForeignKey("_categoryLaddersCategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("OverallRating") + .HasColumnType("REAL"); + + b1.Property("PerformanceRating") + .HasColumnType("REAL"); + + b1.Property("StoryRating") + .HasColumnType("REAL"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.WithOwner() + .HasForeignKey("BookId"); + }); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.Navigation("Book"); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("BookStatus") + .HasColumnType("INTEGER"); + + b1.Property("IsFinished") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloaded") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("TEXT"); + + b1.Property("PdfStatus") + .HasColumnType("INTEGER"); + + b1.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem", (string)null); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId") + .HasColumnType("INTEGER"); + + b2.Property("OverallRating") + .HasColumnType("REAL"); + + b2.Property("PerformanceRating") + .HasColumnType("REAL"); + + b2.Property("StoryRating") + .HasColumnType("REAL"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.WithOwner() + .HasForeignKey("UserDefinedItemBookId"); + }); + + b1.Navigation("Book"); + + b1.Navigation("Rating") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("CategoriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", "CategoryLadder") + .WithMany("BooksLink") + .HasForeignKey("CategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("CategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Navigation("CategoriesLink"); + + b.Navigation("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Navigation("BooksLink"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/20260205171044_MakeDbNullable.cs b/Source/DataLayer.Sqlite/Migrations/20260205171044_MakeDbNullable.cs new file mode 100644 index 00000000..f9c0ba54 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260205171044_MakeDbNullable.cs @@ -0,0 +1,262 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class MakeDbNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Name", + table: "Categories"); + + migrationBuilder.AlterColumn( + name: "Url", + table: "Supplement", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AudibleSeriesId", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Account", + table: "LibraryBooks", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Contributors", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AudibleCategoryId", + table: "Categories", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Title", + table: "Books", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Subtitle", + table: "Books", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "Books", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "Books", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "Books", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Locale", + table: "Books", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Books", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AudibleProductId", + table: "Books", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Url", + table: "Supplement", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "AudibleSeriesId", + table: "Series", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Account", + table: "LibraryBooks", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Contributors", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "AudibleCategoryId", + table: "Categories", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "Name", + table: "Categories", + type: "TEXT", + nullable: true); + + migrationBuilder.AlterColumn( + name: "Title", + table: "Books", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Subtitle", + table: "Books", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "Books", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "Books", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "Books", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "Locale", + table: "Books", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Books", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "AudibleProductId", + table: "Books", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs index e87511a2..3c788b5e 100644 --- a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DataLayer.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); modelBuilder.Entity("CategoryCategoryLadder", b => { @@ -39,6 +39,7 @@ namespace DataLayer.Migrations .HasColumnType("INTEGER"); b.Property("AudibleProductId") + .IsRequired() .HasColumnType("TEXT"); b.Property("ContentType") @@ -48,6 +49,7 @@ namespace DataLayer.Migrations .HasColumnType("TEXT"); b.Property("Description") + .IsRequired() .HasColumnType("TEXT"); b.Property("IsAbridged") @@ -63,6 +65,7 @@ namespace DataLayer.Migrations .HasColumnType("INTEGER"); b.Property("Locale") + .IsRequired() .HasColumnType("TEXT"); b.Property("PictureId") @@ -72,9 +75,11 @@ namespace DataLayer.Migrations .HasColumnType("TEXT"); b.Property("Subtitle") + .IsRequired() .HasColumnType("TEXT"); b.Property("Title") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("BookId"); @@ -131,9 +136,7 @@ namespace DataLayer.Migrations .HasColumnType("INTEGER"); b.Property("AudibleCategoryId") - .HasColumnType("TEXT"); - - b.Property("Name") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("CategoryId"); @@ -164,6 +167,7 @@ namespace DataLayer.Migrations .HasColumnType("TEXT"); b.Property("Name") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("ContributorId"); @@ -189,6 +193,7 @@ namespace DataLayer.Migrations .HasColumnType("INTEGER"); b.Property("Account") + .IsRequired() .HasColumnType("TEXT"); b.Property("DateAdded") @@ -215,6 +220,7 @@ namespace DataLayer.Migrations .HasColumnType("INTEGER"); b.Property("AudibleSeriesId") + .IsRequired() .HasColumnType("TEXT"); b.Property("Name") @@ -296,6 +302,7 @@ namespace DataLayer.Migrations .HasColumnType("INTEGER"); b1.Property("Url") + .IsRequired() .HasColumnType("TEXT"); b1.HasKey("SupplementId"); @@ -375,11 +382,13 @@ namespace DataLayer.Migrations .IsRequired(); }); - b.Navigation("Rating"); + b.Navigation("Rating") + .IsRequired(); b.Navigation("Supplements"); - b.Navigation("UserDefinedItem"); + b.Navigation("UserDefinedItem") + .IsRequired(); }); modelBuilder.Entity("DataLayer.BookCategory", b => diff --git a/Source/DataLayer/AudioFormat.cs b/Source/DataLayer/AudioFormat.cs index 2a517677..e81f13a0 100644 --- a/Source/DataLayer/AudioFormat.cs +++ b/Source/DataLayer/AudioFormat.cs @@ -1,5 +1,4 @@ -#nullable enable -using Newtonsoft.Json; +using Newtonsoft.Json; namespace DataLayer; diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index b0d802ce..0355ba9b 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -34,7 +34,7 @@ namespace DataLayer.Configurations entity .Metadata .FindNavigation(nameof(Book.Supplements)) - .SetPropertyAccessMode(PropertyAccessMode.Field); + ?.SetPropertyAccessMode(PropertyAccessMode.Field); // owns it 1:1, store in separate table entity @@ -48,10 +48,10 @@ namespace DataLayer.Configurations b_udi.Property(udi => udi.LastDownloaded); b_udi .Property(udi => udi.LastDownloadedVersion) - .HasConversion(ver => ver.ToString(), str => Version.Parse(str)); + .HasConversion(ver => ver == null ? null : ver.ToString(), str => str == null ? null : Version.Parse(str)); b_udi .Property(udi => udi.LastDownloadedFormat) - .HasConversion(af => af.Serialize(), str => AudioFormat.Deserialize(str)); + .HasConversion(af => af == null ? 0 : af.Serialize(), str => AudioFormat.Deserialize(str)); b_udi.Property(udi => udi.LastDownloadedFileVersion); diff --git a/Source/DataLayer/Configurations/CategoryLadderConfig.cs b/Source/DataLayer/Configurations/CategoryLadderConfig.cs index aa28b885..db7c5e28 100644 --- a/Source/DataLayer/Configurations/CategoryLadderConfig.cs +++ b/Source/DataLayer/Configurations/CategoryLadderConfig.cs @@ -18,7 +18,7 @@ namespace DataLayer.Configurations entity .Metadata .FindNavigation(nameof(CategoryLadder.BooksLink)) - .SetPropertyAccessMode(PropertyAccessMode.Field); + ?.SetPropertyAccessMode(PropertyAccessMode.Field); } } } diff --git a/Source/DataLayer/Configurations/ContributorConfig.cs b/Source/DataLayer/Configurations/ContributorConfig.cs index 4b810b86..c442c620 100644 --- a/Source/DataLayer/Configurations/ContributorConfig.cs +++ b/Source/DataLayer/Configurations/ContributorConfig.cs @@ -16,7 +16,7 @@ namespace DataLayer.Configurations entity .Metadata .FindNavigation(nameof(Contributor.BooksLink)) - .SetPropertyAccessMode(PropertyAccessMode.Field); + ?.SetPropertyAccessMode(PropertyAccessMode.Field); // seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs entity.HasData(Contributor.GetEmpty()); diff --git a/Source/DataLayer/Configurations/SeriesConfig.cs b/Source/DataLayer/Configurations/SeriesConfig.cs index 70530b85..59c99561 100644 --- a/Source/DataLayer/Configurations/SeriesConfig.cs +++ b/Source/DataLayer/Configurations/SeriesConfig.cs @@ -13,7 +13,7 @@ namespace DataLayer.Configurations entity .Metadata .FindNavigation(nameof(Series.BooksLink)) - .SetPropertyAccessMode(PropertyAccessMode.Field); + ?.SetPropertyAccessMode(PropertyAccessMode.Field); } } } \ No newline at end of file diff --git a/Source/DataLayer/DataLayer.csproj b/Source/DataLayer/DataLayer.csproj index 1b362848..96662a09 100644 --- a/Source/DataLayer/DataLayer.csproj +++ b/Source/DataLayer/DataLayer.csproj @@ -2,6 +2,7 @@ net10.0 + enable diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index f93fb1ef..d627cba7 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -36,7 +36,7 @@ namespace DataLayer public string AudibleProductId { get; private set; } public string Title { get; private set; } public string Subtitle { get; private set; } - private string _titleWithSubtitle; + private string? _titleWithSubtitle; public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}"; public string Description { get; private set; } public int LengthInMinutes { get; private set; } @@ -44,14 +44,14 @@ namespace DataLayer public string Locale { get; private set; } // mutable - public string PictureId { get; set; } - public string PictureLarge { get; set; } + public string? PictureId { get; set; } + public string? PictureLarge { get; set; } // book details public bool IsAbridged { get; private set; } public bool IsSpatial { get; private set; } public DateTime? DatePublished { get; private set; } - public string Language { get; private set; } + public string? Language { get; private set; } // is owned, not optional 1:1 public UserDefinedItem UserDefinedItem { get; private set; } @@ -60,15 +60,17 @@ namespace DataLayer /// The product's aggregate community rating public Rating Rating { get; private set; } = new Rating(0, 0, 0); - // ef-ctor - private Book() { } - // non-ef ctor - /// special id class b/c it's too easy to get string order mixed up - public Book( + // ef-ctor +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private Book() { } +#pragma warning restore CS8618 + // non-ef ctor + /// special id class b/c it's too easy to get string order mixed up + public Book( AudibleProductId audibleProductId, - string title, - string subtitle, - string description, + string? title, + string? subtitle, + string? description, int lengthInMinutes, ContentType contentType, IEnumerable authors, @@ -94,8 +96,10 @@ namespace DataLayer _seriesLink = new HashSet(); _supplements = new HashSet(); - // simple assigns - UpdateTitle(title, subtitle); + // simple assigns + + Title = title?.Trim() ?? ""; + Subtitle = subtitle?.Trim() ?? ""; Description = description?.Trim() ?? ""; LengthInMinutes = lengthInMinutes; ContentType = contentType; @@ -105,7 +109,7 @@ namespace DataLayer ReplaceNarrators(narrators); } - public void UpdateTitle(string title, string subtitle) + public void UpdateTitle(string? title, string? subtitle) { Title = title?.Trim() ?? ""; Subtitle = subtitle?.Trim() ?? ""; @@ -120,32 +124,36 @@ namespace DataLayer public IEnumerable Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList(); public IEnumerable Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList(); - public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name; + public string? Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name; - public void ReplaceAuthors(IEnumerable authors, DbContext context = null) + public void ReplaceAuthors(IEnumerable authors, DbContext? context = null) => replaceContributors(authors, Role.Author, context); - public void ReplaceNarrators(IEnumerable narrators, DbContext context = null) + public void ReplaceNarrators(IEnumerable narrators, DbContext? context = null) => replaceContributors(narrators, Role.Narrator, context); - public void ReplacePublisher(Contributor publisher, DbContext context = null) + public void ReplacePublisher(Contributor publisher, DbContext? context = null) => replaceContributors(new List { publisher }, Role.Publisher, context); - private void replaceContributors(IEnumerable newContributors, Role role, DbContext context = null) + private void replaceContributors(IEnumerable newContributors, Role role, DbContext? context = null) { ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors)); // the edge cases of doing local-loaded vs remote-only got weird. just load it if (ContributorsLink is null) - getEntry(context).Collection(s => s.ContributorsLink).Load(); + { + if (context is null) + throw new ArgumentNullException(nameof(context), "A DbContext is required to load the ContributorsLink collection"); + getEntry(context).Collection(s => s.ContributorsLink).Load(); + } var isIdentical = ContributorsLink - .ByRole(role) + !.ByRole(role) .Select(c => c.Contributor) .SequenceEqual(newContributors); if (isIdentical) return; - ContributorsLink.RemoveWhere(bc => bc.Role == role); + ContributorsLink!.RemoveWhere(bc => bc.Role == role); addNewContributors(newContributors, role); } @@ -174,7 +182,7 @@ namespace DataLayer #region categories internal HashSet CategoriesLink { get; private set; } - private ReadOnlyCollection _categoriesReadOnly; + private ReadOnlyCollection? _categoriesReadOnly; public ReadOnlyCollection Categories { get @@ -196,29 +204,33 @@ namespace DataLayer #endregion #region series - private HashSet _seriesLink; - public IEnumerable SeriesLink => _seriesLink?.ToList(); + private HashSet? _seriesLink; + public IEnumerable SeriesLink => _seriesLink?.ToList() ?? []; - public void UpsertSeries(Series series, string order, DbContext context = null) + public void UpsertSeries(Series series, string? order, DbContext? context = null) { ArgumentValidator.EnsureNotNull(series, nameof(series)); // our add() is conditional upon what's already included in the collection. // therefore if not loaded, a trip is required. might as well just load it if (_seriesLink is null) + { + if (context is null) + throw new ArgumentNullException(nameof(context), "A DbContext is required to load the SeriesLink collection"); getEntry(context).Collection(s => s.SeriesLink).Load(); + } - var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series); + var singleSeriesBook = _seriesLink!.SingleOrDefault(sb => sb.Series == series); if (singleSeriesBook is null) - _seriesLink.Add(new SeriesBook(series, this, order)); + _seriesLink!.Add(new SeriesBook(series, this, order)); else singleSeriesBook.UpdateOrder(order); } #endregion #region supplements - private HashSet _supplements; - public IEnumerable Supplements => _supplements?.ToList(); + private HashSet? _supplements; + public IEnumerable Supplements => _supplements?.ToList() ?? []; public void AddSupplementDownloadUrl(string url) { @@ -227,10 +239,10 @@ namespace DataLayer ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url)); - if (_supplements.Any(s => url.EqualsInsensitive(url))) + if (_supplements?.Any(s => url.EqualsInsensitive(url)) is true) return; - _supplements.Add(new Supplement(this, url)); + _supplements?.Add(new Supplement(this, url)); UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated; } #endregion @@ -238,7 +250,7 @@ namespace DataLayer public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) => Rating.Update(overallRating, performanceRating, storyRating); - public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string language) + public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language) { // don't overwrite with default values IsAbridged |= isAbridged; diff --git a/Source/DataLayer/EfClasses/BookCategory.cs b/Source/DataLayer/EfClasses/BookCategory.cs index 34139aa8..4a776657 100644 --- a/Source/DataLayer/EfClasses/BookCategory.cs +++ b/Source/DataLayer/EfClasses/BookCategory.cs @@ -9,7 +9,9 @@ namespace DataLayer public Book Book { get; private set; } public CategoryLadder CategoryLadder { get; private set; } +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. private BookCategory() { } +#pragma warning restore CS8618 internal BookCategory(Book book, CategoryLadder categoriesList) { diff --git a/Source/DataLayer/EfClasses/BookContributor.cs b/Source/DataLayer/EfClasses/BookContributor.cs index 0d3a2764..bee651fe 100644 --- a/Source/DataLayer/EfClasses/BookContributor.cs +++ b/Source/DataLayer/EfClasses/BookContributor.cs @@ -14,8 +14,10 @@ namespace DataLayer public Book Book { get; private set; } public Contributor Contributor { get; private set; } - private BookContributor() { } - internal BookContributor(Book book, Contributor contributor, Role role, byte order) +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private BookContributor() { } +#pragma warning restore CS8618 + internal BookContributor(Book book, Contributor contributor, Role role, byte order) { ArgumentValidator.EnsureNotNull(book, nameof(book)); ArgumentValidator.EnsureNotNull(contributor, nameof(contributor)); diff --git a/Source/DataLayer/EfClasses/Category.cs b/Source/DataLayer/EfClasses/Category.cs index e4654b3b..82cb6408 100644 --- a/Source/DataLayer/EfClasses/Category.cs +++ b/Source/DataLayer/EfClasses/Category.cs @@ -3,7 +3,6 @@ using System.Collections.ObjectModel; using System.Linq; using Dinah.Core; -#nullable enable namespace DataLayer { public class AudibleCategoryId @@ -19,9 +18,9 @@ namespace DataLayer public class Category { internal int CategoryId { get; private set; } - public string? AudibleCategoryId { get; private set; } + public string AudibleCategoryId { get; } - public string? Name { get; internal set; } + public string Name { get; } internal List _categoryLadders = new(); private ReadOnlyCollection? _categoryLaddersReadOnly; @@ -35,9 +34,11 @@ namespace DataLayer } } +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. private Category() { } - /// special id class b/c it's too easy to get string order mixed up - public Category(AudibleCategoryId audibleSeriesId, string name) +#pragma warning restore CS8618 + /// special id class b/c it's too easy to get string order mixed up + public Category(AudibleCategoryId audibleSeriesId, string name) { ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId)); var id = audibleSeriesId.Id; diff --git a/Source/DataLayer/EfClasses/CategoryLadder.cs b/Source/DataLayer/EfClasses/CategoryLadder.cs index c2f6e52f..1f0efb79 100644 --- a/Source/DataLayer/EfClasses/CategoryLadder.cs +++ b/Source/DataLayer/EfClasses/CategoryLadder.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -#nullable enable namespace DataLayer { public class CategoryLadder : IEquatable diff --git a/Source/DataLayer/EfClasses/Contributor.cs b/Source/DataLayer/EfClasses/Contributor.cs index c2812f27..0dff9823 100644 --- a/Source/DataLayer/EfClasses/Contributor.cs +++ b/Source/DataLayer/EfClasses/Contributor.cs @@ -26,26 +26,29 @@ namespace DataLayer public string Name { get; private set; } private HashSet _booksLink; - public IEnumerable BooksLink => _booksLink?.ToList(); + public IEnumerable BooksLink => _booksLink?.ToList() ?? []; - public string AudibleContributorId { get; private set; } + public string? AudibleContributorId { get; private set; } - private Contributor() { } - public Contributor(string name, string audibleContributorId = null) +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private Contributor() { } +#pragma warning restore CS8618 + public Contributor(string name, string? audibleContributorId = null) { Name = ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name)); _booksLink = new HashSet(); + SetAudibleContributorId(audibleContributorId); + } + public override string ToString() => Name; + public void SetAudibleContributorId(string? audibleContributorId) + { // don't overwrite with null or whitespace but not an error if (!string.IsNullOrWhiteSpace(audibleContributorId)) AudibleContributorId = audibleContributorId; } - public override string ToString() => Name; - public void SetAudibleContributorId(string audibleContributorId) - => AudibleContributorId = audibleContributorId; - public bool IsEmpty => ContributorId == -1; } } diff --git a/Source/DataLayer/EfClasses/LibraryBook.cs b/Source/DataLayer/EfClasses/LibraryBook.cs index 2a4e9c56..84462b43 100644 --- a/Source/DataLayer/EfClasses/LibraryBook.cs +++ b/Source/DataLayer/EfClasses/LibraryBook.cs @@ -16,8 +16,10 @@ namespace DataLayer public DateTime? IncludedUntil { get; private set; } public bool IsAudiblePlus { get; set; } - private LibraryBook() { } - public LibraryBook(Book book, DateTime dateAdded, string account) +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private LibraryBook() { } +#pragma warning restore CS8618 + public LibraryBook(Book book, DateTime dateAdded, string account) { ArgumentValidator.EnsureNotNull(book, nameof(book)); ArgumentValidator.EnsureNotNull(account, nameof(account)); diff --git a/Source/DataLayer/EfClasses/Rating.cs b/Source/DataLayer/EfClasses/Rating.cs index f63f8f35..15cb113f 100644 --- a/Source/DataLayer/EfClasses/Rating.cs +++ b/Source/DataLayer/EfClasses/Rating.cs @@ -1,6 +1,5 @@ using System; -#nullable enable namespace DataLayer { /// Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable diff --git a/Source/DataLayer/EfClasses/Series.cs b/Source/DataLayer/EfClasses/Series.cs index 1c4d92a9..5db7e2c2 100644 --- a/Source/DataLayer/EfClasses/Series.cs +++ b/Source/DataLayer/EfClasses/Series.cs @@ -20,17 +20,19 @@ namespace DataLayer public string AudibleSeriesId { get; private set; } /// optional - public string Name { get; private set; } + public string? Name { get; private set; } private HashSet _booksLink; public IEnumerable BooksLink => _booksLink? .OrderBy(sb => sb.Index) - .ToList(); + .ToList() ?? []; - private Series() { } - /// special id class b/c it's too easy to get string order mixed up - public Series(AudibleSeriesId audibleSeriesId, string name = null) +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private Series() { } +#pragma warning restore CS8618 + /// special id class b/c it's too easy to get string order mixed up + public Series(AudibleSeriesId audibleSeriesId, string? name = null) { ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId)); var id = audibleSeriesId.Id; @@ -40,13 +42,13 @@ namespace DataLayer UpdateName(name); } - public void UpdateName(string name) + public void UpdateName(string? name) { // don't overwrite with null or whitespace but not an error if (!string.IsNullOrWhiteSpace(name)) Name = name; } - public override string ToString() => Name; + public override string? ToString() => Name; } } diff --git a/Source/DataLayer/EfClasses/SeriesBook.cs b/Source/DataLayer/EfClasses/SeriesBook.cs index 825d2515..07c3a0a4 100644 --- a/Source/DataLayer/EfClasses/SeriesBook.cs +++ b/Source/DataLayer/EfClasses/SeriesBook.cs @@ -7,14 +7,16 @@ namespace DataLayer internal int SeriesId { get; private set; } internal int BookId { get; private set; } - public string Order { get; private set; } + public string? Order { get; private set; } public float Index => StringLib.ExtractFirstNumber(Order); public Series Series { get; private set; } public Book Book { get; private set; } - private SeriesBook() { } - internal SeriesBook(Series series, Book book, string order) +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private SeriesBook() { } +#pragma warning restore CS8618 + internal SeriesBook(Series series, Book book, string? order) { ArgumentValidator.EnsureNotNull(series, nameof(series)); ArgumentValidator.EnsureNotNull(book, nameof(book)); @@ -24,7 +26,7 @@ namespace DataLayer Order = order; } - public void UpdateOrder(string order) + public void UpdateOrder(string? order) { if (!string.IsNullOrWhiteSpace(order)) Order = order; diff --git a/Source/DataLayer/EfClasses/Supplement.cs b/Source/DataLayer/EfClasses/Supplement.cs index 2207b3f0..c33f2315 100644 --- a/Source/DataLayer/EfClasses/Supplement.cs +++ b/Source/DataLayer/EfClasses/Supplement.cs @@ -11,8 +11,10 @@ namespace DataLayer public Book Book { get; private set; } public string Url { get; private set; } - private Supplement() { } - public Supplement(Book book, string url) +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private Supplement() { } +#pragma warning restore CS8618 + public Supplement(Book book, string url) { ArgumentValidator.EnsureNotNull(book, nameof(book)); ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url)); diff --git a/Source/DataLayer/EfClasses/UserDefinedItem.cs b/Source/DataLayer/EfClasses/UserDefinedItem.cs index db0c4188..12dad144 100644 --- a/Source/DataLayer/EfClasses/UserDefinedItem.cs +++ b/Source/DataLayer/EfClasses/UserDefinedItem.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Text.RegularExpressions; using Dinah.Core; -#nullable enable namespace DataLayer { /// diff --git a/Source/DataLayer/EntityExtensions.cs b/Source/DataLayer/EntityExtensions.cs index 55d9e1ca..3052515d 100644 --- a/Source/DataLayer/EntityExtensions.cs +++ b/Source/DataLayer/EntityExtensions.cs @@ -52,15 +52,15 @@ namespace DataLayer string getSeriesNameString(SeriesBook sb) => includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1" ? $"{sb.Series.Name} (#{sb.Order})" - : sb.Series.Name; + : sb.Series.Name ?? ""; } public string[] LowestCategoryNames() - => book.CategoriesLink?.Any() is not true ? Array.Empty() + => book.CategoriesLink?.Count is 0 or null ? [] : book .CategoriesLink .Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name) - .Where(c => c is not null) + .OfType() .Distinct() .ToArray(); @@ -72,8 +72,8 @@ namespace DataLayer .Select(c => c.Name) .ToArray(); - public string[] AllCategoryIds() - => book.CategoriesLink?.Any() is not true ? null + public string[]? AllCategoryIds() + => book.CategoriesLink?.Count is null or 0 ? null : book .CategoriesLink .SelectMany(cl => cl.CategoryLadder.Categories) diff --git a/Source/DataLayer/InstanceQueue.cs b/Source/DataLayer/InstanceQueue.cs index 4bfda40a..1620f683 100644 --- a/Source/DataLayer/InstanceQueue.cs +++ b/Source/DataLayer/InstanceQueue.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Threading; -#nullable enable namespace DataLayer; /// Notifies clients that the object is being disposed. diff --git a/Source/DataLayer/LibationContext.cs b/Source/DataLayer/LibationContext.cs index 79a7401b..8ff1223f 100644 --- a/Source/DataLayer/LibationContext.cs +++ b/Source/DataLayer/LibationContext.cs @@ -27,7 +27,7 @@ namespace DataLayer public DbSet Categories { get; private set; } public DbSet CategoryLadders { get; private set; } - public event EventHandler ObjectDisposed; + public event EventHandler? ObjectDisposed; public override void Dispose() { base.Dispose(); diff --git a/Source/DataLayer/MockLibraryBook.cs b/Source/DataLayer/MockLibraryBook.cs index 214017f0..28065857 100644 --- a/Source/DataLayer/MockLibraryBook.cs +++ b/Source/DataLayer/MockLibraryBook.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Reflection; using System.Text; -#nullable enable namespace DataLayer; public class MockLibraryBook : LibraryBook { diff --git a/Source/DataLayer/QueryObjects/BookQueries.cs b/Source/DataLayer/QueryObjects/BookQueries.cs index 81cdc162..e5e44d5d 100644 --- a/Source/DataLayer/QueryObjects/BookQueries.cs +++ b/Source/DataLayer/QueryObjects/BookQueries.cs @@ -10,13 +10,13 @@ namespace DataLayer // only library importing should directly query Book. All else should use LibraryBook public static class BookQueries { - public static Book GetBook_Flat_NoTracking(this LibationContext context, string productId) + public static Book? GetBook_Flat_NoTracking(this LibationContext context, string productId) => context .Books .AsNoTrackingWithIdentityResolution() .GetBook(productId); - public static Book GetBook(this IQueryable books, string productId) + public static Book? GetBook(this IQueryable books, string productId) => books .GetBooks() // 'Single' is more accurate but 'First' is faster and less error prone diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index bf1cfc25..57b81267 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -3,7 +3,6 @@ using System.Linq; using Dinah.Core; using Microsoft.EntityFrameworkCore; -#nullable enable namespace DataLayer; // only library importing should use tracking. All else should be NoTracking. diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index c39622f3..15676199 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -24,7 +24,7 @@ namespace DtoImporterService /// If means that all objects in the DbContext will have their property populated. /// If false, only those Books being imported were loaded, and some objects will have a null property for books not included in the import set. /// - internal bool LoadedEntireLibrary {get; private set; } + internal bool LoadedEntireLibrary { get; private set; } public BookImporter(LibationContext context) : base(context) { @@ -78,17 +78,19 @@ namespace DtoImporterService { var qtyNew = 0; - foreach (var item in importItems) + foreach (var item in importItems) + { + if (item.DtoItem.ProductId is null) + continue; + if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book)) { - if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book)) - { - book = createNewBook(item); - qtyNew++; - } - - updateBook(item, book); + book = createNewBook(item); + qtyNew++; } + updateBook(item, book); + } + return qtyNew; } @@ -99,30 +101,25 @@ namespace DtoImporterService var contentType = GetContentType(item); // absence of authors is very rare, but possible - if (!item.Authors?.Any() ?? true) - item.Authors = new[] { new Person { Name = "", Asin = null } }; + if (item.Authors?.Length is null or 0) + item.Authors = [new Person { Name = "", Asin = null }]; // nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db - var authors = item - .Authors - .DistinctBy(a => a.Name) - .Select(a => contributorImporter.Cache[a.Name]) - .ToList(); + var authors = ContributorsFromCache(item.Authors); var narrators - = item.Narrators is null || !item.Narrators.Any() + = item.Narrators?.Length is null or 0 // if no narrators listed, author is the narrator ? authors // nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db - : item - .Narrators - .DistinctBy(a => a.Name) - .Select(n => contributorImporter.Cache[n.Name]) - .ToList(); + : ContributorsFromCache(item.Narrators); Book book; try { + if (item.ProductId is null) + throw new ArgumentNullException(nameof(item.ProductId), "ProductId is null when trying to create new Book."); + book = DbContext.Books.Add(new Book( new AudibleProductId(item.ProductId), item.Title, @@ -139,14 +136,15 @@ namespace DtoImporterService } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "Error adding book. {@DebugInfo}", new { + Serilog.Log.Logger.Error(ex, "Error adding book. {@DebugInfo}", new + { item.ProductId, item.TitleWithSubtitle, item.Description, item.LengthInMinutes, contentType, - QtyAuthors = authors?.Count, - QtyNarrators = narrators?.Count, + QtyAuthors = authors?.Length, + QtyNarrators = narrators?.Length, importItem.LocaleName }); throw; @@ -159,7 +157,7 @@ namespace DtoImporterService book.ReplacePublisher(publisher); } - if (item.PdfUrl is not null) + if (item.PdfUrl is not null) book.AddSupplementDownloadUrl(item.PdfUrl.ToString()); return book; @@ -173,8 +171,7 @@ namespace DtoImporterService // which would no import narrators with null ASINs. Thus, affected books had the // author listed as the narrators. This can probably be removed in the future. // Bug went live in 13.1.0 on 2026/01/02. Today is 2026/01/08. - var narrators = item.Narrators?.DistinctBy(a => a.Name).Select(n => contributorImporter.Cache[n.Name]).ToArray(); - if (narrators is not null && narrators.Length > 0) + if (ContributorsFromCache(item.Narrators) is { } narrators && narrators.Length > 0) book.ReplaceNarrators(narrators); book.UpdateLengthInMinutes(item.LengthInMinutes); @@ -185,20 +182,20 @@ namespace DtoImporterService // set/update book-specific info which may have changed if (item.PictureId is not null) book.PictureId = item.PictureId; - + if (item.PictureLarge is not null) book.PictureLarge = item.PictureLarge; if (item.IsFinished is not null) - book.UserDefinedItem.IsFinished = item.IsFinished.Value; + book.UserDefinedItem.IsFinished = item.IsFinished.Value; - // 2023-02-01 - // updateBook must update language on books which were imported before the migration which added language. - // 2025-07-30 - // updateBook must update isSpatial on books which were imported before the migration which added isSpatial. - book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language); + // 2023-02-01 + // updateBook must update language on books which were imported before the migration which added language. + // 2025-07-30 + // updateBook must update isSpatial on books which were imported before the migration which added isSpatial. + book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language); - book.UpdateProductRating( + book.UpdateProductRating( (float)(item.Rating?.OverallDistribution?.AverageRating ?? 0), (float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0), (float)(item.Rating?.StoryDistribution?.AverageRating ?? 0)); @@ -212,6 +209,8 @@ namespace DtoImporterService { foreach (var seriesEntry in item.Series) { + if (string.IsNullOrEmpty(seriesEntry.SeriesId)) + continue; var series = seriesImporter.Cache[seriesEntry.SeriesId]; book.UpsertSeries(series, seriesEntry.Sequence); } @@ -220,9 +219,9 @@ namespace DtoImporterService if (item.CategoryLadders is not null) { var ladders = new List(); - foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0)) + foreach (var ladder in item.CategoryLadders.Select(cl => cl?.Ladder).Where(l => l?.Length > 0)) { - var categoryIds = ladder.Select(l => l.CategoryId).ToList(); + var categoryIds = ladder?.Select(l => l?.CategoryId).ToList(); ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds))); } //Set all ladders at once so ladders that have been @@ -237,8 +236,17 @@ namespace DtoImporterService return DataLayer.ContentType.Episode; else if (item.IsSeriesParent) return DataLayer.ContentType.Parent; - else - return DataLayer.ContentType.Product; + else + return DataLayer.ContentType.Product; } + + [return: System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(toLoad))] + private Contributor[]? ContributorsFromCache(IEnumerable? toLoad) + => toLoad + ?.Select(a => a.Name) + .OfType() + .Distinct() + .Select(name => contributorImporter.Cache[name]) + .ToArray(); } } diff --git a/Source/DtoImporterService/CategoryImporter.cs b/Source/DtoImporterService/CategoryImporter.cs index f33aa5d8..2dd9d98f 100644 --- a/Source/DtoImporterService/CategoryImporter.cs +++ b/Source/DtoImporterService/CategoryImporter.cs @@ -26,9 +26,10 @@ namespace DtoImporterService //Import item may not have no (null) categories var categoryLadders = importItems .Where(i => i.DtoItem.CategoryLadders is not null) - .SelectMany(i => i.DtoItem.CategoryLadders) + .SelectMany(i => i.DtoItem.CategoryLadders!) .Select(cl => cl?.Ladder) .Where(l => l?.Length > 0) + .OfType() .ToList(); var qtyNew = upsertCategories(categoryLadders); @@ -55,6 +56,8 @@ namespace DtoImporterService { var id = ladder[i].CategoryId; var name = ladder[i].CategoryName; + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(name)) + continue; if (!CategoryCache.TryGetValue(id, out var category)) { diff --git a/Source/DtoImporterService/ContributorImporter.cs b/Source/DtoImporterService/ContributorImporter.cs index 21fdd9e7..bf201b14 100644 --- a/Source/DtoImporterService/ContributorImporter.cs +++ b/Source/DtoImporterService/ContributorImporter.cs @@ -37,6 +37,7 @@ namespace DtoImporterService .Union(authors.Select(n => n.Name)) .Union(narrators.Select(n => n.Name)) .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() .ToList(); loadLocal_contributors(allNames); @@ -64,6 +65,8 @@ namespace DtoImporterService var qtyNew = 0; foreach (var person in people) { + if (person.Name is null) + continue; if (!Cache.TryGetValue(person.Name, out var contributor)) { contributor = createContributor(person.Name, person.Asin); @@ -97,7 +100,7 @@ namespace DtoImporterService contributor.SetAudibleContributorId(person.Asin); } - private Contributor createContributor(string name, string id = null) + private Contributor createContributor(string name, string? id = null) { try { diff --git a/Source/DtoImporterService/DtoImporterService.csproj b/Source/DtoImporterService/DtoImporterService.csproj index 4c4a90e1..dd96a6bf 100644 --- a/Source/DtoImporterService/DtoImporterService.csproj +++ b/Source/DtoImporterService/DtoImporterService.csproj @@ -2,6 +2,7 @@ net10.0 + enable diff --git a/Source/DtoImporterService/ImportItem.cs b/Source/DtoImporterService/ImportItem.cs index f01908e8..f5651b5d 100644 --- a/Source/DtoImporterService/ImportItem.cs +++ b/Source/DtoImporterService/ImportItem.cs @@ -3,12 +3,8 @@ using AudibleApi.Common; namespace DtoImporterService { - public class ImportItem + public record ImportItem(Item DtoItem, string AccountId, string LocaleName) { - public Item DtoItem { get; set; } - public string AccountId { get; set; } - public string LocaleName { get; set; } - public override string ToString() - => DtoItem is null ? base.ToString() : $"[{DtoItem.ProductId}] {DtoItem.Title}"; + public override string ToString() => $"[{DtoItem.ProductId}] {DtoItem.Title}"; } } diff --git a/Source/DtoImporterService/LibraryBookImporter.cs b/Source/DtoImporterService/LibraryBookImporter.cs index 1d49a7d4..aeb7dfbd 100644 --- a/Source/DtoImporterService/LibraryBookImporter.cs +++ b/Source/DtoImporterService/LibraryBookImporter.cs @@ -48,12 +48,14 @@ public class LibraryBookImporter : ItemsImporterBase var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId); //If importItems are contains duplicates by asin, keep the Item that's "available" - var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak); + var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId ?? string.Empty, tieBreak); int qtyNew = 0; foreach (var item in uniqueImportItems.Values) { - if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing)) + if (item.DtoItem.ProductId is null) + continue; + if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook? existing)) { if (existing.Account != item.AccountId) { @@ -93,7 +95,10 @@ public class LibraryBookImporter : ItemsImporterBase if (bookImporter.LoadedEntireLibrary) { - //If the entire library was loaded, we can be sure that all existing LibraryBooks have their Book property populated. + //If the entire library was loaded, all Books should be loaded onto their LibraryBook. HOWEVER, + //a malformed Database may have a LibraryBook with a BookID that doesn't match any Book in the + //Books table. In this case, the Books property will still be null we should also mark those + //LibraryBooks as absent. //Find LibraryBooks which have a Book but weren't found in the import, and mark them as absent. foreach (var absentBook in allInScannedAccounts.Where(lb => lb.Book?.AudibleProductId is not string asin || !uniqueImportItems.ContainsKey(asin))) absentBook.AbsentFromLastScan = true; @@ -110,7 +115,8 @@ public class LibraryBookImporter : ItemsImporterBase } private static Dictionary ToDictionarySafe(IEnumerable source, Func keySelector, Func tieBreaker) - { + where TKey : notnull + { var dictionary = new Dictionary(); foreach (TSource newItem in source) @@ -118,7 +124,7 @@ public class LibraryBookImporter : ItemsImporterBase TKey key = keySelector(newItem); dictionary[key] - = dictionary.TryGetValue(key, out TSource existingItem) + = dictionary.TryGetValue(key, out TSource? existingItem) ? tieBreaker(existingItem, newItem) : newItem; } diff --git a/Source/DtoImporterService/SeriesImporter.cs b/Source/DtoImporterService/SeriesImporter.cs index 67f5d487..daeaf3fc 100644 --- a/Source/DtoImporterService/SeriesImporter.cs +++ b/Source/DtoImporterService/SeriesImporter.cs @@ -36,7 +36,7 @@ namespace DtoImporterService { var seriesIds = series.Select(s => s.SeriesId).Distinct().ToList(); - if (seriesIds.Any()) + if (seriesIds.Count != 0) Cache = DbContext.Series .Where(s => seriesIds.Contains(s.AudibleSeriesId)) .ToDictionarySafe(s => s.AudibleSeriesId); @@ -48,6 +48,8 @@ namespace DtoImporterService foreach (var s in requestedSeries) { + if (string.IsNullOrEmpty(s.SeriesId)) + continue; // AudibleApi.Common.Series.SeriesId == DataLayer.AudibleSeriesId if (!Cache.TryGetValue(s.SeriesId, out var series)) { diff --git a/Source/FileLiberator/AudioDecodable.cs b/Source/FileLiberator/AudioDecodable.cs index d8213b53..60a65ea2 100644 --- a/Source/FileLiberator/AudioDecodable.cs +++ b/Source/FileLiberator/AudioDecodable.cs @@ -7,16 +7,16 @@ namespace FileLiberator { public abstract class AudioDecodable : Processable { - public delegate byte[] RequestCoverArtHandler(object sender, EventArgs eventArgs); - public event RequestCoverArtHandler RequestCoverArt; - public event EventHandler TitleDiscovered; - public event EventHandler AuthorsDiscovered; - public event EventHandler NarratorsDiscovered; - public event EventHandler CoverImageDiscovered; + public delegate byte[]? RequestCoverArtHandler(object sender, EventArgs eventArgs); + public event RequestCoverArtHandler? RequestCoverArt; + public event EventHandler? TitleDiscovered; + public event EventHandler? AuthorsDiscovered; + public event EventHandler? NarratorsDiscovered; + public event EventHandler? CoverImageDiscovered; public abstract Task CancelAsync(); protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title); - protected void OnTitleDiscovered(object _, string title) + protected void OnTitleDiscovered(object? _, string? title) { Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title }); if (title != null) @@ -24,7 +24,7 @@ namespace FileLiberator } protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors); - protected void OnAuthorsDiscovered(object _, string authors) + protected void OnAuthorsDiscovered(object? _, string? authors) { Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors }); if (authors != null) @@ -32,22 +32,22 @@ namespace FileLiberator } protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators); - protected void OnNarratorsDiscovered(object _, string narrators) + protected void OnNarratorsDiscovered(object? _, string? narrators) { Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators }); if (narrators != null) NarratorsDiscovered?.Invoke(this, narrators); } - protected byte[] OnRequestCoverArt() + protected byte[]? OnRequestCoverArt() { Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) }); - return RequestCoverArt?.Invoke(this, new()); + return RequestCoverArt?.Invoke(this, EventArgs.Empty); } protected void OnCoverImageDiscovered(byte[] coverImage) { - Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = coverImage?.Length }); + Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = coverImage.Length }); CoverImageDiscovered?.Invoke(this, coverImage); } } diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs index e66b49e0..cc2192b1 100644 --- a/Source/FileLiberator/AudioFileStorageExt.cs +++ b/Source/FileLiberator/AudioFileStorageExt.cs @@ -16,34 +16,38 @@ namespace FileLiberator /// Path: directory nested inside of Books directory /// File name: n/a /// - public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook, Configuration config = null) + public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook, Configuration? config = null) { + if (AudibleFileStorage.BooksDirectory is not { } books) + throw new InvalidOperationException("Books directory is not set."); + config ??= Configuration.Instance; if (libraryBook.Book.IsEpisodeChild() && config.SavePodcastsToParentFolder) { 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); if (seriesParent is not null) { - return Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, ""); + return Templates.Folder.GetFilename(seriesParent.ToDto(), books, ""); } } } - return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, ""); + return Templates.Folder.GetFilename(libraryBook.ToDto(), books, ""); } /// /// PDF: audio file does not exist /// public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false) - => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension, returnFirstExisting: returnFirstExisting); + => AudibleFileStorage.BooksDirectory is { } books ? Templates.File.GetFilename(libraryBook.ToDto(), books, extension, null, returnFirstExisting) + : throw new InvalidOperationException("Books directory is not set."); /// /// PDF: audio file already exists /// - public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties partProperties = null, bool returnFirstExisting = false) + public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties? partProperties = null, bool returnFirstExisting = false) => partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting) : Templates.ChapterFile.GetFilename(libraryBook.ToDto(), partProperties, dirFullPath, extension, returnFirstExisting: returnFirstExisting); } diff --git a/Source/FileLiberator/ConvertToMp3.cs b/Source/FileLiberator/ConvertToMp3.cs index 39736754..1569084b 100644 --- a/Source/FileLiberator/ConvertToMp3.cs +++ b/Source/FileLiberator/ConvertToMp3.cs @@ -16,14 +16,15 @@ namespace FileLiberator public class ConvertToMp3 : AudioDecodable, IProcessable { public override string Name => "Convert to Mp3"; - private Mp4Operation Mp4Operation; + private Mp4Operation? Mp4Operation; private readonly AaxDecrypter.AverageSpeed averageSpeed = new(); private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3"); - private CancellationTokenSource CancellationTokenSource { get; set; } + private CancellationTokenSource? CancellationTokenSource { get; set; } public override async Task CancelAsync() { - await CancellationTokenSource.CancelAsync(); + if (CancellationTokenSource is not null) + await CancellationTokenSource.CancelAsync(); if (Mp4Operation is not null) await Mp4Operation.CancelAsync(); } @@ -63,10 +64,14 @@ namespace FileLiberator using var m4bFileStream = File.Open(entry.m4bPath, FileMode.Open, FileAccess.Read, FileShare.Read); var m4bBook = new Mp4File(m4bFileStream); - OnTitleDiscovered(m4bBook.AppleTags.Title); - OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor); - OnNarratorsDiscovered(m4bBook.AppleTags.Narrator); - OnCoverImageDiscovered(m4bBook.AppleTags.Cover); + if (m4bBook.MetadataItems.Title is string title) + OnTitleDiscovered(title); + if (m4bBook.MetadataItems.FirstAuthor is string firstAuthor) + OnAuthorsDiscovered(firstAuthor); + if (m4bBook.MetadataItems.Narrator is string narrator) + OnNarratorsDiscovered(narrator); + if (m4bBook.MetadataItems.Cover is byte[] cover) + OnCoverImageDiscovered(cover); var lameConfig = DownloadOptions.GetLameOptions(Configuration); var chapters = m4bBook.GetChaptersFromMetadata(); @@ -78,9 +83,9 @@ namespace FileLiberator Configuration.LameMatchSourceBR, chapters); - if (m4bBook.AppleTags.Tracks is (int trackNum, int trackCount)) + if (m4bBook.MetadataItems.TrackNumber is { } trackNum && lameConfig.ID3 is not null) { - lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString(); + lameConfig.ID3.Track = trackNum.TotalTracks > 0 ? $"{trackNum.Track}/{trackNum.TotalTracks}" : trackNum.Track.ToString(); } long currentFileNumBytesProcessed = 0; @@ -108,7 +113,8 @@ namespace FileLiberator Configuration.OverwriteExisting); SetFileTime(libraryBook, realMp3Path); - SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path)); + if (Path.GetDirectoryName(realMp3Path) is string outputDir) + SetDirectoryTime(libraryBook, outputDir); OnFileCreated(libraryBook, realMp3Path); } finally @@ -118,7 +124,7 @@ namespace FileLiberator sizeOfCompletedFiles += entry.m4bSize; } - void m4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e) + void m4bBook_ConversionProgressUpdate(object? sender, ConversionProgressEventArgs e) { currentFileNumBytesProcessed = (long)(e.FractionCompleted * entry.m4bSize); var bytesCompleted = sizeOfCompletedFiles + currentFileNumBytesProcessed; diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index e48ac05b..e84dc78d 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -268,7 +268,8 @@ namespace FileLiberator try { e = OnRequestCoverArt(); - downloader.SetCoverArt(e); + if (e is not null) + downloader.SetCoverArt(e); } catch (Exception ex) { @@ -406,6 +407,12 @@ namespace FileLiberator if (!options.Config.DownloadCoverArt) return; var coverPath = "[null]"; + var picId = options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId; + if (picId is null) + { + Serilog.Log.Logger.Warning("No cover art available for {@Book}.", options.LibraryBook.LogFriendly()); + return; + } try { @@ -419,7 +426,8 @@ namespace FileLiberator if (File.Exists(coverPath)) FileUtility.SaferDelete(coverPath); - var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken); + + var picBytes = PictureStorage.GetPictureSynchronously(new(picId, PictureSize.Native), cancellationToken); if (picBytes.Length > 0) { File.WriteAllBytes(coverPath, picBytes); @@ -504,11 +512,17 @@ namespace FileLiberator FileUtility.SaferDelete(metadataPath); var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); - item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo)); - item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference)); + + if (item?.SourceJson is not { } sourceJson) + { + Serilog.Log.Logger.Error("Failed to retrieve metadata from server for {@Book}.", options.LibraryBook.LogFriendly()); + return; + } + sourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo)); + sourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference)); cancellationToken.ThrowIfCancellationRequested(); - File.WriteAllText(metadataPath, item.SourceJson.ToString()); + File.WriteAllText(metadataPath, sourceJson.ToString()); SetFileTime(options.LibraryBook, metadataPath); OnFileCreated(options.LibraryBook, metadataPath); } diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index b579e253..bd2bdb69 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -79,15 +79,22 @@ public partial class DownloadOptions { ContentMetadata = null!; } - public LicenseInfo(ContentLicense license, IEnumerable? keys = null) + + public static LicenseInfo Create(ContentLicense license, IEnumerable? keys = null) { - DrmType = license.DrmType; - ContentMetadata = license.ContentMetadata; - DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher); + ArgumentNullException.ThrowIfNull(license, nameof(license)); + ArgumentNullException.ThrowIfNull(license.ContentMetadata, nameof(license.ContentMetadata)); + + return new LicenseInfo + { + DrmType = license.DrmType, + ContentMetadata = license.ContentMetadata, + DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher) + }; } private static KeyData[]? ToKeys(VoucherDtoV10? voucher) - => voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)]; + => voucher?.Key is null ? null : [new KeyData(voucher.Key, voucher.Iv)]; } private static async Task ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token) @@ -116,7 +123,7 @@ public partial class DownloadOptions token.ThrowIfCancellationRequested(); var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); - return new LicenseInfo(license); + return LicenseInfo.Create(license); } token.ThrowIfCancellationRequested(); @@ -138,7 +145,12 @@ public partial class DownloadOptions spatialCodecChoice); if (contentLic.DrmType is not DrmType.Widevine) - return new LicenseInfo(contentLic); + return LicenseInfo.Create(contentLic); + + if (contentLic.LicenseResponse is null) + throw new InvalidDataException("Widevine license response is null."); + if (contentLic.ContentMetadata is null) + throw new InvalidDataException("Widevine content metadata is null."); using var client = new HttpClient(); using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token); @@ -153,7 +165,7 @@ public partial class DownloadOptions var challenge = session.GetLicenseChallenge(dash); var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge); var keys = session.ParseLicense(licenseMessage); - return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key))); + return LicenseInfo.Create(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key))); } catch (Exception ex) { diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 9ac954c6..5cf6ea54 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -21,9 +21,9 @@ namespace FileLiberator public required Mpeg4Lib.ChapterInfo ChapterInfo { get; init; } public string Title => LibraryBook.Book.Title; public string Subtitle => LibraryBook.Book.Subtitle; - public string Publisher => LibraryBook.Book.Publisher; - public string Language => LibraryBook.Book.Language; - public string? AudibleProductId => LibraryBookDto.AudibleProductId; + public string? Publisher => LibraryBook.Book.Publisher; + public string? Language => LibraryBook.Book.Language; + public string AudibleProductId => LibraryBookDto.AudibleProductId; public string? SeriesName => LibraryBookDto.FirstSeries?.Name; public string? SeriesNumber => LibraryBookDto.FirstSeries?.Order?.ToString(); public NAudio.Lame.LameConfig? LameConfig { get; } @@ -57,7 +57,7 @@ namespace FileLiberator ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo)); - if (licInfo.ContentMetadata.ContentUrl.OfflineUrl is not string licUrl) + if (licInfo.ContentMetadata.ContentUrl?.OfflineUrl is not string licUrl) throw new InvalidDataException("Content license doesn't contain an offline Url"); DownloadUrl = licUrl; diff --git a/Source/FileLiberator/DownloadPdf.cs b/Source/FileLiberator/DownloadPdf.cs index 500087e1..6ba19a7b 100644 --- a/Source/FileLiberator/DownloadPdf.cs +++ b/Source/FileLiberator/DownloadPdf.cs @@ -33,7 +33,8 @@ namespace FileLiberator if (result.IsSuccess) { SetFileTime(libraryBook, actualDownloadedFilePath); - SetDirectoryTime(libraryBook, Path.GetDirectoryName(actualDownloadedFilePath)); + if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir) + SetDirectoryTime(libraryBook, outputDir); } await libraryBook.UpdatePdfStatusAsync(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated); @@ -56,7 +57,7 @@ namespace FileLiberator private static string getProposedDownloadFilePath(LibraryBook libraryBook) { - var extension = Path.GetExtension(getdownloadUrl(libraryBook)); + var extension = Path.GetExtension(getdownloadUrl(libraryBook)) ?? ".pdf"; // if audio file exists, get it's dir. else return base Book dir var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId)); @@ -66,7 +67,7 @@ namespace FileLiberator return AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, extension); } - private static string getdownloadUrl(LibraryBook libraryBook) + private static string? getdownloadUrl(LibraryBook libraryBook) => libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url; private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath) diff --git a/Source/FileLiberator/FileLiberator.csproj b/Source/FileLiberator/FileLiberator.csproj index 529fdc38..667bd9b4 100644 --- a/Source/FileLiberator/FileLiberator.csproj +++ b/Source/FileLiberator/FileLiberator.csproj @@ -2,6 +2,7 @@ net10.0 + enable diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index d563924c..3c97f78c 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -47,7 +47,7 @@ namespace FileLiberator using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); var nickname = persister.AccountsSettings.Accounts - .FirstOrDefault(a => a.AccountId == libraryBook.Account && a.Locale.Name == libraryBook.Book.Locale) + .FirstOrDefault(a => a.AccountId == libraryBook.Account && a.Locale?.Name == libraryBook.Book.Locale) ?.AccountName; return new() @@ -76,7 +76,7 @@ namespace FileLiberator BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate, Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount, - LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion?.ToVersionString(), + LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion.ToVersionString(), FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion }; } diff --git a/Source/FileManager/BackgroundFileSystem.cs b/Source/FileManager/BackgroundFileSystem.cs index 0b37a572..81fe8ab4 100644 --- a/Source/FileManager/BackgroundFileSystem.cs +++ b/Source/FileManager/BackgroundFileSystem.cs @@ -6,195 +6,193 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -#nullable enable -namespace FileManager +namespace FileManager; + +/// +/// Tracks actual locations of files. +/// +public class BackgroundFileSystem : IDisposable { - /// - /// Tracks actual locations of files. - /// - public class BackgroundFileSystem : IDisposable - { - public LongPath? RootDirectory { get; private set; } - public string SearchPattern { get; private set; } - public SearchOption SearchOption { get; private set; } + public LongPath? RootDirectory { get; private set; } + public string SearchPattern { get; private set; } + public SearchOption SearchOption { get; private set; } - private FileSystemWatcher? fileSystemWatcher { get; set; } - private BlockingCollection? directoryChangesEvents { get; set; } - private Task? backgroundScanner { get; set; } + private FileSystemWatcher? fileSystemWatcher { get; set; } + private BlockingCollection? directoryChangesEvents { get; set; } + private Task? backgroundScanner { get; set; } - private Lock fsCacheLocker { get; } = new(); - private List fsCache { get; } = new(); + private Lock fsCacheLocker { get; } = new(); + private List fsCache { get; } = new(); - public BackgroundFileSystem(LongPath rootDirectory, string searchPattern, SearchOption searchOptions) - { - RootDirectory = rootDirectory; - SearchPattern = searchPattern; - SearchOption = searchOptions; + public BackgroundFileSystem(LongPath rootDirectory, string searchPattern, SearchOption searchOptions) + { + RootDirectory = rootDirectory; + SearchPattern = searchPattern; + SearchOption = searchOptions; - Init(); - } + Init(); + } - public LongPath? FindFile(System.Text.RegularExpressions.Regex regex) - { - lock (fsCacheLocker) - return fsCache.FirstOrDefault(s => regex.IsMatch(s)); - } + public LongPath? FindFile(System.Text.RegularExpressions.Regex regex) + { + lock (fsCacheLocker) + return fsCache.FirstOrDefault(s => regex.IsMatch(s)); + } - public List FindFiles(System.Text.RegularExpressions.Regex regex) - { - lock (fsCacheLocker) - return fsCache.Where(s => regex.IsMatch(s)).ToList(); - } + public List FindFiles(System.Text.RegularExpressions.Regex regex) + { + lock (fsCacheLocker) + return fsCache.Where(s => regex.IsMatch(s)).ToList(); + } - public void RefreshFiles() - { - lock (fsCacheLocker) - { - fsCache.Clear(); - if (Directory.Exists(RootDirectory)) - fsCache.AddRange(SafestEnumerateFiles(RootDirectory)); - } - } - - private void Init() - { - Stop(); - - lock (fsCacheLocker) - { - if (!Directory.Exists(RootDirectory)) - { - RootDirectory = null; - return; - } - fsCache.AddRange(SafestEnumerateFiles(RootDirectory)); - } - - directoryChangesEvents = new BlockingCollection(); - fileSystemWatcher = new FileSystemWatcher(RootDirectory) - { - IncludeSubdirectories = true, - EnableRaisingEvents = true - }; - fileSystemWatcher.Created += FileSystemWatcher_Changed; - fileSystemWatcher.Deleted += FileSystemWatcher_Changed; - fileSystemWatcher.Renamed += FileSystemWatcher_Changed; - fileSystemWatcher.Error += FileSystemWatcher_Error; - - backgroundScanner = new Task(BackgroundScanner); - backgroundScanner.Start(); - } - private void Stop() - { - //Stop raising events - fileSystemWatcher?.Dispose(); - - try - { - //Calling CompleteAdding() will cause background scanner to terminate. - directoryChangesEvents?.CompleteAdding(); - } - // if directoryChangesEvents is non-null and isDisposed, this exception is thrown. there's no other way to check >:( - catch (ObjectDisposedException) { } - - //Wait for background scanner to terminate before reinitializing. - backgroundScanner?.Wait(); - - //Dispose of directoryChangesEvents after backgroundScanner exists. - directoryChangesEvents?.Dispose(); - - lock (fsCacheLocker) - fsCache.Clear(); - } - - private void FileSystemWatcher_Error(object sender, ErrorEventArgs e) - { - Init(); - } - - private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e) - { - directoryChangesEvents?.Add(e); - } - - #region Background Thread - private void BackgroundScanner() - { - while (directoryChangesEvents?.TryTake(out var change, -1) is true) - { - lock (fsCacheLocker) - UpdateLocalCache(change); - } - } - - private void UpdateLocalCache(FileSystemEventArgs change) - { - if (change.ChangeType == WatcherChangeTypes.Deleted) - { - RemovePath(change.FullPath); - } - else if (change.ChangeType == WatcherChangeTypes.Created) - { - AddPath(change.FullPath); - } - else if (change.ChangeType == WatcherChangeTypes.Renamed && change is RenamedEventArgs renameChange) - { - RemovePath(renameChange.OldFullPath); - AddPath(renameChange.FullPath); - } - } - - private void RemovePath(LongPath path) - { - path = path.LongPathName; - var pathsToRemove = fsCache.Where(p => ((string)p).StartsWith(path)).ToArray(); - - foreach (var p in pathsToRemove) - fsCache.Remove(p); - } - - private void AddPath(LongPath path) - { - path = path.LongPathName; - //Temporary files created when updating the db will disappear before their attributes can be read. - if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path)) - return; - if (File.GetAttributes(path).HasFlag(FileAttributes.Directory)) - AddUniqueFiles(SafestEnumerateFiles(path)); - else - AddUniqueFile(path); - } - - private IEnumerable SafestEnumerateFiles(string path) - { - try - { - return FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption); - } - catch - { - return []; - } - } - - private void AddUniqueFiles(IEnumerable newFiles) - { - foreach (var file in newFiles) - AddUniqueFile(file); - } - - private void AddUniqueFile(LongPath newFile) - { - if (!fsCache.Contains(newFile)) - fsCache.Add(newFile); - } - - #endregion - - public void Dispose() + public void RefreshFiles() + { + lock (fsCacheLocker) { - Stop(); - GC.SuppressFinalize(this); + fsCache.Clear(); + if (Directory.Exists(RootDirectory)) + fsCache.AddRange(SafestEnumerateFiles(RootDirectory)); } } + + private void Init() + { + Stop(); + + lock (fsCacheLocker) + { + if (!Directory.Exists(RootDirectory)) + { + RootDirectory = null; + return; + } + fsCache.AddRange(SafestEnumerateFiles(RootDirectory)); + } + + directoryChangesEvents = new BlockingCollection(); + fileSystemWatcher = new FileSystemWatcher(RootDirectory) + { + IncludeSubdirectories = true, + EnableRaisingEvents = true + }; + fileSystemWatcher.Created += FileSystemWatcher_Changed; + fileSystemWatcher.Deleted += FileSystemWatcher_Changed; + fileSystemWatcher.Renamed += FileSystemWatcher_Changed; + fileSystemWatcher.Error += FileSystemWatcher_Error; + + backgroundScanner = new Task(BackgroundScanner); + backgroundScanner.Start(); + } + private void Stop() + { + //Stop raising events + fileSystemWatcher?.Dispose(); + + try + { + //Calling CompleteAdding() will cause background scanner to terminate. + directoryChangesEvents?.CompleteAdding(); + } + // if directoryChangesEvents is non-null and isDisposed, this exception is thrown. there's no other way to check >:( + catch (ObjectDisposedException) { } + + //Wait for background scanner to terminate before reinitializing. + backgroundScanner?.Wait(); + + //Dispose of directoryChangesEvents after backgroundScanner exists. + directoryChangesEvents?.Dispose(); + + lock (fsCacheLocker) + fsCache.Clear(); + } + + private void FileSystemWatcher_Error(object sender, ErrorEventArgs e) + { + Init(); + } + + private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e) + { + directoryChangesEvents?.Add(e); + } + + #region Background Thread + private void BackgroundScanner() + { + while (directoryChangesEvents?.TryTake(out var change, -1) is true) + { + lock (fsCacheLocker) + UpdateLocalCache(change); + } + } + + private void UpdateLocalCache(FileSystemEventArgs change) + { + if (change.ChangeType == WatcherChangeTypes.Deleted) + { + RemovePath(change.FullPath); + } + else if (change.ChangeType == WatcherChangeTypes.Created) + { + AddPath(change.FullPath); + } + else if (change.ChangeType == WatcherChangeTypes.Renamed && change is RenamedEventArgs renameChange) + { + RemovePath(renameChange.OldFullPath); + AddPath(renameChange.FullPath); + } + } + + private void RemovePath(LongPath path) + { + path = path.LongPathName; + var pathsToRemove = fsCache.Where(p => ((string)p).StartsWith(path)).ToArray(); + + foreach (var p in pathsToRemove) + fsCache.Remove(p); + } + + private void AddPath(LongPath path) + { + path = path.LongPathName; + //Temporary files created when updating the db will disappear before their attributes can be read. + if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path)) + return; + if (File.GetAttributes(path).HasFlag(FileAttributes.Directory)) + AddUniqueFiles(SafestEnumerateFiles(path)); + else + AddUniqueFile(path); + } + + private IEnumerable SafestEnumerateFiles(string path) + { + try + { + return FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption); + } + catch + { + return []; + } + } + + private void AddUniqueFiles(IEnumerable newFiles) + { + foreach (var file in newFiles) + AddUniqueFile(file); + } + + private void AddUniqueFile(LongPath newFile) + { + if (!fsCache.Contains(newFile)) + fsCache.Add(newFile); + } + + #endregion + + public void Dispose() + { + Stop(); + GC.SuppressFinalize(this); + } } diff --git a/Source/FileManager/FileManager.csproj b/Source/FileManager/FileManager.csproj index 7d1acbc0..afe486c7 100644 --- a/Source/FileManager/FileManager.csproj +++ b/Source/FileManager/FileManager.csproj @@ -2,6 +2,7 @@ net10.0 + enable diff --git a/Source/FileManager/FileSystemTest.cs b/Source/FileManager/FileSystemTest.cs index 27efd98f..846c547b 100644 --- a/Source/FileManager/FileSystemTest.cs +++ b/Source/FileManager/FileSystemTest.cs @@ -1,79 +1,77 @@ using System; using System.IO; -#nullable enable -namespace FileManager +namespace FileManager; + +public static class FileSystemTest { - public static class FileSystemTest + /// + /// Additional characters which are illegal for filenames in Windows environments. + /// Double quotes and slashes are already illegal filename characters on all platforms, + /// so they are not included here. + /// + public static string AdditionalInvalidWindowsFilenameCharacters { get; } = "<>|:*?"; + + /// + /// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, <, >, |). + /// + public static bool CanWriteWindowsInvalidChars(LongPath? directoryName) { - /// - /// Additional characters which are illegal for filenames in Windows environments. - /// Double quotes and slashes are already illegal filename characters on all platforms, - /// so they are not included here. - /// - public static string AdditionalInvalidWindowsFilenameCharacters { get; } = "<>|:*?"; + if (directoryName is null) + return false; + var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString()); + return CanWriteFile(testFile); + } - /// - /// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, <, >, |). - /// - public static bool CanWriteWindowsInvalidChars(LongPath? directoryName) - { - if (directoryName is null) - return false; - var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString()); - return CanWriteFile(testFile); - } - - /// - /// Test if the directory supports filenames with 255 unicode characters. - /// - public static bool CanWrite255UnicodeChars(LongPath? directoryName) - { - if (directoryName is null) - return false; - const char unicodeChar = 'ü'; - var testFileName = new string(unicodeChar, 255); - var testFile = Path.Combine(directoryName, testFileName); - return CanWriteFile(testFile); - } - - /// - /// Test if a directory has write access by attempting to create an empty file in it. - /// Returns true even if the temporary file can not be deleted. - /// - public static bool CanWriteDirectory(LongPath directoryName) - { - if (!Directory.Exists(directoryName)) - return false; - - Serilog.Log.Logger.Debug("Testing write permissions for directory: {DirectoryName}", directoryName); - var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString()); - return CanWriteFile(testFilePath); - } - - private static bool CanWriteFile(LongPath filename) + /// + /// Test if the directory supports filenames with 255 unicode characters. + /// + public static bool CanWrite255UnicodeChars(LongPath? directoryName) + { + if (directoryName is null) + return false; + const char unicodeChar = 'ü'; + var testFileName = new string(unicodeChar, 255); + var testFile = Path.Combine(directoryName, testFileName); + return CanWriteFile(testFile); + } + + /// + /// Test if a directory has write access by attempting to create an empty file in it. + /// Returns true even if the temporary file can not be deleted. + /// + public static bool CanWriteDirectory(LongPath directoryName) + { + if (!Directory.Exists(directoryName)) + return false; + + Serilog.Log.Logger.Debug("Testing write permissions for directory: {DirectoryName}", directoryName); + var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString()); + return CanWriteFile(testFilePath); + } + + private static bool CanWriteFile(LongPath filename) + { + try { + Serilog.Log.Logger.Debug("Testing ability to write filename: {filename}", filename); + File.WriteAllBytes(filename, []); + Serilog.Log.Logger.Debug("Deleting test file after successful write: {filename}", filename); try { - 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; + FileUtility.SaferDelete(filename); } catch (Exception ex) { - Serilog.Log.Logger.Debug(ex, "Error writing test file: {filename}", filename); - return false; + //An error deleting the file doesn't constitute a write failure. + Serilog.Log.Logger.Debug(ex, "Error deleting test file: {filename}", filename); } + return true; + } + catch (Exception ex) + { + Serilog.Log.Logger.Debug(ex, "Error writing test file: {filename}", filename); + return false; } } } diff --git a/Source/FileManager/FileUtility.cs b/Source/FileManager/FileUtility.cs index b2bc4274..8b5ea70a 100644 --- a/Source/FileManager/FileUtility.cs +++ b/Source/FileManager/FileUtility.cs @@ -8,272 +8,270 @@ using Dinah.Core; using Polly; using Polly.Retry; -#nullable enable -namespace FileManager +namespace FileManager; + +public static class FileUtility { - public static class FileUtility + /// + /// "txt" => ".txt" + ///
".txt" => ".txt" + ///
null or whitespace => "" + ///
+ [return: NotNull] + public static string GetStandardizedExtension(string? extension) + => string.IsNullOrWhiteSpace(extension) + ? string.Empty + : '.' + extension.Trim().Trim('.'); + + /// + /// Return position with correct number of leading zeros. + ///
- 2 of 9 => "2" + ///
- 2 of 90 => "02" + ///
- 2 of 900 => "002" + ///
+ /// position in sequence. The 'x' in 'x of y' + /// total qty in sequence. The 'y' in 'x of y' + public static string GetSequenceFormatted(int position, int total) { - /// - /// "txt" => ".txt" - ///
".txt" => ".txt" - ///
null or whitespace => "" - ///
- [return: NotNull] - public static string GetStandardizedExtension(string? extension) - => string.IsNullOrWhiteSpace(extension) - ? string.Empty - : '.' + extension.Trim().Trim('.'); + ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0); + ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0); + if (position > total) + throw new ArgumentException($"{position} may not be greater than {total}"); - /// - /// Return position with correct number of leading zeros. - ///
- 2 of 9 => "2" - ///
- 2 of 90 => "02" - ///
- 2 of 900 => "002" - ///
- /// position in sequence. The 'x' in 'x of y' - /// total qty in sequence. The 'y' in 'x of y' - public static string GetSequenceFormatted(int position, int total) + return position.ToString().PadLeft(total.ToString().Length, '0'); + } + + + /// + /// Ensure valid file name path: + ///
- remove invalid chars + ///
- ensure uniqueness + ///
- enforce max file length + ///
+ public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string? fileExtension, bool returnFirstExisting = false) + { + ArgumentValidator.EnsureNotNull(path, nameof(path)); + ArgumentValidator.EnsureNotNull(replacements, nameof(replacements)); + + fileExtension = GetStandardizedExtension(fileExtension); + + var pathStr = removeInvalidWhitespace(path.Path); + var pathWithoutExtension = pathStr.EndsWithInsensitive(fileExtension) + ? pathStr[..^fileExtension.Length] + : path.Path; + + // remove invalid chars, but leave file extension untouched + pathWithoutExtension = GetSafePath(pathWithoutExtension, replacements); + + // ensure uniqueness and check lengths + var dir = Path.GetDirectoryName(pathWithoutExtension)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty; + + var filenameWithoutExtension = Path.GetFileName(pathWithoutExtension); + var fileStem + = Path.Combine(dir, filenameWithoutExtension.TruncateFilename(LongPath.MaxFilenameLength - fileExtension.Length)) + .TruncateFilename(LongPath.MaxPathLength - fileExtension.Length); + + var fullfilename = removeInvalidWhitespace(fileStem) + fileExtension; + + var i = 0; + while (File.Exists(fullfilename) && !returnFirstExisting) { - ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0); - ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0); - if (position > total) - throw new ArgumentException($"{position} may not be greater than {total}"); - - return position.ToString().PadLeft(total.ToString().Length, '0'); + var increm = $" ({++i})"; + fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - fileExtension.Length) + increm + fileExtension; } + return fullfilename; + } - /// - /// Ensure valid file name path: - ///
- remove invalid chars - ///
- ensure uniqueness - ///
- enforce max file length - ///
- public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string? fileExtension, bool returnFirstExisting = false) + /// Use with full path, not file name. Valid path characters which are invalid file name characters will be retained: '\\', '/' + public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements) + { + ArgumentValidator.EnsureNotNull(path, nameof(path)); + ArgumentValidator.EnsureNotNull(replacements, nameof(replacements)); + + var pathNoPrefix = path.PathWithoutPrefix; + + pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix); + pathNoPrefix = removeDoubleSlashes(pathNoPrefix); + + return pathNoPrefix; + } + + private static string removeDoubleSlashes(string path) + { + if (path.Length < 2) + return path; + + // exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1 + + var remainder = path[1..]; + var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}"; + while (remainder.Contains(dblSeparator)) + remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}"); + + return path[0] + remainder; + } + + private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*"; + private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace); + + /// no part of the path may begin or end in whitespace + private static string removeInvalidWhitespace(string fullfilename) + { + // no whitespace at beginning or end + // replace whitespace around path slashes + // regex (with space added for clarity) + // \s* \\ \s* => \ + // no ending dots. beginning dots are valid + + // regex is easier by ending with separator + fullfilename += Path.DirectorySeparatorChar; + fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString()); + // take separator back off + fullfilename = RemoveLastCharacter(fullfilename); + + fullfilename = removeDoubleSlashes(fullfilename); + return fullfilename; + } + + public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1]; + + public static string TruncateFilename(this string filenameStr, int limit) + { + if (LongPath.IsWindows) return filenameStr.Truncate(limit); + + int index = filenameStr.Length; + + while (index > 0 && System.Text.Encoding.UTF8.GetByteCount(filenameStr, 0, index) > limit) + index--; + + return filenameStr[..index]; + } + + /// + /// Move file. + ///
- Ensure valid file name path: remove invalid chars, enforce max file length + ///
- Perform + ///
+ /// Name of the file to move + /// The new path and name for the file. + /// Rules for replacing illegal file path characters + /// File extension override to use for + /// If false and exists, append " (n)" to filename and try again. + /// The actual destination filename + public static LongPath SaferMoveToValidPath( + LongPath source, + LongPath destination, + ReplacementCharacters replacements, + string? extension = null, + bool overwrite = false) + { + extension ??= Path.GetExtension(source); + destination = GetValidFilename(destination, replacements, extension, overwrite); + SaferMove(source, destination); + return destination; + } + + private static int maxRetryAttempts { get; } = 3; + private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100); + private static RetryPolicy retryPolicy { get; } = + Policy + .Handle() + .WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures); + + /// Delete file. No error when source does not exist. Retry up to 3 times before throwing exception. + public static void SaferDelete(LongPath source) + => retryPolicy.Execute(() => { - ArgumentValidator.EnsureNotNull(path, nameof(path)); - ArgumentValidator.EnsureNotNull(replacements, nameof(replacements)); - - fileExtension = GetStandardizedExtension(fileExtension); - - var pathStr = removeInvalidWhitespace(path.Path); - var pathWithoutExtension = pathStr.EndsWithInsensitive(fileExtension) - ? pathStr[..^fileExtension.Length] - : path.Path; - - // remove invalid chars, but leave file extension untouched - pathWithoutExtension = GetSafePath(pathWithoutExtension, replacements); - - // ensure uniqueness and check lengths - var dir = Path.GetDirectoryName(pathWithoutExtension)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty; - - var filenameWithoutExtension = Path.GetFileName(pathWithoutExtension); - var fileStem - = Path.Combine(dir, filenameWithoutExtension.TruncateFilename(LongPath.MaxFilenameLength - fileExtension.Length)) - .TruncateFilename(LongPath.MaxPathLength - fileExtension.Length); - - var fullfilename = removeInvalidWhitespace(fileStem) + fileExtension; - - var i = 0; - while (File.Exists(fullfilename) && !returnFirstExisting) + try { - var increm = $" ({++i})"; - fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - fileExtension.Length) + increm + fileExtension; + if (!File.Exists(source)) + { + Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source }); + return; + } + + Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source }); + File.Delete(source); + Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source }); } - - return fullfilename; - } - - /// Use with full path, not file name. Valid path characters which are invalid file name characters will be retained: '\\', '/' - public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements) - { - ArgumentValidator.EnsureNotNull(path, nameof(path)); - ArgumentValidator.EnsureNotNull(replacements, nameof(replacements)); - - var pathNoPrefix = path.PathWithoutPrefix; - - pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix); - pathNoPrefix = removeDoubleSlashes(pathNoPrefix); - - return pathNoPrefix; - } - - private static string removeDoubleSlashes(string path) - { - if (path.Length < 2) - return path; - - // exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1 - - var remainder = path[1..]; - var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}"; - while (remainder.Contains(dblSeparator)) - remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}"); - - return path[0] + remainder; - } - - private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*"; - private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace); - - /// no part of the path may begin or end in whitespace - private static string removeInvalidWhitespace(string fullfilename) - { - // no whitespace at beginning or end - // replace whitespace around path slashes - // regex (with space added for clarity) - // \s* \\ \s* => \ - // no ending dots. beginning dots are valid - - // regex is easier by ending with separator - fullfilename += Path.DirectorySeparatorChar; - fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString()); - // take separator back off - fullfilename = RemoveLastCharacter(fullfilename); - - fullfilename = removeDoubleSlashes(fullfilename); - return fullfilename; - } - - public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1]; - - public static string TruncateFilename(this string filenameStr, int limit) - { - if (LongPath.IsWindows) return filenameStr.Truncate(limit); - - int index = filenameStr.Length; - - while (index > 0 && System.Text.Encoding.UTF8.GetByteCount(filenameStr, 0, index) > limit) - index--; - - return filenameStr[..index]; - } - - /// - /// Move file. - ///
- Ensure valid file name path: remove invalid chars, enforce max file length - ///
- Perform - ///
- /// Name of the file to move - /// The new path and name for the file. - /// Rules for replacing illegal file path characters - /// File extension override to use for - /// If false and exists, append " (n)" to filename and try again. - /// The actual destination filename - public static LongPath SaferMoveToValidPath( - LongPath source, - LongPath destination, - ReplacementCharacters replacements, - string? extension = null, - bool overwrite = false) - { - extension ??= Path.GetExtension(source); - destination = GetValidFilename(destination, replacements, extension, overwrite); - SaferMove(source, destination); - return destination; - } - - private static int maxRetryAttempts { get; } = 3; - private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100); - private static RetryPolicy retryPolicy { get; } = - Policy - .Handle() - .WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures); - - /// Delete file. No error when source does not exist. Retry up to 3 times before throwing exception. - public static void SaferDelete(LongPath source) - => retryPolicy.Execute(() => + catch (Exception e) { - try - { - if (!File.Exists(source)) - { - Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source }); - return; - } + Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source }); + throw; + } + }); - Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source }); - File.Delete(source); - Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source }); - } - catch (Exception e) - { - Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source }); - throw; - } - }); - - /// Move file. No error when source does not exist. Retry up to 3 times before throwing exception. - public static void SaferMove(LongPath source, LongPath destination) - => retryPolicy.Execute(() => + /// Move file. No error when source does not exist. Retry up to 3 times before throwing exception. + public static void SaferMove(LongPath source, LongPath destination) + => retryPolicy.Execute(() => + { + try { - try + if (!File.Exists(source)) { - if (!File.Exists(source)) - { - Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source }); - return; - } - - SaferDelete(destination); - - var dir = Path.GetDirectoryName(destination); - if (dir is null) - throw new DirectoryNotFoundException(); - - Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir }); - Directory.CreateDirectory(dir); - - Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination }); - File.Move(source, destination); - Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination }); + Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source }); + return; } - catch (Exception e) - { - Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination }); - throw; - } - }); - /// - /// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException - /// - /// Starting directory - /// Filename pattern match - /// Search subdirectories or only top level directory for files - /// List of files - public static IEnumerable SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly) - { - var enumOptions = new EnumerationOptions - { - RecurseSubdirectories = searchOption == SearchOption.AllDirectories, - IgnoreInaccessible = true, - ReturnSpecialDirectories = false, - MatchType = MatchType.Simple - }; - return Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath) p); - } + SaferDelete(destination); - /// - /// Creates a subdirectory or subdirectories on the specified path. - /// The specified path can be relative to this instance of the class. - /// - /// Fixes an issue with where it fails when the parent is a drive root. - /// - /// The specified path. This cannot be a different disk volume or Universal Naming Convention (UNC) name. - /// The last directory specified in - public static DirectoryInfo CreateSubdirectoryEx(this DirectoryInfo parent, string path) - { - if (parent.Root.FullName != parent.FullName || Path.IsPathRooted(path)) - return parent.CreateSubdirectory(path); + var dir = Path.GetDirectoryName(destination); + if (dir is null) + throw new DirectoryNotFoundException(); - // parent is a drive root and subDirectory is relative - //Solves a problem with DirectoryInfo.CreateSubdirectory where it fails - //If the parent DirectoryInfo is a drive root. - var fullPath = Path.GetFullPath(Path.Combine(parent.FullName, path)); - var directoryInfo = new DirectoryInfo(fullPath); - directoryInfo.Create(); - return directoryInfo; - } + Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir }); + Directory.CreateDirectory(dir); + + Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination }); + File.Move(source, destination); + Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination }); + } + catch (Exception e) + { + Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination }); + throw; + } + }); + + /// + /// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException + /// + /// Starting directory + /// Filename pattern match + /// Search subdirectories or only top level directory for files + /// List of files + public static IEnumerable SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + var enumOptions = new EnumerationOptions + { + RecurseSubdirectories = searchOption == SearchOption.AllDirectories, + IgnoreInaccessible = true, + ReturnSpecialDirectories = false, + MatchType = MatchType.Simple + }; + return Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath) p); + } + + /// + /// Creates a subdirectory or subdirectories on the specified path. + /// The specified path can be relative to this instance of the class. + /// + /// Fixes an issue with where it fails when the parent is a drive root. + /// + /// The specified path. This cannot be a different disk volume or Universal Naming Convention (UNC) name. + /// The last directory specified in + public static DirectoryInfo CreateSubdirectoryEx(this DirectoryInfo parent, string path) + { + if (parent.Root.FullName != parent.FullName || Path.IsPathRooted(path)) + return parent.CreateSubdirectory(path); + + // parent is a drive root and subDirectory is relative + //Solves a problem with DirectoryInfo.CreateSubdirectory where it fails + //If the parent DirectoryInfo is a drive root. + var fullPath = Path.GetFullPath(Path.Combine(parent.FullName, path)); + var directoryInfo = new DirectoryInfo(fullPath); + directoryInfo.Create(); + return directoryInfo; } } diff --git a/Source/FileManager/IJsonBackedDictionary.cs b/Source/FileManager/IJsonBackedDictionary.cs index 512dbd89..89510fa3 100644 --- a/Source/FileManager/IJsonBackedDictionary.cs +++ b/Source/FileManager/IJsonBackedDictionary.cs @@ -2,7 +2,6 @@ using System; using System.Linq; -#nullable enable namespace FileManager; public interface IJsonBackedDictionary diff --git a/Source/FileManager/LogArchiver.cs b/Source/FileManager/LogArchiver.cs index e0041da5..1f70e5ed 100644 --- a/Source/FileManager/LogArchiver.cs +++ b/Source/FileManager/LogArchiver.cs @@ -8,73 +8,71 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -#nullable enable -namespace FileManager +namespace FileManager; + +public sealed class LogArchiver : IAsyncDisposable, IDisposable { - public sealed class LogArchiver : IAsyncDisposable, IDisposable + public Encoding Encoding { get; set; } + public string FileName { get; } + private readonly ZipArchive archive; + + public LogArchiver(string filename) : this(filename, Encoding.UTF8) { } + public LogArchiver(string filename, Encoding encoding) { - public Encoding Encoding { get; set; } - public string FileName { get; } - private readonly ZipArchive archive; - - public LogArchiver(string filename) : this(filename, Encoding.UTF8) { } - public LogArchiver(string filename, Encoding encoding) - { - FileName = ArgumentValidator.EnsureNotNull(filename, nameof(filename)); - Encoding = ArgumentValidator.EnsureNotNull(encoding, nameof(encoding)); - archive = new ZipArchive(File.Open(FileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite), ZipArchiveMode.Update, false, Encoding); - } - - public void DeleteOlderThan(DateTime cutoffDate) - => DeleteEntries(archive.Entries.Where(e => e.LastWriteTime < cutoffDate).ToList()); - - public void DeleteOldestN(int quantity) - => DeleteEntries(archive.Entries.OrderBy(e => e.LastWriteTime).Take(quantity).ToList()); - - public void DeleteAllButNewestN(int quantity) - => DeleteEntries(archive.Entries.OrderByDescending(e => e.LastWriteTime).Skip(quantity).ToList()); - - private void DeleteEntries(List entries) - { - foreach (var e in entries) - e.Delete(); - } - - public async Task AddFileAsync(string name, JObject contents, string? comment = null) - { - ArgumentValidator.EnsureNotNull(contents, nameof(contents)); - await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment); - } - - public async Task AddFileAsync(string name, string contents, string? comment = null) - { - ArgumentValidator.EnsureNotNull(contents, nameof(contents)); - await AddFileAsync(name, Encoding.GetBytes(contents), comment); - } - - public Task AddFileAsync(string name, ReadOnlyMemory contents, string? comment = null) - { - ArgumentValidator.EnsureNotNull(name, nameof(name)); - - name = ReplacementCharacters.Barebones(true).ReplaceFilenameChars(name); - return Task.Run(() => AddFileInternal(name, contents.Span, comment)); - } - - private readonly object lockObj = new(); - private void AddFileInternal(string name, ReadOnlySpan contents, string? comment) - { - lock (lockObj) - { - var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); - - entry.Comment = comment; - using var entryStream = entry.Open(); - entryStream.Write(contents); - } - } - - public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose); - - public void Dispose() => archive.Dispose(); + FileName = ArgumentValidator.EnsureNotNull(filename, nameof(filename)); + Encoding = ArgumentValidator.EnsureNotNull(encoding, nameof(encoding)); + archive = new ZipArchive(File.Open(FileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite), ZipArchiveMode.Update, false, Encoding); } + + public void DeleteOlderThan(DateTime cutoffDate) + => DeleteEntries(archive.Entries.Where(e => e.LastWriteTime < cutoffDate).ToList()); + + public void DeleteOldestN(int quantity) + => DeleteEntries(archive.Entries.OrderBy(e => e.LastWriteTime).Take(quantity).ToList()); + + public void DeleteAllButNewestN(int quantity) + => DeleteEntries(archive.Entries.OrderByDescending(e => e.LastWriteTime).Skip(quantity).ToList()); + + private void DeleteEntries(List entries) + { + foreach (var e in entries) + e.Delete(); + } + + public async Task AddFileAsync(string name, JObject contents, string? comment = null) + { + ArgumentValidator.EnsureNotNull(contents, nameof(contents)); + await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment); + } + + public async Task AddFileAsync(string name, string contents, string? comment = null) + { + ArgumentValidator.EnsureNotNull(contents, nameof(contents)); + await AddFileAsync(name, Encoding.GetBytes(contents), comment); + } + + public Task AddFileAsync(string name, ReadOnlyMemory contents, string? comment = null) + { + ArgumentValidator.EnsureNotNull(name, nameof(name)); + + name = ReplacementCharacters.Barebones(true).ReplaceFilenameChars(name); + return Task.Run(() => AddFileInternal(name, contents.Span, comment)); + } + + private readonly object lockObj = new(); + private void AddFileInternal(string name, ReadOnlySpan contents, string? comment) + { + lock (lockObj) + { + var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); + + entry.Comment = comment; + using var entryStream = entry.Open(); + entryStream.Write(contents); + } + } + + public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose); + + public void Dispose() => archive.Dispose(); } diff --git a/Source/FileManager/LongPath.cs b/Source/FileManager/LongPath.cs index a6e00e3d..b605fda5 100644 --- a/Source/FileManager/LongPath.cs +++ b/Source/FileManager/LongPath.cs @@ -1,181 +1,177 @@ using Newtonsoft.Json; -using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Runtime.InteropServices; using System.Text; -#nullable enable -namespace FileManager +namespace FileManager; + +public class LongPath { - public class LongPath + //https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd + + public const int MaxFilenameLength = 255; + public static readonly int MaxDirectoryLength; + public static readonly int MaxPathLength; + private const int WIN_MAX_PATH = 260; + private const string WIN_LONG_PATH_PREFIX = @"\\?\"; + internal static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + internal static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + internal static readonly bool IsOSX = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + public string Path { get; } + + static LongPath() { - //https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd - - public const int MaxFilenameLength = 255; - public static readonly int MaxDirectoryLength; - public static readonly int MaxPathLength; - private const int WIN_MAX_PATH = 260; - private const string WIN_LONG_PATH_PREFIX = @"\\?\"; - internal static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - internal static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - internal static readonly bool IsOSX = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - - public string Path { get; } - - static LongPath() + if (IsWindows) { - if (IsWindows) - { - MaxPathLength = short.MaxValue; - MaxDirectoryLength = MaxPathLength - 13; - } - else if (IsOSX) - { - MaxPathLength = 1024; - MaxDirectoryLength = MaxPathLength - MaxFilenameLength; - } - else - { - MaxPathLength = 4096; - MaxDirectoryLength = MaxPathLength - MaxFilenameLength; - } + MaxPathLength = short.MaxValue; + MaxDirectoryLength = MaxPathLength - 13; } - - [JsonConstructor] - private LongPath(string path) + else if (IsOSX) { - if (IsWindows && path.Length > MaxPathLength) - throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} character limit. ({path})"); - if (!IsWindows && Encoding.UTF8.GetByteCount(path) > MaxPathLength) - throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} byte limit. ({path})"); - - Path = path; + MaxPathLength = 1024; + MaxDirectoryLength = MaxPathLength - MaxFilenameLength; } - - //Filename limits on NTFS and FAT filesystems are based on characters, - //but on ext* filesystems they're based on bytes. The ext* filesystems - //don't care about encoding, so how unicode characters are encoded is - ///a choice made by the linux kernel. As best as I can tell, pretty - //much everyone uses UTF-8. - public static int GetFilesystemStringLength(string filename) - => IsWindows ? filename.Length - : Encoding.UTF8.GetByteCount(filename); - - [return: NotNullIfNotNull(nameof(path))] - public static implicit operator LongPath?(string? path) + else { - if (path is null) return null; - - if (!IsWindows) return new LongPath(path); - - //File I/O functions in the Windows API convert "/" to "\" as part of converting - //the name to an NT-style name, except when using the "\\?\" prefix - path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar); - - if (path.StartsWith(WIN_LONG_PATH_PREFIX)) - return new LongPath(path); - else if ((path.Length > 2 && path[1] == ':') || path.StartsWith(@"UNC\")) - return new LongPath(WIN_LONG_PATH_PREFIX + path); - else if (path.StartsWith(@"\\")) - //The "\\?\" prefix can also be used with paths constructed according to the - //universal naming convention (UNC). To specify such a path using UNC, use - //the "\\?\UNC\" prefix. - return new LongPath(WIN_LONG_PATH_PREFIX + @"UNC\" + path.Substring(2)); - else - { - //These prefixes are not used as part of the path itself. They indicate that - //the path should be passed to the system with minimal modification, which - //means that you cannot use forward slashes to represent path separators, or - //a period to represent the current directory, or double dots to represent the - //parent directory. Because you cannot use the "\\?\" prefix with a relative - //path, relative paths are always limited to a total of MAX_PATH characters. - if (path.Length > WIN_MAX_PATH) - throw new System.IO.PathTooLongException(); - return new LongPath(path); - } + MaxPathLength = 4096; + MaxDirectoryLength = MaxPathLength - MaxFilenameLength; } - - [return: NotNullIfNotNull(nameof(path))] - public static implicit operator string?(LongPath? path) => path?.Path; - - [JsonIgnore] - public string ShortPathName - { - get - { - if (!IsWindows) return Path; - - //Short Path names are useful for navigating to the file in windows explorer, - //which will not recognize paths longer than MAX_PATH. Short path names are not - //always enabled on every volume. So to check if a volume enables short path - //names (aka 8dot3 names), run the following command from an elevated command - //prompt: - // - // fsutil 8dot3name query c: - // - //It will say: - // - // "Based on the above settings, 8dot3 name creation is [enabled/disabled] on c:" - // - //To enable short names on a volume on the system, run the following command - //from an elevated command prompt: - // - // fsutil 8dot3name set c: 0 - // - //or for all volumes on the system: - // - // fsutil 8dot3name set 0 - // - //Note that after enabling 8dot3 names on a volume, they will only be available - //for newly-created entries in ther file system. Existing entries made while - //8dot3 names were disabled will not be reachable by short paths. - - StringBuilder shortPathBuffer = new(MaxPathLength); - GetShortPathName(Path, shortPathBuffer, MaxPathLength); - return shortPathBuffer.ToString(); - } - } - - [JsonIgnore] - public string LongPathName - { - get - { - if (!IsWindows) return Path; - - StringBuilder longPathBuffer = new(MaxPathLength); - GetLongPathName(Path, longPathBuffer, MaxPathLength); - return longPathBuffer.ToString(); - } - } - - [JsonIgnore] - public string PathWithoutPrefix - { - get - { - if (!IsWindows) return Path; - return - Path.StartsWith(WIN_LONG_PATH_PREFIX) - ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length) - : Path; - } - } - - public override string ToString() => Path; - - public override int GetHashCode() => Path.GetHashCode(); - public override bool Equals(object? obj) => obj is LongPath other && Path == other.Path; - public static bool operator ==(LongPath? path1, LongPath? path2) => path1?.Equals(path2) is true; - public static bool operator !=(LongPath? path1, LongPath? path2) => path1 is null || path2 is null || !path1.Equals(path2); - - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern int GetLongPathName([MarshalAs(UnmanagedType.LPWStr)] string lpszShortPath, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszLongPath, int cchBuffer); - } + + [JsonConstructor] + private LongPath(string path) + { + if (IsWindows && path.Length > MaxPathLength) + throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} character limit. ({path})"); + if (!IsWindows && Encoding.UTF8.GetByteCount(path) > MaxPathLength) + throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} byte limit. ({path})"); + + Path = path; + } + + //Filename limits on NTFS and FAT filesystems are based on characters, + //but on ext* filesystems they're based on bytes. The ext* filesystems + //don't care about encoding, so how unicode characters are encoded is + ///a choice made by the linux kernel. As best as I can tell, pretty + //much everyone uses UTF-8. + public static int GetFilesystemStringLength(string filename) + => IsWindows ? filename.Length + : Encoding.UTF8.GetByteCount(filename); + + [return: NotNullIfNotNull(nameof(path))] + public static implicit operator LongPath?(string? path) + { + if (path is null) return null; + + if (!IsWindows) return new LongPath(path); + + //File I/O functions in the Windows API convert "/" to "\" as part of converting + //the name to an NT-style name, except when using the "\\?\" prefix + path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar); + + if (path.StartsWith(WIN_LONG_PATH_PREFIX)) + return new LongPath(path); + else if ((path.Length > 2 && path[1] == ':') || path.StartsWith(@"UNC\")) + return new LongPath(WIN_LONG_PATH_PREFIX + path); + else if (path.StartsWith(@"\\")) + //The "\\?\" prefix can also be used with paths constructed according to the + //universal naming convention (UNC). To specify such a path using UNC, use + //the "\\?\UNC\" prefix. + return new LongPath(WIN_LONG_PATH_PREFIX + @"UNC\" + path.Substring(2)); + else + { + //These prefixes are not used as part of the path itself. They indicate that + //the path should be passed to the system with minimal modification, which + //means that you cannot use forward slashes to represent path separators, or + //a period to represent the current directory, or double dots to represent the + //parent directory. Because you cannot use the "\\?\" prefix with a relative + //path, relative paths are always limited to a total of MAX_PATH characters. + if (path.Length > WIN_MAX_PATH) + throw new System.IO.PathTooLongException(); + return new LongPath(path); + } + } + + [return: NotNullIfNotNull(nameof(path))] + public static implicit operator string?(LongPath? path) => path?.Path; + + [JsonIgnore] + public string ShortPathName + { + get + { + if (!IsWindows) return Path; + + //Short Path names are useful for navigating to the file in windows explorer, + //which will not recognize paths longer than MAX_PATH. Short path names are not + //always enabled on every volume. So to check if a volume enables short path + //names (aka 8dot3 names), run the following command from an elevated command + //prompt: + // + // fsutil 8dot3name query c: + // + //It will say: + // + // "Based on the above settings, 8dot3 name creation is [enabled/disabled] on c:" + // + //To enable short names on a volume on the system, run the following command + //from an elevated command prompt: + // + // fsutil 8dot3name set c: 0 + // + //or for all volumes on the system: + // + // fsutil 8dot3name set 0 + // + //Note that after enabling 8dot3 names on a volume, they will only be available + //for newly-created entries in ther file system. Existing entries made while + //8dot3 names were disabled will not be reachable by short paths. + + StringBuilder shortPathBuffer = new(MaxPathLength); + GetShortPathName(Path, shortPathBuffer, MaxPathLength); + return shortPathBuffer.ToString(); + } + } + + [JsonIgnore] + public string LongPathName + { + get + { + if (!IsWindows) return Path; + + StringBuilder longPathBuffer = new(MaxPathLength); + GetLongPathName(Path, longPathBuffer, MaxPathLength); + return longPathBuffer.ToString(); + } + } + + [JsonIgnore] + public string PathWithoutPrefix + { + get + { + if (!IsWindows) return Path; + return + Path.StartsWith(WIN_LONG_PATH_PREFIX) + ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length) + : Path; + } + } + + public override string ToString() => Path; + + public override int GetHashCode() => Path.GetHashCode(); + public override bool Equals(object? obj) => obj is LongPath other && Path == other.Path; + public static bool operator ==(LongPath? path1, LongPath? path2) => path1?.Equals(path2) is true; + public static bool operator !=(LongPath? path1, LongPath? path2) => path1 is null || path2 is null || !path1.Equals(path2); + + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetLongPathName([MarshalAs(UnmanagedType.LPWStr)] string lpszShortPath, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszLongPath, int cchBuffer); + } diff --git a/Source/FileManager/MoveWithProgress.cs b/Source/FileManager/MoveWithProgress.cs index 87e10394..39bfcd9f 100644 --- a/Source/FileManager/MoveWithProgress.cs +++ b/Source/FileManager/MoveWithProgress.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; using Serilog; -#nullable enable namespace FileManager; public class MoveFileProgressEventArgs : EventArgs diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 481d5a02..bce92a95 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Text.RegularExpressions; -#nullable enable namespace FileManager.NamingTemplate; internal interface IClosingPropertyTag : IPropertyTag diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs index e7212ef2..6d84b2f2 100644 --- a/Source/FileManager/NamingTemplate/NamingTemplate.cs +++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs @@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; -#nullable enable namespace FileManager.NamingTemplate; public class NamingTemplate diff --git a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs index 04d349b5..4ffb8563 100644 --- a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs @@ -6,7 +6,6 @@ using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; -#nullable enable namespace FileManager.NamingTemplate; public delegate string PropertyFormatter(ITemplateTag templateTag, T value, string formatString); diff --git a/Source/FileManager/NamingTemplate/TagBase.cs b/Source/FileManager/NamingTemplate/TagBase.cs index 47cccc14..af80d5f4 100644 --- a/Source/FileManager/NamingTemplate/TagBase.cs +++ b/Source/FileManager/NamingTemplate/TagBase.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; -#nullable enable namespace FileManager.NamingTemplate; internal interface IPropertyTag diff --git a/Source/FileManager/NamingTemplate/TagCollection.cs b/Source/FileManager/NamingTemplate/TagCollection.cs index 4cf3f725..e2516a27 100644 --- a/Source/FileManager/NamingTemplate/TagCollection.cs +++ b/Source/FileManager/NamingTemplate/TagCollection.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; -#nullable enable namespace FileManager.NamingTemplate; /// A collection of s registered to a single . diff --git a/Source/FileManager/NamingTemplate/TemplatePart.cs b/Source/FileManager/NamingTemplate/TemplatePart.cs index 447a9e0f..9ef90885 100644 --- a/Source/FileManager/NamingTemplate/TemplatePart.cs +++ b/Source/FileManager/NamingTemplate/TemplatePart.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; -#nullable enable namespace FileManager.NamingTemplate; /// Represents one part of an evaluated . diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs index 6594ba5b..a297c585 100644 --- a/Source/FileManager/PersistentDictionary.cs +++ b/Source/FileManager/PersistentDictionary.cs @@ -5,275 +5,273 @@ using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -#nullable enable -namespace FileManager +namespace FileManager; + +public class PersistentDictionary : IJsonBackedDictionary { - public class PersistentDictionary : IJsonBackedDictionary - { - public string Filepath { get; } - public bool IsReadOnly { get; } + public string Filepath { get; } + public bool IsReadOnly { get; } - // optimize for strings. expectation is most settings will be strings and a rare exception will be something else - private Dictionary stringCache { get; } = new(); - private Dictionary objectCache { get; } = new(); + // optimize for strings. expectation is most settings will be strings and a rare exception will be something else + private Dictionary stringCache { get; } = new(); + private Dictionary objectCache { get; } = new(); - public PersistentDictionary(string filepath, bool isReadOnly = false) - { - Filepath = filepath; - IsReadOnly = isReadOnly; + public PersistentDictionary(string filepath, bool isReadOnly = false) + { + Filepath = filepath; + IsReadOnly = isReadOnly; - if (File.Exists(Filepath) || Path.GetDirectoryName(Filepath) is not string dirName) - return; + if (File.Exists(Filepath) || Path.GetDirectoryName(Filepath) is not string dirName) + return; - // will create any missing directories, incl subdirectories. if all already exist: no action - Directory.CreateDirectory(dirName); + // will create any missing directories, incl subdirectories. if all already exist: no action + Directory.CreateDirectory(dirName); - if (IsReadOnly) - return; + if (IsReadOnly) + return; - createNewFile(); - } - - [return: NotNullIfNotNull(nameof(defaultValue))] - public string? GetString(string propertyName, string? defaultValue = null) - { - if (!stringCache.ContainsKey(propertyName)) - { - var jObject = readFile(); - if (jObject.ContainsKey(propertyName)) - stringCache[propertyName] = jObject[propertyName]?.Value(); - else - stringCache[propertyName] = defaultValue; - } - - return stringCache[propertyName]; - } - - [return: NotNullIfNotNull(nameof(defaultValue))] - public T? GetNonString(string propertyName, T? defaultValue = default) - { - var obj = GetObject(propertyName); - - if (obj is null) - { - objectCache[propertyName] = defaultValue; - return defaultValue; - } - return IJsonBackedDictionary.UpCast(obj); - } - - public object? GetObject(string propertyName) - { - if (!objectCache.ContainsKey(propertyName)) - { - var jObject = readFile(); - if (!jObject.ContainsKey(propertyName)) - return null; - objectCache[propertyName] = jObject[propertyName]?.Value(); - } - - return objectCache[propertyName]; - } - - public string? GetStringFromJsonPath(string jsonPath) - { - if (!stringCache.ContainsKey(jsonPath)) - { - try - { - var jObject = readFile(); - var token = jObject.SelectToken(jsonPath); - if (token is null) - return null; - stringCache[jsonPath] = token.Value(); - } - catch - { - return null; - } - } - - return stringCache[jsonPath]; - } - - public bool Exists(string propertyName) => readFile().ContainsKey(propertyName); - - private object locker { get; } = new object(); - public void SetString(string propertyName, string? newValue) - { - // only do this check in string cache, NOT object cache - if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue) - return; - - // set cache - stringCache[propertyName] = newValue; - - writeFile(propertyName, newValue); - } - - public void SetNonString(string propertyName, object? newValue) - { - // set cache - objectCache[propertyName] = newValue; - - var parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue)); - writeFile(propertyName, parsedNewValue); - } - - public bool RemoveProperty(string propertyName) - { - if (IsReadOnly) - return false; - - var success = false; - try - { - lock (locker) - { - var jObject = readFile(); - - if (!jObject.ContainsKey(propertyName)) - return false; - - jObject.Remove(propertyName); - - var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); - - File.WriteAllText(Filepath, endContents); - success = true; - } - Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName); - } - catch { } - - return success; - } - - private void writeFile(string propertyName, JToken? newValue) - { - if (IsReadOnly) - return; - - // write new setting to file - lock (locker) - { - var jObject = readFile(); - var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); - - jObject[propertyName] = newValue; - var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); - - if (startContents == endContents) - return; - - File.WriteAllText(Filepath, endContents); - } - - try - { - var str = formatValueForLog(newValue?.ToString()); - Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = str }); - } - catch { } - } - - /// WILL ONLY set if already present. WILL NOT create new - /// Value was changed - public bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false) - { - if (IsReadOnly) - return false; - - var path = $"{jsonPath}.{propertyName}"; - - { - // only do this check in string cache, NOT object cache - if (stringCache.ContainsKey(path) && stringCache[path] == newValue) - return false; - - // set cache - stringCache[path] = newValue; - } - - try - { - lock (locker) - { - var jObject = readFile(); - var token = jObject.SelectToken(jsonPath); - if (token is null || token[propertyName] is null) - return false; - - var oldValue = token.Value(propertyName); - if (oldValue == newValue) - return false; - - token[propertyName] = newValue; - File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented)); - } - } - catch (Exception exDebug) - { - Serilog.Log.Logger.Debug(exDebug, "Silent failure"); - return false; - } - - if (!suppressLogging) - { - try - { - var str = formatValueForLog(newValue?.ToString()); - Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { jsonPath, propertyName, newValue = str }); - } - catch { } - } - - return true; - } - - private static string formatValueForLog(string? value) - => value is null ? "[null]" - : string.IsNullOrEmpty(value) ? "[empty]" - : string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]" - : value.Length > 100 ? $"[Length={value.Length}] {value[0..50]}...{value[^50..^0]}" - : value; - - private JObject readFile() - { - if (!File.Exists(Filepath)) - { - var msg = "Unrecoverable error. Settings file cannot be found"; - var ex = new FileNotFoundException(msg, Filepath); - Serilog.Log.Logger.Error(ex, msg); - throw ex; - } - - var settingsJsonContents = File.ReadAllText(Filepath); - - if (string.IsNullOrWhiteSpace(settingsJsonContents)) - { - createNewFile(); - settingsJsonContents = File.ReadAllText(Filepath); - } - - var jObject = JsonConvert.DeserializeObject(settingsJsonContents); - - if (jObject is null) - { - var msg = "Unrecoverable error. Unable to read settings from Settings file"; - var ex = new NullReferenceException(msg); - Serilog.Log.Logger.Error(ex, msg); - throw ex; - } - - return jObject; - } - - private void createNewFile() - { - File.WriteAllText(Filepath, "{}"); - } - - public JObject GetJObject() => readFile(); + createNewFile(); } + + [return: NotNullIfNotNull(nameof(defaultValue))] + public string? GetString(string propertyName, string? defaultValue = null) + { + if (!stringCache.ContainsKey(propertyName)) + { + var jObject = readFile(); + if (jObject.ContainsKey(propertyName)) + stringCache[propertyName] = jObject[propertyName]?.Value(); + else + stringCache[propertyName] = defaultValue; + } + + return stringCache[propertyName]; + } + + [return: NotNullIfNotNull(nameof(defaultValue))] + public T? GetNonString(string propertyName, T? defaultValue = default) + { + var obj = GetObject(propertyName); + + if (obj is null) + { + objectCache[propertyName] = defaultValue; + return defaultValue; + } + return IJsonBackedDictionary.UpCast(obj); + } + + public object? GetObject(string propertyName) + { + if (!objectCache.ContainsKey(propertyName)) + { + var jObject = readFile(); + if (!jObject.ContainsKey(propertyName)) + return null; + objectCache[propertyName] = jObject[propertyName]?.Value(); + } + + return objectCache[propertyName]; + } + + public string? GetStringFromJsonPath(string jsonPath) + { + if (!stringCache.ContainsKey(jsonPath)) + { + try + { + var jObject = readFile(); + var token = jObject.SelectToken(jsonPath); + if (token is null) + return null; + stringCache[jsonPath] = token.Value(); + } + catch + { + return null; + } + } + + return stringCache[jsonPath]; + } + + public bool Exists(string propertyName) => readFile().ContainsKey(propertyName); + + private object locker { get; } = new object(); + public void SetString(string propertyName, string? newValue) + { + // only do this check in string cache, NOT object cache + if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue) + return; + + // set cache + stringCache[propertyName] = newValue; + + writeFile(propertyName, newValue); + } + + public void SetNonString(string propertyName, object? newValue) + { + // set cache + objectCache[propertyName] = newValue; + + var parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue)); + writeFile(propertyName, parsedNewValue); + } + + public bool RemoveProperty(string propertyName) + { + if (IsReadOnly) + return false; + + var success = false; + try + { + lock (locker) + { + var jObject = readFile(); + + if (!jObject.ContainsKey(propertyName)) + return false; + + jObject.Remove(propertyName); + + var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); + + File.WriteAllText(Filepath, endContents); + success = true; + } + Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName); + } + catch { } + + return success; + } + + private void writeFile(string propertyName, JToken? newValue) + { + if (IsReadOnly) + return; + + // write new setting to file + lock (locker) + { + var jObject = readFile(); + var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); + + jObject[propertyName] = newValue; + var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); + + if (startContents == endContents) + return; + + File.WriteAllText(Filepath, endContents); + } + + try + { + var str = formatValueForLog(newValue?.ToString()); + Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = str }); + } + catch { } + } + + /// WILL ONLY set if already present. WILL NOT create new + /// Value was changed + public bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false) + { + if (IsReadOnly) + return false; + + var path = $"{jsonPath}.{propertyName}"; + + { + // only do this check in string cache, NOT object cache + if (stringCache.ContainsKey(path) && stringCache[path] == newValue) + return false; + + // set cache + stringCache[path] = newValue; + } + + try + { + lock (locker) + { + var jObject = readFile(); + var token = jObject.SelectToken(jsonPath); + if (token is null || token[propertyName] is null) + return false; + + var oldValue = token.Value(propertyName); + if (oldValue == newValue) + return false; + + token[propertyName] = newValue; + File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented)); + } + } + catch (Exception exDebug) + { + Serilog.Log.Logger.Debug(exDebug, "Silent failure"); + return false; + } + + if (!suppressLogging) + { + try + { + var str = formatValueForLog(newValue?.ToString()); + Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { jsonPath, propertyName, newValue = str }); + } + catch { } + } + + return true; + } + + private static string formatValueForLog(string? value) + => value is null ? "[null]" + : string.IsNullOrEmpty(value) ? "[empty]" + : string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]" + : value.Length > 100 ? $"[Length={value.Length}] {value[0..50]}...{value[^50..^0]}" + : value; + + private JObject readFile() + { + if (!File.Exists(Filepath)) + { + var msg = "Unrecoverable error. Settings file cannot be found"; + var ex = new FileNotFoundException(msg, Filepath); + Serilog.Log.Logger.Error(ex, msg); + throw ex; + } + + var settingsJsonContents = File.ReadAllText(Filepath); + + if (string.IsNullOrWhiteSpace(settingsJsonContents)) + { + createNewFile(); + settingsJsonContents = File.ReadAllText(Filepath); + } + + var jObject = JsonConvert.DeserializeObject(settingsJsonContents); + + if (jObject is null) + { + var msg = "Unrecoverable error. Unable to read settings from Settings file"; + var ex = new NullReferenceException(msg); + Serilog.Log.Logger.Error(ex, msg); + throw ex; + } + + return jObject; + } + + private void createNewFile() + { + File.WriteAllText(Filepath, "{}"); + } + + public JObject GetJObject() => readFile(); } diff --git a/Source/FileManager/ReplacementCharacters.cs b/Source/FileManager/ReplacementCharacters.cs index 476299fb..d7eba9dd 100644 --- a/Source/FileManager/ReplacementCharacters.cs +++ b/Source/FileManager/ReplacementCharacters.cs @@ -5,339 +5,337 @@ using System.Collections.Generic; using System.IO; using System.Linq; -#nullable enable -namespace FileManager +namespace FileManager; + +public record Replacement { - public record Replacement + public const int FIXED_COUNT = 6; + + internal const char QUOTE_MARK = '"'; + [JsonIgnore] public bool Mandatory { get; set; } + [JsonProperty] public char CharacterToReplace { get; private set; } + [JsonProperty] public string ReplacementString { get; private set; } + [JsonProperty] public string Description { get; set; } + public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})"; + + public Replacement(char charToReplace, string replacementString, string description) { - public const int FIXED_COUNT = 6; + CharacterToReplace = charToReplace; + ReplacementString = replacementString; + Description = description; + } + private Replacement(char charToReplace, string replacementString, string description, bool mandatory) + : this(charToReplace, replacementString, description) + { + Mandatory = mandatory; + } - internal const char QUOTE_MARK = '"'; - [JsonIgnore] public bool Mandatory { get; set; } - [JsonProperty] public char CharacterToReplace { get; private set; } - [JsonProperty] public string ReplacementString { get; private set; } - [JsonProperty] public string Description { get; set; } - public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})"; + public void Update(char charToReplace, string replacementString, string description) + { + ReplacementString = replacementString; - public Replacement(char charToReplace, string replacementString, string description) + if (!Mandatory) { CharacterToReplace = charToReplace; - ReplacementString = replacementString; Description = description; } - private Replacement(char charToReplace, string replacementString, string description, bool mandatory) - : this(charToReplace, replacementString, description) - { - Mandatory = mandatory; - } - - public void Update(char charToReplace, string replacementString, string description) - { - ReplacementString = replacementString; - - if (!Mandatory) - { - CharacterToReplace = charToReplace; - Description = description; - } - } - - public static Replacement OtherInvalid(string replacement) => new(default, replacement, "All other invalid characters", true); - public static Replacement FilenameForwardSlash(string replacement) => new('/', replacement, "Forward Slash (Filename Only)", true); - public static Replacement FilenameBackSlash(string replacement) => new('\\', replacement, "Back Slash (Filename Only)", true); - public static Replacement OpenQuote(string replacement) => new('"', replacement, "Open Quote", true); - public static Replacement CloseQuote(string replacement) => new('"', replacement, "Close Quote", true); - public static Replacement OtherQuote(string replacement) => new('"', replacement, "Other Quote", true); - public static Replacement Colon(string replacement) => new(':', replacement, "Colon"); - public static Replacement Asterisk(string replacement) => new('*', replacement, "Asterisk"); - public static Replacement QuestionMark(string replacement) => new('?', replacement, "Question Mark"); - public static Replacement OpenAngleBracket(string replacement) => new('<', replacement, "Open Angle Bracket"); - public static Replacement CloseAngleBracket(string replacement) => new('>', replacement, "Close Angle Bracket"); - public static Replacement Pipe(string replacement) => new('|', replacement, "Vertical Line"); - } - [JsonConverter(typeof(ReplacementCharactersConverter))] - public class ReplacementCharacters + public static Replacement OtherInvalid(string replacement) => new(default, replacement, "All other invalid characters", true); + public static Replacement FilenameForwardSlash(string replacement) => new('/', replacement, "Forward Slash (Filename Only)", true); + public static Replacement FilenameBackSlash(string replacement) => new('\\', replacement, "Back Slash (Filename Only)", true); + public static Replacement OpenQuote(string replacement) => new('"', replacement, "Open Quote", true); + public static Replacement CloseQuote(string replacement) => new('"', replacement, "Close Quote", true); + public static Replacement OtherQuote(string replacement) => new('"', replacement, "Other Quote", true); + public static Replacement Colon(string replacement) => new(':', replacement, "Colon"); + public static Replacement Asterisk(string replacement) => new('*', replacement, "Asterisk"); + public static Replacement QuestionMark(string replacement) => new('?', replacement, "Question Mark"); + public static Replacement OpenAngleBracket(string replacement) => new('<', replacement, "Open Angle Bracket"); + public static Replacement CloseAngleBracket(string replacement) => new('>', replacement, "Close Angle Bracket"); + public static Replacement Pipe(string replacement) => new('|', replacement, "Vertical Line"); + +} + +[JsonConverter(typeof(ReplacementCharactersConverter))] +public class ReplacementCharacters +{ + public override bool Equals(object? obj) { - public override bool Equals(object? obj) + if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count) { - if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count) - { - for (int i = 0; i < Replacements.Count; i++) - if (Replacements[i] != second.Replacements[i]) - return false; + for (int i = 0; i < Replacements.Count; i++) + if (Replacements[i] != second.Replacements[i]) + return false; - return true; - } - return false; + return true; } - public override int GetHashCode() => Replacements.GetHashCode(); + return false; + } + public override int GetHashCode() => Replacements.GetHashCode(); - public static ReplacementCharacters Default(bool ntfs) => ntfs ? HiFi_NTFS : HiFi_Other; - public static ReplacementCharacters LoFiDefault(bool ntfs) => ntfs ? LoFi_NTFS : LoFi_Other; - public static ReplacementCharacters Barebones(bool ntfs) => ntfs ? BareBones_NTFS : BareBones_Other; + public static ReplacementCharacters Default(bool ntfs) => ntfs ? HiFi_NTFS : HiFi_Other; + public static ReplacementCharacters LoFiDefault(bool ntfs) => ntfs ? LoFi_NTFS : LoFi_Other; + public static ReplacementCharacters Barebones(bool ntfs) => ntfs ? BareBones_NTFS : BareBones_Other; - #region Defaults - private static readonly ReplacementCharacters HiFi_NTFS = new() + #region Defaults + private static readonly ReplacementCharacters HiFi_NTFS = new() + { + Replacements = [ + Replacement.OtherInvalid("_"), + Replacement.FilenameForwardSlash("∕"), + Replacement.FilenameBackSlash(""), + Replacement.OpenQuote("“"), + Replacement.CloseQuote("”"), + Replacement.OtherQuote("""), + Replacement.OpenAngleBracket("<"), + Replacement.CloseAngleBracket(">"), + Replacement.Colon("_"), + Replacement.Asterisk("✱"), + Replacement.QuestionMark("?"), + Replacement.Pipe("⏐")] + }; + + private static readonly ReplacementCharacters HiFi_Other = new() + { + Replacements = [ + Replacement.OtherInvalid("_"), + Replacement.FilenameForwardSlash("∕"), + Replacement.FilenameBackSlash("\\"), + Replacement.OpenQuote("“"), + Replacement.CloseQuote("”"), + Replacement.OtherQuote("\"")] + }; + + private static readonly ReplacementCharacters LoFi_NTFS = new() + { + Replacements = [ + Replacement.OtherInvalid("_"), + Replacement.FilenameForwardSlash("_"), + Replacement.FilenameBackSlash("_"), + Replacement.OpenQuote("'"), + Replacement.CloseQuote("'"), + Replacement.OtherQuote("'"), + Replacement.OpenAngleBracket("{"), + Replacement.CloseAngleBracket("}"), + Replacement.Colon("-")] + }; + + private static readonly ReplacementCharacters LoFi_Other = new() + { + Replacements = [ + Replacement.OtherInvalid("_"), + Replacement.FilenameForwardSlash("_"), + Replacement.FilenameBackSlash("\\"), + Replacement.OpenQuote("\""), + Replacement.CloseQuote("\""), + Replacement.OtherQuote("\"")] + }; + + private static readonly ReplacementCharacters BareBones_NTFS = new() + { + Replacements = [ + Replacement.OtherInvalid("_"), + Replacement.FilenameForwardSlash("_"), + Replacement.FilenameBackSlash("_"), + Replacement.OpenQuote("_"), + Replacement.CloseQuote("_"), + Replacement.OtherQuote("_")] + }; + + private static readonly ReplacementCharacters BareBones_Other = new() + { + Replacements = [ + Replacement.OtherInvalid("_"), + Replacement.FilenameForwardSlash("_"), + Replacement.FilenameBackSlash("\\"), + Replacement.OpenQuote("\""), + Replacement.CloseQuote("\""), + Replacement.OtherQuote("\"")] + }; + #endregion + /// + /// Characters to consider invalid in filenames in addition to those returned by + /// + public static char[] AdditionalInvalidFilenameCharacters { get; set; } = []; + + internal static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT; + + private static char[] invalidPathChars { get; } = Path.GetInvalidFileNameChars().Except(new[] { + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar + }).ToArray(); + + private static char[] invalidSlashes { get; } = Path.GetInvalidFileNameChars().Intersect(new[] { + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar + }).ToArray(); + + required public IReadOnlyList Replacements { get; init; } + private string DefaultReplacement => Replacements[0].ReplacementString; + private Replacement ForwardSlash => Replacements[1]; + private Replacement BackSlash => Replacements[2]; + private string OpenQuote => Replacements[3].ReplacementString; + private string CloseQuote => Replacements[4].ReplacementString; + private string OtherQuote => Replacements[5].ReplacementString; + + private string GetFilenameCharReplacement(char toReplace, char preceding, char succeding) + { + if (toReplace == ForwardSlash.CharacterToReplace) + return ForwardSlash.ReplacementString; + else if (toReplace == BackSlash.CharacterToReplace) + return BackSlash.ReplacementString; + else return GetPathCharReplacement(toReplace, preceding, succeding); + } + private string GetPathCharReplacement(char toReplace, char preceding, char succeding) + { + if (toReplace == Replacement.QUOTE_MARK) { - Replacements = [ - Replacement.OtherInvalid("_"), - Replacement.FilenameForwardSlash("∕"), - Replacement.FilenameBackSlash(""), - Replacement.OpenQuote("“"), - Replacement.CloseQuote("”"), - Replacement.OtherQuote("""), - Replacement.OpenAngleBracket("<"), - Replacement.CloseAngleBracket(">"), - Replacement.Colon("_"), - Replacement.Asterisk("✱"), - Replacement.QuestionMark("?"), - Replacement.Pipe("⏐")] - }; - - private static readonly ReplacementCharacters HiFi_Other = new() - { - Replacements = [ - Replacement.OtherInvalid("_"), - Replacement.FilenameForwardSlash("∕"), - Replacement.FilenameBackSlash("\\"), - Replacement.OpenQuote("“"), - Replacement.CloseQuote("”"), - Replacement.OtherQuote("\"")] - }; - - private static readonly ReplacementCharacters LoFi_NTFS = new() - { - Replacements = [ - Replacement.OtherInvalid("_"), - Replacement.FilenameForwardSlash("_"), - Replacement.FilenameBackSlash("_"), - Replacement.OpenQuote("'"), - Replacement.CloseQuote("'"), - Replacement.OtherQuote("'"), - Replacement.OpenAngleBracket("{"), - Replacement.CloseAngleBracket("}"), - Replacement.Colon("-")] - }; - - private static readonly ReplacementCharacters LoFi_Other = new() - { - Replacements = [ - Replacement.OtherInvalid("_"), - Replacement.FilenameForwardSlash("_"), - Replacement.FilenameBackSlash("\\"), - Replacement.OpenQuote("\""), - Replacement.CloseQuote("\""), - Replacement.OtherQuote("\"")] - }; - - private static readonly ReplacementCharacters BareBones_NTFS = new() - { - Replacements = [ - Replacement.OtherInvalid("_"), - Replacement.FilenameForwardSlash("_"), - Replacement.FilenameBackSlash("_"), - Replacement.OpenQuote("_"), - Replacement.CloseQuote("_"), - Replacement.OtherQuote("_")] - }; - - private static readonly ReplacementCharacters BareBones_Other = new() - { - Replacements = [ - Replacement.OtherInvalid("_"), - Replacement.FilenameForwardSlash("_"), - Replacement.FilenameBackSlash("\\"), - Replacement.OpenQuote("\""), - Replacement.CloseQuote("\""), - Replacement.OtherQuote("\"")] - }; - #endregion - /// - /// Characters to consider invalid in filenames in addition to those returned by - /// - public static char[] AdditionalInvalidFilenameCharacters { get; set; } = []; - - internal static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT; - - private static char[] invalidPathChars { get; } = Path.GetInvalidFileNameChars().Except(new[] { - Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar - }).ToArray(); - - private static char[] invalidSlashes { get; } = Path.GetInvalidFileNameChars().Intersect(new[] { - Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar - }).ToArray(); - - required public IReadOnlyList Replacements { get; init; } - private string DefaultReplacement => Replacements[0].ReplacementString; - private Replacement ForwardSlash => Replacements[1]; - private Replacement BackSlash => Replacements[2]; - private string OpenQuote => Replacements[3].ReplacementString; - private string CloseQuote => Replacements[4].ReplacementString; - private string OtherQuote => Replacements[5].ReplacementString; - - private string GetFilenameCharReplacement(char toReplace, char preceding, char succeding) - { - if (toReplace == ForwardSlash.CharacterToReplace) - return ForwardSlash.ReplacementString; - else if (toReplace == BackSlash.CharacterToReplace) - return BackSlash.ReplacementString; - else return GetPathCharReplacement(toReplace, preceding, succeding); - } - private string GetPathCharReplacement(char toReplace, char preceding, char succeding) - { - if (toReplace == Replacement.QUOTE_MARK) - { - if ( - preceding == default || - ( - !char.IsLetter(preceding) && - !char.IsNumber(preceding) && - (char.IsLetter(succeding) || char.IsNumber(succeding)) - ) - ) - return OpenQuote; - else if ( - succeding == default || - ( - !char.IsLetter(succeding) && - !char.IsNumber(succeding) && - (char.IsLetter(preceding) || char.IsNumber(preceding)) - ) - ) - return CloseQuote; - else - return OtherQuote; - } - - if (!IsWindows && toReplace == BackSlash.CharacterToReplace) - return BackSlash.ReplacementString; - - //Replace any other non-mandatory characters - for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++) - { - var r = Replacements[i]; - if (r.CharacterToReplace == toReplace) - return r.ReplacementString; - } - return DefaultReplacement; - } - - private static bool CharIsPathInvalid(char c) - => invalidPathChars.Contains(c) || AdditionalInvalidFilenameCharacters.Contains(c); - - public static bool ContainsInvalidPathChar(string path) - => path.Any(CharIsPathInvalid); - public static bool ContainsInvalidFilenameChar(string path) - => ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c)); - - public string ReplaceFilenameChars(string fileName) - { - if (string.IsNullOrEmpty(fileName)) return string.Empty; - var builder = new System.Text.StringBuilder(); - for (var i = 0; i < fileName.Length; i++) - { - var c = fileName[i]; - - if (CharIsPathInvalid(c) - || invalidSlashes.Contains(c) - || Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ ) - { - char preceding = i > 0 ? fileName[i - 1] : default; - char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default; - builder.Append(GetFilenameCharReplacement(c, preceding, succeeding)); - } - else - builder.Append(c); - } - return builder.ToString(); - } - - public string ReplacePathChars(string pathStr) - { - if (string.IsNullOrEmpty(pathStr)) return string.Empty; - - var builder = new System.Text.StringBuilder(); - for (var i = 0; i < pathStr.Length; i++) - { - var c = pathStr[i]; - - if ( + if ( + preceding == default || ( - CharIsPathInvalid(c) - || ( // Replace any other legal characters that they user wants. - c != Path.DirectorySeparatorChar - && c != Path.AltDirectorySeparatorChar - && Replacements.Any(r => r.CharacterToReplace == c) - ) - ) - && !( // replace all colons except drive letter designator on Windows - c == ':' - && i == 1 - && Path.IsPathRooted(pathStr) - && IsWindows + !char.IsLetter(preceding) && + !char.IsNumber(preceding) && + (char.IsLetter(succeding) || char.IsNumber(succeding)) ) ) - { - char preceding = i > 0 ? pathStr[i - 1] : default; - char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default; - builder.Append(GetPathCharReplacement(c, preceding, succeeding)); - } - else - builder.Append(c); - } - return builder.ToString(); + return OpenQuote; + else if ( + succeding == default || + ( + !char.IsLetter(succeding) && + !char.IsNumber(succeding) && + (char.IsLetter(preceding) || char.IsNumber(preceding)) + ) + ) + return CloseQuote; + else + return OtherQuote; } + + if (!IsWindows && toReplace == BackSlash.CharacterToReplace) + return BackSlash.ReplacementString; + + //Replace any other non-mandatory characters + for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++) + { + var r = Replacements[i]; + if (r.CharacterToReplace == toReplace) + return r.ReplacementString; + } + return DefaultReplacement; } - #region JSON Converter - internal class ReplacementCharactersConverter : JsonConverter + private static bool CharIsPathInvalid(char c) + => invalidPathChars.Contains(c) || AdditionalInvalidFilenameCharacters.Contains(c); + + public static bool ContainsInvalidPathChar(string path) + => path.Any(CharIsPathInvalid); + public static bool ContainsInvalidFilenameChar(string path) + => ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c)); + + public string ReplaceFilenameChars(string fileName) { - public override bool CanConvert(Type objectType) - => objectType == typeof(ReplacementCharacters); - - public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + if (string.IsNullOrEmpty(fileName)) return string.Empty; + var builder = new System.Text.StringBuilder(); + for (var i = 0; i < fileName.Length; i++) { - var defaults = ReplacementCharacters.Default(ReplacementCharacters.IsWindows).Replacements; + var c = fileName[i]; - var jObj = JObject.Load(reader); - var replaceArr = jObj[nameof(Replacement)]; - var dict = replaceArr?.ToObject()?.ToList() ?? defaults; - - //Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid. - //If not, reset to default. - for (int i = 0; i < Replacement.FIXED_COUNT; i++) + if (CharIsPathInvalid(c) + || invalidSlashes.Contains(c) + || Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ ) { - if (dict.Count < Replacement.FIXED_COUNT - || dict[i].CharacterToReplace != defaults[i].CharacterToReplace - || dict[i].Description != defaults[i].Description) - { - dict = defaults; - break; - } + char preceding = i > 0 ? fileName[i - 1] : default; + char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default; + builder.Append(GetFilenameCharReplacement(c, preceding, succeeding)); + } + else + builder.Append(c); + } + return builder.ToString(); + } - //First FIXED_COUNT are mandatory - dict[i].Mandatory = true; + public string ReplacePathChars(string pathStr) + { + if (string.IsNullOrEmpty(pathStr)) return string.Empty; + + var builder = new System.Text.StringBuilder(); + for (var i = 0; i < pathStr.Length; i++) + { + var c = pathStr[i]; + + if ( + ( + CharIsPathInvalid(c) + || ( // Replace any other legal characters that they user wants. + c != Path.DirectorySeparatorChar + && c != Path.AltDirectorySeparatorChar + && Replacements.Any(r => r.CharacterToReplace == c) + ) + ) + && !( // replace all colons except drive letter designator on Windows + c == ':' + && i == 1 + && Path.IsPathRooted(pathStr) + && IsWindows + ) + ) + { + char preceding = i > 0 ? pathStr[i - 1] : default; + char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default; + builder.Append(GetPathCharReplacement(c, preceding, succeeding)); + } + else + builder.Append(c); + } + return builder.ToString(); + } +} + +#region JSON Converter +internal class ReplacementCharactersConverter : JsonConverter +{ + public override bool CanConvert(Type objectType) + => objectType == typeof(ReplacementCharacters); + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var defaults = ReplacementCharacters.Default(ReplacementCharacters.IsWindows).Replacements; + + var jObj = JObject.Load(reader); + var replaceArr = jObj[nameof(Replacement)]; + var dict = replaceArr?.ToObject()?.ToList() ?? defaults; + + //Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid. + //If not, reset to default. + for (int i = 0; i < Replacement.FIXED_COUNT; i++) + { + if (dict.Count < Replacement.FIXED_COUNT + || dict[i].CharacterToReplace != defaults[i].CharacterToReplace + || dict[i].Description != defaults[i].Description) + { + dict = defaults; + break; } - return new ReplacementCharacters { Replacements = dict }; + //First FIXED_COUNT are mandatory + dict[i].Mandatory = true; } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - if (value is not ReplacementCharacters replacements) - return; - - var propertyNames = replacements.Replacements - .Select(JObject.FromObject).ToList(); - - var prop = new JProperty(nameof(Replacement), new JArray(propertyNames)); - - var obj = new JObject(); - obj.AddFirst(prop); - obj.WriteTo(writer); - } + return new ReplacementCharacters { Replacements = dict }; + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is not ReplacementCharacters replacements) + return; + + var propertyNames = replacements.Replacements + .Select(JObject.FromObject).ToList(); + + var prop = new JProperty(nameof(Replacement), new JArray(propertyNames)); + + var obj = new JObject(); + obj.AddFirst(prop); + obj.WriteTo(writer); } - #endregion } +#endregion diff --git a/Source/LibationAvalonia/AvaloniaUtils.cs b/Source/LibationAvalonia/AvaloniaUtils.cs index f2f5d8c2..259181e5 100644 --- a/Source/LibationAvalonia/AvaloniaUtils.cs +++ b/Source/LibationAvalonia/AvaloniaUtils.cs @@ -29,8 +29,11 @@ namespace LibationAvalonia private static Bitmap? defaultImage; - public static Bitmap TryLoadImageOrDefault(byte[] picture, PictureSize defaultSize = PictureSize.Native) + public static Bitmap TryLoadImageOrDefault(byte[]? picture, PictureSize defaultSize = PictureSize.Native) { + if (picture is null || picture.Length == 0) + return getDefaultImage(); + try { using var ms = new System.IO.MemoryStream(picture); @@ -39,7 +42,17 @@ namespace LibationAvalonia catch { using var ms = new System.IO.MemoryStream(PictureStorage.GetDefaultImage(defaultSize)); - return defaultImage ??= new Bitmap(ms); + return getDefaultImage(); + } + + Bitmap getDefaultImage() + { + if (defaultImage is null) + { + using var ms = new System.IO.MemoryStream(PictureStorage.GetDefaultImage(defaultSize)); + defaultImage = new Bitmap(ms); + } + return defaultImage; } } } diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs index 19ae4482..60b9442d 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs @@ -28,7 +28,7 @@ namespace LibationAvalonia.Controls.Settings { using var accounts = AudibleApiStorage.GetAccountsSettingsPersister(); - if (!accounts.AccountsSettings.Accounts.All(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) + if (!accounts.AccountsSettings.Accounts.All(a => a.IdentityTokens?.DeviceType == AudibleApi.Resources.DeviceType)) { if (VisualRoot is Window parent) { @@ -44,7 +44,7 @@ namespace LibationAvalonia.Controls.Settings { foreach (var account in accounts.AccountsSettings.Accounts.ToArray()) { - if (account.IdentityTokens.DeviceType != AudibleApi.Resources.DeviceType) + if (account.Locale is not null && account.IdentityTokens?.DeviceType != AudibleApi.Resources.DeviceType) { accounts.AccountsSettings.Delete(account); var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name); diff --git a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs index e9ef7fc5..51677fd2 100644 --- a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs @@ -40,7 +40,7 @@ namespace LibationAvalonia.Dialogs { LibraryScan = account.LibraryScan; AccountId = account.AccountId; - SelectedLocale = Locales.Single(l => l.Name == account.Locale.Name); + SelectedLocale = Locales.Single(l => l.Name == account.Locale?.Name); AccountName = account.AccountName; } } @@ -121,15 +121,15 @@ namespace LibationAvalonia.Dialogs try { var jsonText = File.ReadAllText(selectedFile); - var mkbAuth = Mkb79Auth.FromJson(jsonText); + var mkbAuth = Mkb79Auth.FromJson(jsonText) ?? throw new Exception("File did not contain valid mkb79/audible-cli account data."); var account = await mkbAuth.ToAccountAsync(); // without transaction, accounts persister will write ANY EDIT immediately to file using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name)) + if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name)) { - await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account"); + await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale?.Name}", "Cannot Add Duplicate Account"); return; } @@ -241,7 +241,7 @@ namespace LibationAvalonia.Dialogs // without transaction, accounts persister will write ANY EDIT immediately to file using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == acc.AccountId && a.Locale.Name == acc.SelectedLocale?.Name); + var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == acc.AccountId && a.Locale?.Name == acc.SelectedLocale?.Name); if (account is null) return; diff --git a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs index 1b3536ca..a48f77b0 100644 --- a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs @@ -111,7 +111,8 @@ namespace LibationAvalonia.Dialogs Tags = libraryBook.Book.UserDefinedItem.Tags; //init cover image - var picture = PictureStorage.GetPictureSynchronously(new PictureDefinition(libraryBook.Book.PictureId, PictureSize._80x80)); + byte[]? picture = libraryBook.Book.PictureId is not string picId ? null + : PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, PictureSize._80x80)); Cover = AvaloniaUtils.TryLoadImageOrDefault(picture, PictureSize._80x80); var title = string.IsNullOrEmpty(Book.Subtitle) ? Book.Title : $"{Book.Title}\r\n {Book.Subtitle}"; diff --git a/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs index bd301587..52867852 100644 --- a/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs @@ -17,7 +17,7 @@ namespace LibationAvalonia.Dialogs { Account = account; IsChecked = account.LibraryScan; - Text = $"{account.AccountName} ({account.AccountId} - {account.Locale.Name})"; + Text = $"{account.AccountName} ({account.AccountId} - {account.Locale?.Name})"; } public Account Account { get; } public string Text { get; } diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index 9c69f4b4..be03216e 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -79,7 +79,7 @@ - + diff --git a/Source/LibationAvalonia/MessageBox.cs b/Source/LibationAvalonia/MessageBox.cs index 947d7d47..e2d2f9a4 100644 --- a/Source/LibationAvalonia/MessageBox.cs +++ b/Source/LibationAvalonia/MessageBox.cs @@ -114,7 +114,7 @@ namespace LibationAvalonia return await DisplayWindow(dialog, owner); }); - private static MessageBoxWindow CreateMessageBox(Window? owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) + private static MessageBoxWindow CreateMessageBox(TopLevel? owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) { var dialog = new MessageBoxWindow(saveAndRestorePosition); diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs index 118df593..80bc3379 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs @@ -58,7 +58,8 @@ namespace LibationAvalonia.ViewModels return persister.AccountsSettings .GetAll() .Where(a => a.LibraryScan) - .Select(a => (a.AccountId, a.Locale.Name)) + .Select(a => (a.AccountId, a.Locale?.Name)) + .OfType<(string, string)>() .ToList(); } diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs index 708b0585..b817703c 100644 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs +++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs @@ -2,11 +2,9 @@ using Avalonia.Data.Converters; using Avalonia.Threading; using DataLayer; -using LibationFileManager; using LibationUiBase; using LibationUiBase.ProcessQueue; using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; @@ -30,49 +28,49 @@ namespace LibationAvalonia.Views if (Design.IsDesignMode) { ViewModels.MainVM.Configure_NonUI(); - Configuration.CreateMockInstance(); + var config = LibationFileManager.Configuration.CreateMockInstance(); var vm = new ProcessQueueViewModel(); DataContext = vm; var trialBook = MockLibraryBook.CreateBook(); - List testList = new() + System.Collections.Generic.List testList = new() { - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.FailedAbort, Status = ProcessBookStatus.Failed, }, - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.FailedSkip, Status = ProcessBookStatus.Failed, }, - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.FailedRetry, Status = ProcessBookStatus.Failed, }, - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.ValidationFail, Status = ProcessBookStatus.Failed, }, - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.Cancelled, Status = ProcessBookStatus.Cancelled, }, - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.Success, Status = ProcessBookStatus.Completed, }, - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.None, Status = ProcessBookStatus.Working, }, - new ProcessBookViewModel(trialBook, Configuration.Instance) + new ProcessBookViewModel(trialBook, config) { Result = ProcessBookResult.None, Status = ProcessBookStatus.Queued, diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index ebf6d77d..1326220a 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -607,17 +607,24 @@ namespace LibationAvalonia.Views lbe.LastDownload.OpenReleaseUrl(); } - public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) + public async void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) { if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry) return; + var pictureId = gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId; + if (string.IsNullOrEmpty(pictureId)) + { + await MessageBox.Show(VisualRoot as Window, "No cover art is available for this book.", "No Cover Art", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) { imageDisplayDialog = new ImageDisplayDialog(); } - var picDef = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); + var picDef = new PictureDefinition(pictureId, PictureSize.Native); void PictureCached(object? sender, PictureCachedEventArgs e) { diff --git a/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs index 2c58471c..70f0df24 100644 --- a/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs +++ b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs @@ -6,6 +6,7 @@ using Dinah.Core; using LibationAvalonia.Controls; using LibationAvalonia.Dialogs; using LibationFileManager; +using LibationUiBase.Forms; using LibationUiBase.SeriesView; using System.Collections.Generic; using System.Linq; @@ -49,19 +50,26 @@ namespace LibationAvalonia.Views sentry.ViewOnAudible(LibraryBook.Book.Locale); } - public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) + public async void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) { if (sender is not Image tblock || tblock.DataContext is not SeriesItem sentry) return; Item libraryBook = sentry.Item; + var pictureId = libraryBook.PictureLarge ?? libraryBook.PictureId; + if (string.IsNullOrEmpty(pictureId)) + { + await MessageBox.Show(VisualRoot as Window, "No cover art is available for this book.", "No Cover Art", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) { imageDisplayDialog = new ImageDisplayDialog(); } - var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.PictureId, PictureSize.Native); + var picDef = new PictureDefinition(pictureId, PictureSize.Native); void PictureCached(object? sender, PictureCachedEventArgs e) { diff --git a/Source/LibationCli/ConsoleProgressBar.cs b/Source/LibationCli/ConsoleProgressBar.cs index 1e4b85c5..db3236f2 100644 --- a/Source/LibationCli/ConsoleProgressBar.cs +++ b/Source/LibationCli/ConsoleProgressBar.cs @@ -1,5 +1,4 @@ -using Dinah.Core; -using System; +using System; using System.IO; namespace LibationCli; diff --git a/Source/LibationCli/HelpVerb.cs b/Source/LibationCli/HelpVerb.cs index 22a7226f..c807d326 100644 --- a/Source/LibationCli/HelpVerb.cs +++ b/Source/LibationCli/HelpVerb.cs @@ -12,7 +12,7 @@ internal class HelpVerb /// Name of the verb to get help about /// [Value(0, Default = "")] - public string HelpType { get; set; } + public string? HelpType { get; set; } /// /// Create a base for @@ -32,7 +32,7 @@ internal class HelpVerb public HelpText GetHelpText() { var helpText = CreateHelpText(); - var result = new Parser().ParseArguments(new string[] { HelpType }, Program.VerbTypes); + var result = new Parser().ParseArguments([HelpType], Program.VerbTypes); if (result.TypeInfo.Current == typeof(NullInstance)) { //HelpType is not a defined verb so get LibationCli usage diff --git a/Source/LibationCli/LibationCli.csproj b/Source/LibationCli/LibationCli.csproj index 521e5ae3..f55b88ce 100644 --- a/Source/LibationCli/LibationCli.csproj +++ b/Source/LibationCli/LibationCli.csproj @@ -3,12 +3,12 @@ Exe - net10.0-windows7.0 - win-x64 + net10.0 true false false True + enable diff --git a/Source/LibationCli/Options/CopyDbOptions.cs b/Source/LibationCli/Options/CopyDbOptions.cs index dd7c5fdb..6b208da5 100644 --- a/Source/LibationCli/Options/CopyDbOptions.cs +++ b/Source/LibationCli/Options/CopyDbOptions.cs @@ -6,7 +6,6 @@ using System; using System.Linq; using System.Threading.Tasks; -#nullable enable namespace LibationCli { [Verb("copydb", HelpText = "Copy the local sqlite database to postgres.")] diff --git a/Source/LibationCli/Options/ExportOptions.cs b/Source/LibationCli/Options/ExportOptions.cs index c7ed5a0c..a134d19c 100644 --- a/Source/LibationCli/Options/ExportOptions.cs +++ b/Source/LibationCli/Options/ExportOptions.cs @@ -7,7 +7,6 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -#nullable enable namespace LibationCli { [Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json")] diff --git a/Source/LibationCli/Options/GetLicenseOptions.cs b/Source/LibationCli/Options/GetLicenseOptions.cs index bc637953..1a98f403 100644 --- a/Source/LibationCli/Options/GetLicenseOptions.cs +++ b/Source/LibationCli/Options/GetLicenseOptions.cs @@ -8,7 +8,6 @@ using Newtonsoft.Json.Converters; using System; using System.Threading.Tasks; -#nullable enable namespace LibationCli.Options; [Verb("get-license", HelpText = "Get the license information for a book.")] diff --git a/Source/LibationCli/Options/GetSettingOptions.cs b/Source/LibationCli/Options/GetSettingOptions.cs index b9de10ff..edd5e820 100644 --- a/Source/LibationCli/Options/GetSettingOptions.cs +++ b/Source/LibationCli/Options/GetSettingOptions.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; -#nullable enable namespace LibationCli.Options; [Verb("get-setting", HelpText = "List current settings files and their locations.")] diff --git a/Source/LibationCli/Options/LiberateOptions.cs b/Source/LibationCli/Options/LiberateOptions.cs index adcd9b5a..609102de 100644 --- a/Source/LibationCli/Options/LiberateOptions.cs +++ b/Source/LibationCli/Options/LiberateOptions.cs @@ -10,7 +10,6 @@ using System; using System.IO; using System.Threading.Tasks; -#nullable enable namespace LibationCli { [Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs.\n" diff --git a/Source/LibationCli/Options/ScanOptions.cs b/Source/LibationCli/Options/ScanOptions.cs index 532193a2..80fed16a 100644 --- a/Source/LibationCli/Options/ScanOptions.cs +++ b/Source/LibationCli/Options/ScanOptions.cs @@ -12,7 +12,7 @@ namespace LibationCli public class ScanOptions : OptionsBase { [Value(0, MetaName = "Accounts", HelpText = "Optional: user ID or nicknames of accounts to scan.", Required = false)] - public IEnumerable AccountNames { get; set; } + public IEnumerable? AccountNames { get; set; } protected override async Task ProcessAsync() { @@ -44,14 +44,14 @@ namespace LibationCli using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); var allAccounts = persister.AccountsSettings.GetAll().ToArray(); - if (!AccountNames.Any()) + if (AccountNames?.Any() is not true) return allAccounts; var accountNames = AccountNames.Select(n => n.ToLower()).ToArray(); var found = allAccounts - .Where(acct => accountNames.Contains(acct.AccountName.ToLower()) || accountNames.Contains(acct.AccountId.ToLower())) + .Where(acct => accountNames.Contains(acct.AccountName?.ToLower()) || accountNames.Contains(acct.AccountId.ToLower())) .ToArray(); var notFound = allAccounts.Except(found).ToArray(); diff --git a/Source/LibationCli/Options/SearchOptions.cs b/Source/LibationCli/Options/SearchOptions.cs index 18f9acec..46368626 100644 --- a/Source/LibationCli/Options/SearchOptions.cs +++ b/Source/LibationCli/Options/SearchOptions.cs @@ -15,14 +15,14 @@ internal class SearchOptions : OptionsBase public int NumResultsPerPage { get; set; } [Value(0, MetaName = "query", Required = true, HelpText = "Lucene search string")] - public IEnumerable Query { get; set; } + public IEnumerable? Query { get; set; } [Option('b', "bare", HelpText = "Print bare list of ASINs without titles")] public bool Bare { get; set; } protected override Task ProcessAsync() { - var query = string.Join(" ", Query).Trim('\"'); + var query = string.Join(" ", Query ?? []).Trim('\"'); var results = SearchEngineCommands.Search(query).Docs.ToList(); if (NumResultsPerPage == 0) diff --git a/Source/LibationCli/Options/SetDownloadStatusOptions.cs b/Source/LibationCli/Options/SetDownloadStatusOptions.cs index 1baeed57..f17ecc55 100644 --- a/Source/LibationCli/Options/SetDownloadStatusOptions.cs +++ b/Source/LibationCli/Options/SetDownloadStatusOptions.cs @@ -25,7 +25,7 @@ namespace LibationCli public bool Force { get; set; } [Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books on which to set download status.")] - public IEnumerable Asins { get; set; } + public IEnumerable? Asins { get; set; } protected override async Task ProcessAsync() { @@ -37,7 +37,7 @@ namespace LibationCli var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking(); - if (Asins.Any()) + if (Asins?.Any() is true) { var asins = Asins.Select(a => a.TrimStart('[').TrimEnd(']').ToLower()).ToArray(); libraryBooks = libraryBooks.Where(lb => lb.Book.AudibleProductId.ToLower().In(asins)).ToList(); diff --git a/Source/LibationCli/Options/_OptionsBase.cs b/Source/LibationCli/Options/_OptionsBase.cs index 8c9c1cfc..5da468ed 100644 --- a/Source/LibationCli/Options/_OptionsBase.cs +++ b/Source/LibationCli/Options/_OptionsBase.cs @@ -10,7 +10,6 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; -#nullable enable namespace LibationCli { public abstract class OptionsBase diff --git a/Source/LibationCli/Options/_ProcessableOptionsBase.cs b/Source/LibationCli/Options/_ProcessableOptionsBase.cs index b7d1cf94..d1245025 100644 --- a/Source/LibationCli/Options/_ProcessableOptionsBase.cs +++ b/Source/LibationCli/Options/_ProcessableOptionsBase.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -#nullable enable namespace LibationCli { public abstract class ProcessableOptionsBase : OptionsBase @@ -61,12 +60,11 @@ namespace LibationCli if (currentLibraryBook is null) return null; - var quality - = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && currentLibraryBook.Book.PictureLarge is not null - ? new PictureDefinition(currentLibraryBook.Book.PictureLarge, PictureSize.Native) - : new PictureDefinition(currentLibraryBook.Book.PictureId, PictureSize._500x500); + var pictureId = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High + ? currentLibraryBook.Book.PictureLarge ?? currentLibraryBook.Book.PictureId + : currentLibraryBook.Book.PictureId; - return PictureStorage.GetPictureSynchronously(quality); + return pictureId is null ? null : PictureStorage.GetPictureSynchronously(new PictureDefinition(pictureId, PictureSize.Native)); }; } diff --git a/Source/LibationCli/TextTableExtention.cs b/Source/LibationCli/TextTableExtention.cs index ff86db8f..3d234bff 100644 --- a/Source/LibationCli/TextTableExtention.cs +++ b/Source/LibationCli/TextTableExtention.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Linq.Expressions; -#nullable enable namespace LibationCli; public enum Justify diff --git a/Source/LibationFileManager/AudibleFileStorage.cs b/Source/LibationFileManager/AudibleFileStorage.cs index 944b5330..7918a9a9 100644 --- a/Source/LibationFileManager/AudibleFileStorage.cs +++ b/Source/LibationFileManager/AudibleFileStorage.cs @@ -10,7 +10,6 @@ using System.Threading; using FileManager; using AaxDecrypter; -#nullable enable namespace LibationFileManager { public abstract class AudibleFileStorage diff --git a/Source/LibationFileManager/Configuration.ConnectionStrings.cs b/Source/LibationFileManager/Configuration.ConnectionStrings.cs index 10bb92ed..de9403c0 100644 --- a/Source/LibationFileManager/Configuration.ConnectionStrings.cs +++ b/Source/LibationFileManager/Configuration.ConnectionStrings.cs @@ -1,16 +1,14 @@ -#nullable enable -using System; +using System; using System.ComponentModel; -namespace LibationFileManager +namespace LibationFileManager; + +public partial class Configuration { - public partial class Configuration - { - [Description("Connection string for Postgresql")] - public string? PostgresqlConnectionString - { - get => GetString(Environment.GetEnvironmentVariable("LIBATION_CONNECTION_STRING")); - set => SetString(value); - } - } + [Description("Connection string for Postgresql")] + public string? PostgresqlConnectionString + { + get => GetString(Environment.GetEnvironmentVariable("LIBATION_CONNECTION_STRING")); + set => SetString(value); + } } diff --git a/Source/LibationFileManager/Configuration.Environment.cs b/Source/LibationFileManager/Configuration.Environment.cs index 978678a2..bf0443f5 100644 --- a/Source/LibationFileManager/Configuration.Environment.cs +++ b/Source/LibationFileManager/Configuration.Environment.cs @@ -1,38 +1,35 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +[Flags] +public enum OS { - [Flags] - public enum OS - { - Unknown, - Windows = 0x100000, - Linux = 0x200000, - MacOS = 0x400000, - } - - public static class Estensions - { - public static string ToVersionString(this Version version) => version.Revision > 1 ? version.ToString(4) : version.ToString(3); - } - - public partial class Configuration - { - public static bool IsWindows { get; } = OperatingSystem.IsWindows(); - public static bool IsLinux { get; } = OperatingSystem.IsLinux(); - public static bool IsMacOs { get; } = OperatingSystem.IsMacOS(); - public static Version? LibationVersion { get; private set; } - public static void SetLibationVersion(Version version) => LibationVersion = version; - - public static OS OS { get; } - = IsLinux ? OS.Linux - : IsMacOs ? OS.MacOS - : IsWindows ? OS.Windows - : OS.Unknown; - } + Unknown, + Windows = 0x100000, + Linux = 0x200000, + MacOS = 0x400000, +} + +public static class Extensions +{ + public static string ToVersionString(this Version? version) + => version is null ? "[unknown]" + : version.Revision > 1 ? version.ToString(4) + : version.ToString(3); +} + +public partial class Configuration +{ + public static bool IsWindows { get; } = OperatingSystem.IsWindows(); + public static bool IsLinux { get; } = OperatingSystem.IsLinux(); + public static bool IsMacOs { get; } = OperatingSystem.IsMacOS(); + public static Version? LibationVersion { get; private set; } + public static void SetLibationVersion(Version? version) => LibationVersion = version; + + public static OS OS { get; } + = IsLinux ? OS.Linux + : IsMacOs ? OS.MacOS + : IsWindows ? OS.Windows + : OS.Unknown; } diff --git a/Source/LibationFileManager/Configuration.HelpText.cs b/Source/LibationFileManager/Configuration.HelpText.cs index c764d8a5..b10d363f 100644 --- a/Source/LibationFileManager/Configuration.HelpText.cs +++ b/Source/LibationFileManager/Configuration.HelpText.cs @@ -1,150 +1,148 @@ using System.Collections.Generic; using System.Collections.ObjectModel; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public partial class Configuration { - public partial class Configuration - { - private static ReadOnlyDictionary HelpText { get; } = new Dictionary - { - {nameof(CombineNestedChapterTitles),""" - If the book has nested chapters, e.g. a chapter named - "Part 1" that contains chapters "Chapter 1" and - "Chapter 2", then combine the chapter titles like the - following example: + private static ReadOnlyDictionary HelpText { get; } = new Dictionary + { + {nameof(CombineNestedChapterTitles),""" + If the book has nested chapters, e.g. a chapter named + "Part 1" that contains chapters "Chapter 1" and + "Chapter 2", then combine the chapter titles like the + following example: - Part 1: Chapter 1 - Part 1: Chapter 2 - """}, - {nameof(AllowLibationFixup), """ - In addition to the options that are enabled if you allow - "fixing up" the audiobook, it does the following: - - * Sets the ©gen metadata tag for the genres. - * Adds the TCOM (@wrt in M4B files) metadata tag for the narrators. - * Unescapes the copyright symbol (replace © with ©) - * Replaces the recording copyright (P) string with ℗ - * Adds various other metadata tags recognized by AudiobookShelf - * Sets the embedded cover art image with cover art retrieved from Audible - """ }, - {nameof(MoveMoovToBeginning), """ - Moves the mpeg 'moov' box to the beginning of the file. - Using this option will generally make the audiobook load - faster, and will make streaming the file over the internet - faster. + Part 1: Chapter 1 + Part 1: Chapter 2 + """}, + {nameof(AllowLibationFixup), """ + In addition to the options that are enabled if you allow + "fixing up" the audiobook, it does the following: + + * Sets the ©gen metadata tag for the genres. + * Adds the TCOM (@wrt in M4B files) metadata tag for the narrators. + * Unescapes the copyright symbol (replace © with ©) + * Replaces the recording copyright (P) string with ℗ + * Adds various other metadata tags recognized by AudiobookShelf + * Sets the embedded cover art image with cover art retrieved from Audible + """ }, + {nameof(MoveMoovToBeginning), """ + Moves the mpeg 'moov' box to the beginning of the file. + Using this option will generally make the audiobook load + faster, and will make streaming the file over the internet + faster. - This is an extra operation performed after the m4b file - has been created, and the speed of it can vary greatly - depending on how fast Libation can read and write from the - book storage location. - """ }, - {nameof(LameDownsampleMono), """ - Most "stereo" audiobooks just duplicate the same audio - for both channels, so you can save on storage size and - decrease encoding time by only using one audio channel. - """ }, - {nameof(DecryptToLossy), """ - Audible delivers its audiobooks in the mpeg-4 audio - file format (aka M4B). If you choose the "Lossless" - option, Libation will leave the original Audible audio - untouched. If you choose "MP3", Libation will re- - encode the audio as an MP3 using the settings below. + This is an extra operation performed after the m4b file + has been created, and the speed of it can vary greatly + depending on how fast Libation can read and write from the + book storage location. + """ }, + {nameof(LameDownsampleMono), """ + Most "stereo" audiobooks just duplicate the same audio + for both channels, so you can save on storage size and + decrease encoding time by only using one audio channel. + """ }, + {nameof(DecryptToLossy), """ + Audible delivers its audiobooks in the mpeg-4 audio + file format (aka M4B). If you choose the "Lossless" + option, Libation will leave the original Audible audio + untouched. If you choose "MP3", Libation will re- + encode the audio as an MP3 using the settings below. - Note that podcasts are usually delivered as MP3s. - """ }, - {nameof(MergeOpeningAndEndCredits), """ - This setting only affects the chapter metadata. - In most audiobooks, the first chapter is "Opening - Credits" and the last chapter is "End Credits". - Enabling this option will remove the credits chapter - markers and shift the adjacent chapter markers to - fill the space. - """ }, - {nameof(RetainAaxFile), """ - Libation will keep the Audible source aax file - and move it to the book's destination directory. - Libation will also create a .key file containing - the decryption key and IV. - """ }, - {nameof(StripUnabridged), """ - Many audiobooks contain "(Unabridged)" in the title. - Enabling this option will remove that text from the - Title and Album metadata tags. - """ }, - {nameof(StripAudibleBrandAudio), """ - All audiobooks begin and end with a few seconds of - Audible branding audio. In English it's "This is - Audible" and "Audible hopes you have enjoyed this - program". + Note that podcasts are usually delivered as MP3s. + """ }, + {nameof(MergeOpeningAndEndCredits), """ + This setting only affects the chapter metadata. + In most audiobooks, the first chapter is "Opening + Credits" and the last chapter is "End Credits". + Enabling this option will remove the credits chapter + markers and shift the adjacent chapter markers to + fill the space. + """ }, + {nameof(RetainAaxFile), """ + Libation will keep the Audible source aax file + and move it to the book's destination directory. + Libation will also create a .key file containing + the decryption key and IV. + """ }, + {nameof(StripUnabridged), """ + Many audiobooks contain "(Unabridged)" in the title. + Enabling this option will remove that text from the + Title and Album metadata tags. + """ }, + {nameof(StripAudibleBrandAudio), """ + All audiobooks begin and end with a few seconds of + Audible branding audio. In English it's "This is + Audible" and "Audible hopes you have enjoyed this + program". - Enabling this option will remove that branded audio - from the decrypted audiobook. This does not require - re-encoding. - """ }, - {nameof(MinimumFileDuration), """ - The minimum duration (in minutes) for an chapter to - be split into its own file. Chapters shorter than - this duration will be merged with the following - chapter. Merged chapter titles will be joined with - a space between them. - """ }, - {nameof(SpatialAudioCodec), """ - The Dolby Digital Plus (E-AC-3) codec is more widely - supported than the AC-4 codec, but E-AC-3 files are - much larger than AC-4 files. - """ }, + Enabling this option will remove that branded audio + from the decrypted audiobook. This does not require + re-encoding. + """ }, + {nameof(MinimumFileDuration), """ + The minimum duration (in minutes) for an chapter to + be split into its own file. Chapters shorter than + this duration will be merged with the following + chapter. Merged chapter titles will be joined with + a space between them. + """ }, + {nameof(SpatialAudioCodec), """ + The Dolby Digital Plus (E-AC-3) codec is more widely + supported than the AC-4 codec, but E-AC-3 files are + much larger than AC-4 files. + """ }, {nameof(UseWidevine), """ - Some audiobooks are only delivered in the highest - available quality with special, third-party content - protection. Enabling this option will allows you to - request audiobooks in the xHE-AAC codec, which is - often higher quality than the standard AAC-LC codec. - """ }, + Some audiobooks are only delivered in the highest + available quality with special, third-party content + protection. Enabling this option will allows you to + request audiobooks in the xHE-AAC codec, which is + often higher quality than the standard AAC-LC codec. + """ }, {nameof(Request_xHE_AAC), """ - If selected, Libation will request audiobooks in the - xHE-AAC codec. This codec is generally better quality - than AAC-LC codec (which is what you'll get if this - option isn't enabled), but it isn't as commonly - supported by media players, so you may have some - difficulty playing these audiobooks. - """ }, - {nameof(RequestSpatial), """ - If selected, Libation will request audiobooks in the - Dolby Atmos 'Spatial Audio' format. Audiobooks which - don't have a spatial audio version will be download - as usual based on your other audio format settings. - """ }, - {"LocateAudiobooks",""" - Scan the contents a folder to find audio files that - match books in Libation's database. This is useful - if you moved your Books folder or re-installed - Libation and want it to be able to find your - already downloaded audiobooks. + If selected, Libation will request audiobooks in the + xHE-AAC codec. This codec is generally better quality + than AAC-LC codec (which is what you'll get if this + option isn't enabled), but it isn't as commonly + supported by media players, so you may have some + difficulty playing these audiobooks. + """ }, + {nameof(RequestSpatial), """ + If selected, Libation will request audiobooks in the + Dolby Atmos 'Spatial Audio' format. Audiobooks which + don't have a spatial audio version will be download + as usual based on your other audio format settings. + """ }, + {"LocateAudiobooks",""" + Scan the contents a folder to find audio files that + match books in Libation's database. This is useful + if you moved your Books folder or re-installed + Libation and want it to be able to find your + already downloaded audiobooks. - Prerequisite: An audiobook must already exist in - Libation's database (through an Audible account - scan) for a matching audio file to be found. - """ }, - {"FindBetterQualityBooks",""" - For all liberated audiobooks in your library, scan - Audible's servers to see if a higher-quality audio - format exists. If a better quality format is found, - you can choose to mark those books for re - -download. - """ }, - {"LocateAudiobooksDialog",""" - Libation will search all .m4b and .mp3 files in a folder, looking for audio files belonging to library books in Libation's database. + Prerequisite: An audiobook must already exist in + Libation's database (through an Audible account + scan) for a matching audio file to be found. + """ }, + {"FindBetterQualityBooks",""" + For all liberated audiobooks in your library, scan + Audible's servers to see if a higher-quality audio + format exists. If a better quality format is found, + you can choose to mark those books for re + -download. + """ }, + {"LocateAudiobooksDialog",""" + Libation will search all .m4b and .mp3 files in a folder, looking for audio files belonging to library books in Libation's database. - If an audiobook file is found that matches one of Libation's library books, Libation will mark that book as "Liberated" (green stoplight). + If an audiobook file is found that matches one of Libation's library books, Libation will mark that book as "Liberated" (green stoplight). - For an audio file to be identified, Libation must have that library book in its database. If you're on a fresh installation of Libation, be sure to add and scan all of your Audible accounts before running this action. + For an audio file to be identified, Libation must have that library book in its database. If you're on a fresh installation of Libation, be sure to add and scan all of your Audible accounts before running this action. - This may take a while, depending on the number of audio files in the folder and the speed of your storage device. - """ } - }.AsReadOnly(); + This may take a while, depending on the number of audio files in the folder and the speed of your storage device. + """ } + }.AsReadOnly(); - public static string GetHelpText(string? settingName) - => settingName != null && HelpText.TryGetValue(settingName, out var value) ? value : ""; - } + public static string GetHelpText(string? settingName) + => settingName != null && HelpText.TryGetValue(settingName, out var value) ? value : ""; } diff --git a/Source/LibationFileManager/Configuration.KnownDirectories.cs b/Source/LibationFileManager/Configuration.KnownDirectories.cs index 176a80c2..30368246 100644 --- a/Source/LibationFileManager/Configuration.KnownDirectories.cs +++ b/Source/LibationFileManager/Configuration.KnownDirectories.cs @@ -3,76 +3,73 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; -using Dinah.Core; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public partial class Configuration { - public partial class Configuration - { - public static string ProcessDirectory { get; } = Path.GetDirectoryName(Environment.ProcessPath)!; - public static string AppDir_Relative => $@".{Path.DirectorySeparatorChar}{LibationFiles.LIBATION_FILES_KEY}"; - public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LibationFiles.LIBATION_FILES_KEY)); - public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation")); - public static string MyMusic => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), "Libation")); - public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")); - public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")); - public static string LocalAppData => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation")); + public static string ProcessDirectory { get; } = Path.GetDirectoryName(Environment.ProcessPath)!; + public static string AppDir_Relative => $@".{Path.DirectorySeparatorChar}{LibationFiles.LIBATION_FILES_KEY}"; + public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LibationFiles.LIBATION_FILES_KEY)); + public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation")); + public static string MyMusic => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), "Libation")); + public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")); + public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")); + public static string LocalAppData => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation")); - public enum KnownDirectories - { - None = 0, + public enum KnownDirectories + { + None = 0, - [Description("My Users folder")] - UserProfile = 1, + [Description("My Users folder")] + UserProfile = 1, - [Description("The same folder that Libation is running from")] - AppDir = 2, + [Description("The same folder that Libation is running from")] + AppDir = 2, - [Description("System temporary folder")] - WinTemp = 3, + [Description("System temporary folder")] + WinTemp = 3, - [Description("My Documents")] - MyDocs = 4, + [Description("My Documents")] + MyDocs = 4, - [Description("Your settings folder (aka: Libation Files)")] - LibationFiles = 5, - - [Description("User Application Data Folder")] - ApplicationData = 6, - - [Description("My Music")] - MyMusic = 7, - } - // use func calls so we always get the latest value of LibationFiles - private static List<(KnownDirectories directory, Func getPathFunc)> directoryOptionsPaths { get; } = new() - { - (KnownDirectories.None, () => null), - (KnownDirectories.ApplicationData, () => LocalAppData), - (KnownDirectories.MyMusic, () => MyMusic), - (KnownDirectories.UserProfile, () => UserProfile), - (KnownDirectories.AppDir, () => AppDir_Relative), - (KnownDirectories.WinTemp, () => WinTemp), - (KnownDirectories.MyDocs, () => MyDocs), + [Description("Your settings folder (aka: Libation Files)")] + LibationFiles = 5, + + [Description("User Application Data Folder")] + ApplicationData = 6, + + [Description("My Music")] + MyMusic = 7, + } + // use func calls so we always get the latest value of LibationFiles + private static List<(KnownDirectories directory, Func getPathFunc)> directoryOptionsPaths { get; } = new() + { + (KnownDirectories.None, () => null), + (KnownDirectories.ApplicationData, () => LocalAppData), + (KnownDirectories.MyMusic, () => MyMusic), + (KnownDirectories.UserProfile, () => UserProfile), + (KnownDirectories.AppDir, () => AppDir_Relative), + (KnownDirectories.WinTemp, () => WinTemp), + (KnownDirectories.MyDocs, () => MyDocs), // this is important to not let very early calls try to accidentally load LibationFiles too early. // also, keep this at bottom of this list (KnownDirectories.LibationFiles, () => Instance.LibationFiles.Location) - }; - public static string? GetKnownDirectoryPath(KnownDirectories directory) - { - var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory); - return dirFunc == default ? null : dirFunc.getPathFunc(); - } - public static KnownDirectories GetKnownDirectory(string directory) - { - // especially important so a very early call doesn't match null => LibationFiles - if (string.IsNullOrWhiteSpace(directory)) - return KnownDirectories.None; + }; + public static string? GetKnownDirectoryPath(KnownDirectories directory) + { + var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory); + return dirFunc == default ? null : dirFunc.getPathFunc(); + } + public static KnownDirectories GetKnownDirectory(string directory) + { + // especially important so a very early call doesn't match null => LibationFiles + if (string.IsNullOrWhiteSpace(directory)) + return KnownDirectories.None; - // 'First' instead of 'Single' because LibationFiles could match other directories. eg: default value of LibationFiles == UserProfile. - // since it's a list, order matters and non-LibationFiles will be returned first - var dirFunc = directoryOptionsPaths.FirstOrDefault(dirFunc => dirFunc.getPathFunc() == directory); - return dirFunc == default ? KnownDirectories.None : dirFunc.directory; - } - } + // 'First' instead of 'Single' because LibationFiles could match other directories. eg: default value of LibationFiles == UserProfile. + // since it's a list, order matters and non-LibationFiles will be returned first + var dirFunc = directoryOptionsPaths.FirstOrDefault(dirFunc => dirFunc.getPathFunc() == directory); + return dirFunc == default ? KnownDirectories.None : dirFunc.directory; + } } diff --git a/Source/LibationFileManager/Configuration.Logging.cs b/Source/LibationFileManager/Configuration.Logging.cs index ab9593e9..1f1837eb 100644 --- a/Source/LibationFileManager/Configuration.Logging.cs +++ b/Source/LibationFileManager/Configuration.Logging.cs @@ -8,75 +8,73 @@ using Serilog.Events; using Serilog.Exceptions; using Serilog.Settings.Configuration; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public partial class Configuration { - public partial class Configuration - { - private IConfigurationRoot? configuration; + private IConfigurationRoot? configuration; - public bool SerilogInitialized { get; private set; } + public bool SerilogInitialized { get; private set; } - public void ConfigureLogging() - { - //pass explicit assemblies to the ConfigurationReaderOptions - //This is a workaround for the issue where serilog will try to load all - //Assemblies starting with "serilog" from the app folder, but it will fail - //if those assemblies are unreferenced. - //This was a problem when migrating from the ZipFile sink to the File sink. - //Upgrading users would still have the ZipFile sink dll in the program - //folder and serilog would try to load it, unsuccessfully. - //https://github.com/serilog/serilog-settings-configuration/issues/406 - var readerOptions = new ConfigurationReaderOptions( - typeof(ILogger).Assembly, // Serilog - typeof(LoggerCallerEnrichmentConfiguration).Assembly, // Dinah.Core - typeof(LoggerEnrichmentConfigurationExtensions).Assembly, // Serilog.Exceptions - typeof(ConsoleLoggerConfigurationExtensions).Assembly, // Serilog.Sinks.Console - typeof(FileLoggerConfigurationExtensions).Assembly); // Serilog.Sinks.File + public void ConfigureLogging() + { + //pass explicit assemblies to the ConfigurationReaderOptions + //This is a workaround for the issue where serilog will try to load all + //Assemblies starting with "serilog" from the app folder, but it will fail + //if those assemblies are unreferenced. + //This was a problem when migrating from the ZipFile sink to the File sink. + //Upgrading users would still have the ZipFile sink dll in the program + //folder and serilog would try to load it, unsuccessfully. + //https://github.com/serilog/serilog-settings-configuration/issues/406 + var readerOptions = new ConfigurationReaderOptions( + typeof(ILogger).Assembly, // Serilog + typeof(LoggerCallerEnrichmentConfiguration).Assembly, // Dinah.Core + typeof(LoggerEnrichmentConfigurationExtensions).Assembly, // Serilog.Exceptions + typeof(ConsoleLoggerConfigurationExtensions).Assembly, // Serilog.Sinks.Console + typeof(FileLoggerConfigurationExtensions).Assembly); // Serilog.Sinks.File - configuration = new ConfigurationBuilder() - .AddJsonFile(Instance.LibationFiles.SettingsFilePath, optional: false, reloadOnChange: true) - .Build(); - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration, readerOptions) - .Destructure.ByTransforming(lp => lp.Path) - .Destructure.With() - .CreateLogger(); - SerilogInitialized = true; - } + configuration = new ConfigurationBuilder() + .AddJsonFile(Instance.LibationFiles.SettingsFilePath, optional: false, reloadOnChange: true) + .Build(); + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration, readerOptions) + .Destructure.ByTransforming(lp => lp.Path) + .Destructure.With() + .CreateLogger(); + SerilogInitialized = true; + } - [Description("The importance of a log event")] - public LogEventLevel LogLevel - { - get - { - var logLevelStr = Settings.GetStringFromJsonPath("Serilog", "MinimumLevel"); - return Enum.TryParse(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information; - } - set - { - OnPropertyChanging(nameof(LogLevel), LogLevel, value); - var valueWasChanged = Settings.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString()); - if (!valueWasChanged) - { - Log.Logger.Debug("LogLevel.set attempt. No change"); - return; - } + [Description("The importance of a log event")] + public LogEventLevel LogLevel + { + get + { + var logLevelStr = Settings.GetStringFromJsonPath("Serilog", "MinimumLevel"); + return Enum.TryParse(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information; + } + set + { + OnPropertyChanging(nameof(LogLevel), LogLevel, value); + var valueWasChanged = Settings.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString()); + if (!valueWasChanged) + { + Log.Logger.Debug("LogLevel.set attempt. No change"); + return; + } - configuration?.Reload(); + configuration?.Reload(); - OnPropertyChanged(nameof(LogLevel), value); + OnPropertyChanged(nameof(LogLevel), value); - Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new - { - LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), - LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(), - LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(), - LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(), - LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(), - LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled() - }); - } - } - } + Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new + { + LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), + LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(), + LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(), + LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(), + LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(), + LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled() + }); + } + } } diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index 95bf701c..2c5b599e 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -9,408 +9,407 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public partial class Configuration { - public partial class Configuration + // note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app + + // default setting and directory creation occur in class responsible for files. + // config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation + // exceptions: appsettings.json, LibationFiles dir, Settings.json + private IJsonBackedDictionary? JsonBackedDictionary { get; set; } + private IJsonBackedDictionary Settings => JsonBackedDictionary + ?? throw new InvalidOperationException($"{nameof(LoadPersistentSettings)} must first be called prior to accessing {nameof(Settings)}"); + + internal void LoadPersistentSettings(string settingsFile) + => JsonBackedDictionary = new PersistentDictionary(settingsFile); + + internal void LoadEphemeralSettings(JObject dataStore) + => JsonBackedDictionary = new EphemeralDictionary(dataStore); + + private LibationFiles? _libationFiles; + [Description("Location for storage of program-created files")] + public LibationFiles LibationFiles => _libationFiles ??= new LibationFiles(); + + public bool RemoveProperty(string propertyName) => Settings.RemoveProperty(propertyName); + + [return: NotNullIfNotNull(nameof(defaultValue))] + public T? GetNonString(T defaultValue, [CallerMemberName] string propertyName = "") + => Settings is null ? default : Settings.GetNonString(propertyName, defaultValue); + + + [return: NotNullIfNotNull(nameof(defaultValue))] + public string? GetString(string? defaultValue = null, [CallerMemberName] string propertyName = "") + => Settings?.GetString(propertyName, defaultValue); + + public object? GetObject([CallerMemberName] string propertyName = "") => Settings.GetObject(propertyName); + + public void SetNonString(object? newValue, [CallerMemberName] string propertyName = "") { - // note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app + var existing = getExistingValue(propertyName); + if (existing?.Equals(newValue) is true) return; - // default setting and directory creation occur in class responsible for files. - // config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation - // exceptions: appsettings.json, LibationFiles dir, Settings.json - private IJsonBackedDictionary? JsonBackedDictionary { get; set; } - private IJsonBackedDictionary Settings => JsonBackedDictionary - ?? throw new InvalidOperationException($"{nameof(LoadPersistentSettings)} must first be called prior to accessing {nameof(Settings)}"); - - internal void LoadPersistentSettings(string settingsFile) - => JsonBackedDictionary = new PersistentDictionary(settingsFile); - - internal void LoadEphemeralSettings(JObject dataStore) - => JsonBackedDictionary = new EphemeralDictionary(dataStore); - - private LibationFiles? _libationFiles; - [Description("Location for storage of program-created files")] - public LibationFiles LibationFiles => _libationFiles ??= new LibationFiles(); - - public bool RemoveProperty(string propertyName) => Settings.RemoveProperty(propertyName); - - [return: NotNullIfNotNull(nameof(defaultValue))] - public T? GetNonString(T defaultValue, [CallerMemberName] string propertyName = "") - => Settings is null ? default : Settings.GetNonString(propertyName, defaultValue); - - - [return: NotNullIfNotNull(nameof(defaultValue))] - public string? GetString(string? defaultValue = null, [CallerMemberName] string propertyName = "") - => Settings?.GetString(propertyName, defaultValue); - - public object? GetObject([CallerMemberName] string propertyName = "") => Settings.GetObject(propertyName); - - public void SetNonString(object? newValue, [CallerMemberName] string propertyName = "") - { - var existing = getExistingValue(propertyName); - if (existing?.Equals(newValue) is true) return; - - OnPropertyChanging(propertyName, existing, newValue); - Settings.SetNonString(propertyName, newValue); - OnPropertyChanged(propertyName, newValue); - } - - public void SetString(string? newValue, [CallerMemberName] string propertyName = "") - { - var existing = getExistingValue(propertyName); - if (existing?.Equals(newValue) is true) return; - - OnPropertyChanging(propertyName, existing, newValue); - Settings.SetString(propertyName, newValue); - OnPropertyChanged(propertyName, newValue); - } - - private object? getExistingValue(string propertyName) - { - var property = GetType().GetProperty(propertyName); - if (property is not null) return property.GetValue(this); - return GetObject(propertyName); - } - - /// WILL ONLY set if already present. WILL NOT create new - public void SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false) - { - var settingWasChanged = Settings.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging); - if (settingWasChanged) - configuration?.Reload(); - } - - public static string GetDescription(string propertyName) - { - var attribute = typeof(Configuration) - .GetProperty(propertyName) - ?.GetCustomAttributes(typeof(DescriptionAttribute), true) - .SingleOrDefault() - as DescriptionAttribute; - - return attribute?.Description ?? $"[{propertyName}]"; - } - - public bool Exists(string propertyName) => Settings.Exists(propertyName); - - [Description("Set cover art as the folder's icon.")] - public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Save audiobook metadata to metadata.json")] - public bool SaveMetadataToFile { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Book display grid size")] - public float GridScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); } - - [Description("Book display font size")] - public float GridFontScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); } - - [Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")] - 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 - { - if (value != Books) - { - OnPropertyChanging(nameof(Books), Books, value); - Settings.SetString(nameof(Books), value); - m_BooksCanWrite255UnicodeChars = null; - m_BooksCanWriteWindowsInvalidChars = null; - OnPropertyChanged(nameof(Books), value); - } - } - } - - private bool? m_BooksCanWrite255UnicodeChars; - private bool? m_BooksCanWriteWindowsInvalidChars; - /// - /// True if the Books directory can be written to with 255 unicode character filenames - /// Does not persist. Check and set this value at runtime and whenever Books is changed. - /// - public bool BooksCanWrite255UnicodeChars => m_BooksCanWrite255UnicodeChars ??= FileSystemTest.CanWrite255UnicodeChars(AudibleFileStorage.BooksDirectory); - /// - /// True if the Books directory can be written to with filenames containing characters invalid on Windows (:, *, ?, <, >, |) - /// Always false on Windows platforms. - /// Does not persist. Check and set this value at runtime and whenever Books is changed. - /// - public bool BooksCanWriteWindowsInvalidChars => !IsWindows && (m_BooksCanWriteWindowsInvalidChars ??= FileSystemTest.CanWriteWindowsInvalidChars(AudibleFileStorage.BooksDirectory)); - - [Description("Overwrite existing files if they already exist?")] - public bool OverwriteExisting { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - // temp/working dir(s) should be outside of dropbox - [Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")] - public string InProgress - { - get - { - var tempDir = GetString(); - return string.IsNullOrWhiteSpace(tempDir) ? WinTemp : tempDir; - } - set => SetString(value); - } - - [Description("Libation's display color theme")] - public Theme ThemeVariant { get => GetNonString(defaultValue: Theme.System); set => SetNonString(value); } - - [Description("Allow Libation to fix up audiobook metadata")] - public bool AllowLibationFixup { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Create a cue sheet (.cue)")] - public bool CreateCueSheet { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Retain the Aax file after successfully decrypting")] - public bool RetainAaxFile { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Split my books into multiple files by chapter")] - public bool SplitFilesByChapter { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Minimum file duration (seconds)")] - public int MinimumFileDuration { get => Math.Max(0, GetNonString(defaultValue: 3)); set => SetNonString(value); } - - [Description("Merge Opening/End Credits into the following/preceding chapters")] - public bool MergeOpeningAndEndCredits { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Strip \"(Unabridged)\" from audiobook metadata tags")] - public bool StripUnabridged { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Strip audible branding from the start and end of audiobooks.\r\n(e.g. \"This is Audible\")")] - public bool StripAudibleBrandAudio { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Decrypt to lossy format?")] - public bool DecryptToLossy { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Move the mp4 moov atom to the beginning of the file?")] - public bool MoveMoovToBeginning { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Lame encoder target. true = Bitrate, false = Quality")] - public bool LameTargetBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Maximum audio sample rate")] - public AAXClean.SampleRate MaxSampleRate { get => GetNonString(defaultValue: AAXClean.SampleRate.Hz_44100); set => SetNonString(value); } - - [Description("Lame encoder quality")] - public NAudio.Lame.EncoderQuality LameEncoderQuality { get => GetNonString(defaultValue: NAudio.Lame.EncoderQuality.High); set => SetNonString(value); } - - [Description("Lame encoder downsamples to mono")] - public bool LameDownsampleMono { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Lame target bitrate [16,320]")] - public int LameBitrate { get => GetNonString(defaultValue: 64); set => SetNonString(value); } - - [Description("Restrict encoder to constant bitrate?")] - public bool LameConstantBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Match the source bitrate?")] - public bool LameMatchSourceBR { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Lame target VBR quality [10,100]")] - public int LameVBRQuality { get => GetNonString(defaultValue: 2); set => SetNonString(value); } - - private static readonly EquatableDictionary DefaultColumns = new([ - new ("SeriesOrder", false), - new ("LastDownload", false), - new ("IsSpatial", false), - new ("IncludedUntil", false), - new ("Account", false), - ]); - public bool GetColumnVisibility(string columnName) - => GridColumnsVisibilities.TryGetValue(columnName, out var isVisible) ? isVisible - :DefaultColumns.GetValueOrDefault(columnName, true); - - [Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")] - public Dictionary GridColumnsVisibilities { get => GetNonString(defaultValue: DefaultColumns).Clone(); set => SetNonString(value); } - - [Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")] - public Dictionary GridColumnsDisplayIndices { get => GetNonString(defaultValue: new EquatableDictionary()).Clone(); set => SetNonString(value); } - - [Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")] - public Dictionary GridColumnsWidths { get => GetNonString(defaultValue: new EquatableDictionary()).Clone(); set => SetNonString(value); } - - [Description("Save cover image alongside audiobook?")] - public bool DownloadCoverArt { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Combine nested chapter titles")] - public bool CombineNestedChapterTitles { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Download clips and bookmarks?")] - public bool DownloadClipsBookmarks { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("File format to save clips and bookmarks")] - public ClipBookmarkFormat ClipsBookmarksFileFormat { get => GetNonString(defaultValue: ClipBookmarkFormat.CSV); set => SetNonString(value); } - - [JsonConverter(typeof(StringEnumConverter))] - public enum ClipBookmarkFormat - { - [Description("Comma-separated values")] - CSV, - [Description("Microsoft Excel Spreadsheet")] - Xlsx, - [Description("JavaScript Object Notation (JSON)")] - Json - } - - [JsonConverter(typeof(StringEnumConverter))] - public enum BadBookAction - { - [Description("Ask each time what action to take.")] - Ask = 0, - [Description("Stop processing books.")] - Abort = 1, - [Description("Retry book later. Skip for now. Continue processing books.")] - Retry = 2, - [Description("Permanently ignore book. Continue processing books. Do not try book again.")] - Ignore = 3 - } - - [JsonConverter(typeof(StringEnumConverter))] - public enum Theme - { - System = 0, - Light = 1, - Dark = 2 - } - - [JsonConverter(typeof(StringEnumConverter))] - public enum DateTimeSource - { - [Description("File creation date/time")] - File, - [Description("Audiobook publication date")] - Published, - [Description("Date book was added to your Audible account")] - Added - } - - [JsonConverter(typeof(StringEnumConverter))] - public enum DownloadQuality - { - High, - Normal - } - - [JsonConverter(typeof(StringEnumConverter))] - public enum SpatialCodec - { - EC_3, - AC_4 - } - - [Description("Use Widevine DRM")] - public bool UseWidevine { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Request xHE-AAC codec")] - public bool Request_xHE_AAC { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - //[Description("Request Spatial Audio")] - public bool RequestSpatial { get => false; set { } } // { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - //[Description("Spatial audio codec:")] - public SpatialCodec SpatialAudioCodec { get => GetNonString(defaultValue: SpatialCodec.EC_3); set => SetNonString(value); } - - [Description("Audio quality to request from Audible:")] - public DownloadQuality FileDownloadQuality { get => GetNonString(defaultValue: DownloadQuality.High); set => SetNonString(value); } - - [Description("Set file \"created\" timestamp to:")] - public DateTimeSource CreationTime { get => GetNonString(defaultValue: DateTimeSource.File); set => SetNonString(value); } - - [Description("Set file \"modified\" timestamp to:")] - public DateTimeSource LastWriteTime { get => GetNonString(defaultValue: DateTimeSource.File); set => SetNonString(value); } - - [Description("Indicates that this is the first time Libation has been run")] - public bool FirstLaunch { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("When liberating books and there is an error, Libation should:")] - public BadBookAction BadBook { get => GetNonString(defaultValue: BadBookAction.Ask); set => SetNonString(value); } - - [Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")] - public bool ShowImportedStats { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")] - public bool ImportEpisodes { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Import Audible Plus books (books you do not own)? When unchecked, Audible Plus books will not be imported into Libation.")] - public bool ImportPlusTitles { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")] - public bool DownloadEpisodes { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Automatically run periodic scans in the background?")] - public bool AutoScan { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Use Libation's built-in web browser to log into Audible?")] - public bool UseWebView { get => GetNonString(defaultValue: true); set => SetNonString(value); } - - [Description("Auto download books? After scan, download new books in 'checked' accounts.")] - // poorly named setting. Should just be 'AutoDownload'. It is NOT episode specific - public bool AutoDownloadEpisodes { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Save all podcast episodes in a series to the series parent folder?")] - public bool SavePodcastsToParentFolder { get => GetNonString(defaultValue: false); set => SetNonString(value); } - - [Description("Global download speed limit in bytes per second.")] - public long DownloadSpeedLimit - { - get - { - var limit = GetNonString(defaultValue: 0L); - return limit <= 0 ? 0 : Math.Max(limit, AaxDecrypter.NetworkFileStream.MIN_BYTES_PER_SECOND); - } - set - { - var limit = value <= 0 ? 0 : Math.Max(value, AaxDecrypter.NetworkFileStream.MIN_BYTES_PER_SECOND); - SetNonString(limit); - } - } - - #region templates: custom file naming - - [Description("Edit how filename characters are replaced")] - public ReplacementCharacters ReplacementCharacters { get => GetNonString(defaultValue: ReplacementCharacters.Default(IsWindows)); set => SetNonString(value); } - - [Description("How to format the folders in which files will be saved")] - public string FolderTemplate - { - get => getTemplate(); - set => setTemplate(value); - } - - [Description("How to format the saved pdf and audio files")] - public string FileTemplate - { - get => getTemplate(); - set => setTemplate(value); - } - - [Description("How to format the saved audio files when split by chapters")] - public string ChapterFileTemplate - { - get => getTemplate(); - set => setTemplate(value); - } - - [Description("How to format the file's Title stored in metadata")] - public string ChapterTitleTemplate - { - get => getTemplate(); - set => setTemplate(value); - } - - private string getTemplate([CallerMemberName] string propertyName = "") - where T : Templates.Templates, Templates.ITemplate, new() - { - return Templates.Templates.GetTemplate(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText; - } - - private void setTemplate(string newValue, [CallerMemberName] string propertyName = "") - where T : Templates.Templates, Templates.ITemplate, new() - { - SetString(Templates.Templates.GetTemplate(newValue).TemplateText, propertyName); - } - #endregion + OnPropertyChanging(propertyName, existing, newValue); + Settings.SetNonString(propertyName, newValue); + OnPropertyChanged(propertyName, newValue); } + + public void SetString(string? newValue, [CallerMemberName] string propertyName = "") + { + var existing = getExistingValue(propertyName); + if (existing?.Equals(newValue) is true) return; + + OnPropertyChanging(propertyName, existing, newValue); + Settings.SetString(propertyName, newValue); + OnPropertyChanged(propertyName, newValue); + } + + private object? getExistingValue(string propertyName) + { + var property = GetType().GetProperty(propertyName); + if (property is not null) return property.GetValue(this); + return GetObject(propertyName); + } + + /// WILL ONLY set if already present. WILL NOT create new + public void SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false) + { + var settingWasChanged = Settings.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging); + if (settingWasChanged) + configuration?.Reload(); + } + + public static string GetDescription(string propertyName) + { + var attribute = typeof(Configuration) + .GetProperty(propertyName) + ?.GetCustomAttributes(typeof(DescriptionAttribute), true) + .SingleOrDefault() + as DescriptionAttribute; + + return attribute?.Description ?? $"[{propertyName}]"; + } + + public bool Exists(string propertyName) => Settings.Exists(propertyName); + + [Description("Set cover art as the folder's icon.")] + public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Save audiobook metadata to metadata.json")] + public bool SaveMetadataToFile { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Book display grid size")] + public float GridScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); } + + [Description("Book display font size")] + public float GridFontScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); } + + [Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")] + 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 + { + if (value != Books) + { + OnPropertyChanging(nameof(Books), Books, value); + Settings.SetString(nameof(Books), value); + m_BooksCanWrite255UnicodeChars = null; + m_BooksCanWriteWindowsInvalidChars = null; + OnPropertyChanged(nameof(Books), value); + } + } + } + + private bool? m_BooksCanWrite255UnicodeChars; + private bool? m_BooksCanWriteWindowsInvalidChars; + /// + /// True if the Books directory can be written to with 255 unicode character filenames + /// Does not persist. Check and set this value at runtime and whenever Books is changed. + /// + public bool BooksCanWrite255UnicodeChars => m_BooksCanWrite255UnicodeChars ??= FileSystemTest.CanWrite255UnicodeChars(AudibleFileStorage.BooksDirectory); + /// + /// True if the Books directory can be written to with filenames containing characters invalid on Windows (:, *, ?, <, >, |) + /// Always false on Windows platforms. + /// Does not persist. Check and set this value at runtime and whenever Books is changed. + /// + public bool BooksCanWriteWindowsInvalidChars => !IsWindows && (m_BooksCanWriteWindowsInvalidChars ??= FileSystemTest.CanWriteWindowsInvalidChars(AudibleFileStorage.BooksDirectory)); + + [Description("Overwrite existing files if they already exist?")] + public bool OverwriteExisting { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + // temp/working dir(s) should be outside of dropbox + [AllowNull] + [Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")] + public string InProgress + { + get + { + var tempDir = GetString(); + return string.IsNullOrWhiteSpace(tempDir) ? WinTemp : tempDir; + } + set => SetString(value ?? WinTemp); + } + + [Description("Libation's display color theme")] + public Theme ThemeVariant { get => GetNonString(defaultValue: Theme.System); set => SetNonString(value); } + + [Description("Allow Libation to fix up audiobook metadata")] + public bool AllowLibationFixup { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Create a cue sheet (.cue)")] + public bool CreateCueSheet { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Retain the Aax file after successfully decrypting")] + public bool RetainAaxFile { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Split my books into multiple files by chapter")] + public bool SplitFilesByChapter { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Minimum file duration (seconds)")] + public int MinimumFileDuration { get => Math.Max(0, GetNonString(defaultValue: 3)); set => SetNonString(value); } + + [Description("Merge Opening/End Credits into the following/preceding chapters")] + public bool MergeOpeningAndEndCredits { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Strip \"(Unabridged)\" from audiobook metadata tags")] + public bool StripUnabridged { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Strip audible branding from the start and end of audiobooks.\r\n(e.g. \"This is Audible\")")] + public bool StripAudibleBrandAudio { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Decrypt to lossy format?")] + public bool DecryptToLossy { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Move the mp4 moov atom to the beginning of the file?")] + public bool MoveMoovToBeginning { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Lame encoder target. true = Bitrate, false = Quality")] + public bool LameTargetBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Maximum audio sample rate")] + public AAXClean.SampleRate MaxSampleRate { get => GetNonString(defaultValue: AAXClean.SampleRate.Hz_44100); set => SetNonString(value); } + + [Description("Lame encoder quality")] + public NAudio.Lame.EncoderQuality LameEncoderQuality { get => GetNonString(defaultValue: NAudio.Lame.EncoderQuality.High); set => SetNonString(value); } + + [Description("Lame encoder downsamples to mono")] + public bool LameDownsampleMono { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Lame target bitrate [16,320]")] + public int LameBitrate { get => GetNonString(defaultValue: 64); set => SetNonString(value); } + + [Description("Restrict encoder to constant bitrate?")] + public bool LameConstantBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Match the source bitrate?")] + public bool LameMatchSourceBR { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Lame target VBR quality [10,100]")] + public int LameVBRQuality { get => GetNonString(defaultValue: 2); set => SetNonString(value); } + + private static readonly EquatableDictionary DefaultColumns = new([ + new ("SeriesOrder", false), + new ("LastDownload", false), + new ("IsSpatial", false), + new ("IncludedUntil", false), + new ("Account", false), + ]); + public bool GetColumnVisibility(string columnName) + => GridColumnsVisibilities.TryGetValue(columnName, out var isVisible) ? isVisible + :DefaultColumns.GetValueOrDefault(columnName, true); + + [Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")] + public Dictionary GridColumnsVisibilities { get => GetNonString(defaultValue: DefaultColumns).Clone(); set => SetNonString(value); } + + [Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")] + public Dictionary GridColumnsDisplayIndices { get => GetNonString(defaultValue: new EquatableDictionary()).Clone(); set => SetNonString(value); } + + [Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")] + public Dictionary GridColumnsWidths { get => GetNonString(defaultValue: new EquatableDictionary()).Clone(); set => SetNonString(value); } + + [Description("Save cover image alongside audiobook?")] + public bool DownloadCoverArt { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Combine nested chapter titles")] + public bool CombineNestedChapterTitles { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Download clips and bookmarks?")] + public bool DownloadClipsBookmarks { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("File format to save clips and bookmarks")] + public ClipBookmarkFormat ClipsBookmarksFileFormat { get => GetNonString(defaultValue: ClipBookmarkFormat.CSV); set => SetNonString(value); } + + [JsonConverter(typeof(StringEnumConverter))] + public enum ClipBookmarkFormat + { + [Description("Comma-separated values")] + CSV, + [Description("Microsoft Excel Spreadsheet")] + Xlsx, + [Description("JavaScript Object Notation (JSON)")] + Json + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum BadBookAction + { + [Description("Ask each time what action to take.")] + Ask = 0, + [Description("Stop processing books.")] + Abort = 1, + [Description("Retry book later. Skip for now. Continue processing books.")] + Retry = 2, + [Description("Permanently ignore book. Continue processing books. Do not try book again.")] + Ignore = 3 + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum Theme + { + System = 0, + Light = 1, + Dark = 2 + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum DateTimeSource + { + [Description("File creation date/time")] + File, + [Description("Audiobook publication date")] + Published, + [Description("Date book was added to your Audible account")] + Added + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum DownloadQuality + { + High, + Normal + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum SpatialCodec + { + EC_3, + AC_4 + } + + [Description("Use Widevine DRM")] + public bool UseWidevine { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Request xHE-AAC codec")] + public bool Request_xHE_AAC { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + //[Description("Request Spatial Audio")] + public bool RequestSpatial { get => false; set { } } // { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + //[Description("Spatial audio codec:")] + public SpatialCodec SpatialAudioCodec { get => GetNonString(defaultValue: SpatialCodec.EC_3); set => SetNonString(value); } + + [Description("Audio quality to request from Audible:")] + public DownloadQuality FileDownloadQuality { get => GetNonString(defaultValue: DownloadQuality.High); set => SetNonString(value); } + + [Description("Set file \"created\" timestamp to:")] + public DateTimeSource CreationTime { get => GetNonString(defaultValue: DateTimeSource.File); set => SetNonString(value); } + + [Description("Set file \"modified\" timestamp to:")] + public DateTimeSource LastWriteTime { get => GetNonString(defaultValue: DateTimeSource.File); set => SetNonString(value); } + + [Description("Indicates that this is the first time Libation has been run")] + public bool FirstLaunch { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("When liberating books and there is an error, Libation should:")] + public BadBookAction BadBook { get => GetNonString(defaultValue: BadBookAction.Ask); set => SetNonString(value); } + + [Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")] + public bool ShowImportedStats { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")] + public bool ImportEpisodes { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Import Audible Plus books (books you do not own)? When unchecked, Audible Plus books will not be imported into Libation.")] + public bool ImportPlusTitles { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")] + public bool DownloadEpisodes { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Automatically run periodic scans in the background?")] + public bool AutoScan { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Use Libation's built-in web browser to log into Audible?")] + public bool UseWebView { get => GetNonString(defaultValue: true); set => SetNonString(value); } + + [Description("Auto download books? After scan, download new books in 'checked' accounts.")] + // poorly named setting. Should just be 'AutoDownload'. It is NOT episode specific + public bool AutoDownloadEpisodes { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Save all podcast episodes in a series to the series parent folder?")] + public bool SavePodcastsToParentFolder { get => GetNonString(defaultValue: false); set => SetNonString(value); } + + [Description("Global download speed limit in bytes per second.")] + public long DownloadSpeedLimit + { + get + { + var limit = GetNonString(defaultValue: 0L); + return limit <= 0 ? 0 : Math.Max(limit, AaxDecrypter.NetworkFileStream.MIN_BYTES_PER_SECOND); + } + set + { + var limit = value <= 0 ? 0 : Math.Max(value, AaxDecrypter.NetworkFileStream.MIN_BYTES_PER_SECOND); + SetNonString(limit); + } + } + + #region templates: custom file naming + + [Description("Edit how filename characters are replaced")] + public ReplacementCharacters ReplacementCharacters { get => GetNonString(defaultValue: ReplacementCharacters.Default(IsWindows)); set => SetNonString(value); } + + [Description("How to format the folders in which files will be saved")] + public string FolderTemplate + { + get => getTemplate(); + set => setTemplate(value); + } + + [Description("How to format the saved pdf and audio files")] + public string FileTemplate + { + get => getTemplate(); + set => setTemplate(value); + } + + [Description("How to format the saved audio files when split by chapters")] + public string ChapterFileTemplate + { + get => getTemplate(); + set => setTemplate(value); + } + + [Description("How to format the file's Title stored in metadata")] + public string ChapterTitleTemplate + { + get => getTemplate(); + set => setTemplate(value); + } + + private string getTemplate([CallerMemberName] string propertyName = "") + where T : Templates.Templates, Templates.ITemplate, new() + { + return Templates.Templates.GetTemplate(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText; + } + + private void setTemplate(string newValue, [CallerMemberName] string propertyName = "") + where T : Templates.Templates, Templates.ITemplate, new() + { + SetString(Templates.Templates.GetTemplate(newValue).TemplateText, propertyName); + } + #endregion } diff --git a/Source/LibationFileManager/Configuration.PropertyChange.cs b/Source/LibationFileManager/Configuration.PropertyChange.cs index e1fbb08c..3913dbdb 100644 --- a/Source/LibationFileManager/Configuration.PropertyChange.cs +++ b/Source/LibationFileManager/Configuration.PropertyChange.cs @@ -1,33 +1,31 @@ using System.Collections.Generic; -#nullable enable -namespace LibationFileManager -{ - public partial class Configuration - { - /* - * Use this type in the getter for any Dictionary settings, - * and be sure to clone it before returning. This allows Configuration to - * accurately detect if any of the Dictionary's elements have changed. - */ - private class EquatableDictionary : Dictionary where TKey : notnull - { - public EquatableDictionary() { } - public EquatableDictionary(IEnumerable> keyValuePairs) : base(keyValuePairs) { } - public EquatableDictionary Clone() => new(this); - public override bool Equals(object? obj) - { - if (obj is Dictionary dic && Count == dic.Count) - { - foreach (var pair in this) - if (!dic.TryGetValue(pair.Key, out var value) || pair.Value?.Equals(value) is not true) - return false; +namespace LibationFileManager; - return true; - } - return false; +public partial class Configuration +{ + /* + * Use this type in the getter for any Dictionary settings, + * and be sure to clone it before returning. This allows Configuration to + * accurately detect if any of the Dictionary's elements have changed. + */ + private class EquatableDictionary : Dictionary where TKey : notnull + { + public EquatableDictionary() { } + public EquatableDictionary(IEnumerable> keyValuePairs) : base(keyValuePairs) { } + public EquatableDictionary Clone() => new(this); + public override bool Equals(object? obj) + { + if (obj is Dictionary dic && Count == dic.Count) + { + foreach (var pair in this) + if (!dic.TryGetValue(pair.Key, out var value) || pair.Value?.Equals(value) is not true) + return false; + + return true; } - public override int GetHashCode() => base.GetHashCode(); + return false; } + public override int GetHashCode() => base.GetHashCode(); } } diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index b4d36b9c..5d234af5 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -1,47 +1,42 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using Dinah.Core; -using FileManager; -using Newtonsoft.Json.Linq; - -#nullable enable -namespace LibationFileManager -{ - public partial class Configuration : PropertyChangeFilter - { - - #region singleton stuff - - public static Configuration CreateMockInstance() - { +using Dinah.Core; #if !DEBUG - if (!new StackTrace().GetFrames().Select(f => f.GetMethod()?.DeclaringType?.Assembly.GetName().Name).Any(f => f?.EndsWith(".Tests") ?? false)) - throw new InvalidOperationException($"Can only mock {nameof(Configuration)} in Debug mode or in test assemblies."); +using System.Linq; #endif - var mockInstance = new Configuration() { JsonBackedDictionary = new EphemeralDictionary() }; - mockInstance.SetString("Light", "ThemeVariant"); - Instance = mockInstance; - return mockInstance; - } - public static void RestoreSingletonInstance() - { - Instance = s_SingletonInstance; - } - private static readonly Configuration s_SingletonInstance = new(); - public static Configuration Instance { get; private set; } = s_SingletonInstance; - public bool IsEphemeralInstance => JsonBackedDictionary is EphemeralDictionary; +namespace LibationFileManager; - public Configuration CreateEphemeralCopy() - { - var copy = new Configuration(); - copy.LoadEphemeralSettings(Settings.GetJObject()); - return copy; - } +public partial class Configuration : PropertyChangeFilter +{ - private Configuration() { } - #endregion + #region singleton stuff + + public static Configuration CreateMockInstance() + { +#if !DEBUG + if (!new System.Diagnostics.StackTrace().GetFrames().Select(f => f.GetMethod()?.DeclaringType?.Assembly.GetName().Name).Any(f => f?.EndsWith(".Tests") ?? false)) + throw new System.InvalidOperationException($"Can only mock {nameof(Configuration)} in Debug mode or in test assemblies."); +#endif + + var mockInstance = new Configuration() { JsonBackedDictionary = new EphemeralDictionary() }; + mockInstance.SetString("Light", "ThemeVariant"); + Instance = mockInstance; + return mockInstance; } + public static void RestoreSingletonInstance() + { + Instance = s_SingletonInstance; + } + private static readonly Configuration s_SingletonInstance = new(); + public static Configuration Instance { get; private set; } = s_SingletonInstance; + public bool IsEphemeralInstance => JsonBackedDictionary is EphemeralDictionary; + + public Configuration CreateEphemeralCopy() + { + var copy = new Configuration(); + copy.LoadEphemeralSettings(Settings.GetJObject()); + return copy; + } + + private Configuration() { } + #endregion } diff --git a/Source/LibationFileManager/EphemeralDictionary.cs b/Source/LibationFileManager/EphemeralDictionary.cs index 7ed2a686..2a08b474 100644 --- a/Source/LibationFileManager/EphemeralDictionary.cs +++ b/Source/LibationFileManager/EphemeralDictionary.cs @@ -1,7 +1,6 @@ using FileManager; using Newtonsoft.Json.Linq; -#nullable enable namespace LibationFileManager; internal class EphemeralDictionary : IJsonBackedDictionary diff --git a/Source/LibationFileManager/FilePathCache.cs b/Source/LibationFileManager/FilePathCache.cs index 5e2c2b27..0e37c61c 100644 --- a/Source/LibationFileManager/FilePathCache.cs +++ b/Source/LibationFileManager/FilePathCache.cs @@ -7,234 +7,232 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public static class FilePathCache { - public static class FilePathCache + public record CacheEntry(string Id, FileType FileType, LongPath Path); + + private const string FILENAME_V2 = "FileLocationsV2.json"; + + public static event EventHandler? Inserted; + public static event EventHandler? Removed; + + private static LongPath jsonFileV2 => Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2); + + private static readonly FileCacheV2 Cache = new(); + + static FilePathCache() { - public record CacheEntry(string Id, FileType FileType, LongPath Path); + // load json into memory. if file doesn't exist, nothing to do. save() will create if needed + if (!File.Exists(jsonFileV2)) + return; - private const string FILENAME_V2 = "FileLocationsV2.json"; - - public static event EventHandler? Inserted; - public static event EventHandler? Removed; - - private static LongPath jsonFileV2 => Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2); - - private static readonly FileCacheV2 Cache = new(); - - static FilePathCache() + try { - // load json into memory. if file doesn't exist, nothing to do. save() will create if needed - if (!File.Exists(jsonFileV2)) - return; + Cache = JsonConvert.DeserializeObject>(File.ReadAllText(jsonFileV2)) + ?? throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy."); - try - { - Cache = JsonConvert.DeserializeObject>(File.ReadAllText(jsonFileV2)) - ?? throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy."); - - //Once per startup, launch a task to validate existence of files in the cache. - //This is fire-and-forget. Since it is never awaited, it will no exceptions will be thrown to the caller. - Task.Run(ValidateAllFiles); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFileV2 }); - lock (locker) - File.Delete(jsonFileV2); - return; - } + //Once per startup, launch a task to validate existence of files in the cache. + //This is fire-and-forget. Since it is never awaited, it will no exceptions will be thrown to the caller. + Task.Run(ValidateAllFiles); } - - private static void ValidateAllFiles() + catch (Exception ex) { - bool cacheChanged = false; - foreach (var id in Cache.GetIDs()) + Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFileV2 }); + lock (locker) + File.Delete(jsonFileV2); + return; + } + } + + private static void ValidateAllFiles() + { + bool cacheChanged = false; + foreach (var id in Cache.GetIDs()) + { + foreach (var entry in Cache.GetIdEntries(id)) { - foreach (var entry in Cache.GetIdEntries(id)) + if (!File.Exists(entry.Path)) { - if (!File.Exists(entry.Path)) - { - cacheChanged |= Remove(entry); - } + cacheChanged |= Remove(entry); } } - if (cacheChanged) - save(); } + if (cacheChanged) + save(); + } - public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null; + public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null; - public static List<(FileType fileType, LongPath path)> GetFiles(string id) + public static List<(FileType fileType, LongPath path)> GetFiles(string id) + { + List matchingFiles; + lock(locker) + matchingFiles = Cache.GetIdEntries(id); + + bool cacheChanged = false; + + //Verify all entries exist + for (int i = 0; i < matchingFiles.Count; i++) { - List matchingFiles; - lock(locker) - matchingFiles = Cache.GetIdEntries(id); + if (!File.Exists(matchingFiles[i].Path)) + { + var entryToRemove = matchingFiles[i]; + matchingFiles.RemoveAt(i); + cacheChanged |= Remove(entryToRemove); + } + } + if (cacheChanged) + save(); - bool cacheChanged = false; + return matchingFiles.Select(e => (e.FileType, e.Path)).ToList(); + } - //Verify all entries exist + public static LongPath? GetFirstPath(string id, FileType type) + { + List matchingFiles; + lock (locker) + matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList(); + + bool cacheChanged = false; + try + { + //Verify entries exist, but return first matching 'type' for (int i = 0; i < matchingFiles.Count; i++) { - if (!File.Exists(matchingFiles[i].Path)) + if (File.Exists(matchingFiles[i].Path)) + return matchingFiles[i].Path; + else { var entryToRemove = matchingFiles[i]; matchingFiles.RemoveAt(i); cacheChanged |= Remove(entryToRemove); } } + return null; + } + finally + { if (cacheChanged) save(); - - return matchingFiles.Select(e => (e.FileType, e.Path)).ToList(); } + } - public static LongPath? GetFirstPath(string id, FileType type) + private static bool Remove(CacheEntry entry) + { + bool removed; + lock (locker) + removed = Cache.Remove(entry.Id, entry); + if (removed) { - List matchingFiles; - lock (locker) - matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList(); - - bool cacheChanged = false; - try - { - //Verify entries exist, but return first matching 'type' - for (int i = 0; i < matchingFiles.Count; i++) - { - if (File.Exists(matchingFiles[i].Path)) - return matchingFiles[i].Path; - else - { - var entryToRemove = matchingFiles[i]; - matchingFiles.RemoveAt(i); - cacheChanged |= Remove(entryToRemove); - } - } - return null; - } - finally - { - if (cacheChanged) - save(); - } + Removed?.Invoke(null, entry); + return true; } + return false; + } - private static bool Remove(CacheEntry entry) + public static void Insert(string id, params string[] paths) + { + var newEntries + = paths + .Select(path => new CacheEntry(id, FileTypes.GetFileTypeFromPath(path), path)) + .ToList(); + + lock (locker) + Cache.AddRange(id, newEntries); + + if (Inserted is not null) + newEntries.ForEach(e => Inserted?.Invoke(null, e)); + + save(); + } + + public static void Insert(CacheEntry entry) + { + lock(locker) + Cache.Add(entry.Id, entry); + Inserted?.Invoke(null, entry); + save(); + } + + // cache is thread-safe and lock free. but file saving is not + private static object locker { get; } = new object(); + private static void save() + { + // create json if not exists + static void resave() => File.WriteAllText(jsonFileV2, JsonConvert.SerializeObject(Cache, Formatting.Indented)); + + lock (locker) { - bool removed; - lock (locker) - removed = Cache.Remove(entry.Id, entry); - if (removed) - { - Removed?.Invoke(null, entry); - return true; - } - return false; - } - - public static void Insert(string id, params string[] paths) - { - var newEntries - = paths - .Select(path => new CacheEntry(id, FileTypes.GetFileTypeFromPath(path), path)) - .ToList(); - - lock (locker) - Cache.AddRange(id, newEntries); - - if (Inserted is not null) - newEntries.ForEach(e => Inserted?.Invoke(null, e)); - - save(); - } - - public static void Insert(CacheEntry entry) - { - lock(locker) - Cache.Add(entry.Id, entry); - Inserted?.Invoke(null, entry); - save(); - } - - // cache is thread-safe and lock free. but file saving is not - private static object locker { get; } = new object(); - private static void save() - { - // create json if not exists - static void resave() => File.WriteAllText(jsonFileV2, JsonConvert.SerializeObject(Cache, Formatting.Indented)); - - lock (locker) + try { resave(); } + catch (IOException) { try { resave(); } - catch (IOException) + catch (IOException ex) { - try { resave(); } - catch (IOException ex) - { - Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME_V2}"); - throw; - } - } - } - } - - private class FileCacheV2 - { - [JsonProperty] - private readonly ConcurrentDictionary> Dictionary = new(); - private static object lockObject = new(); - - public List GetIDs() => Dictionary.Keys.ToList(); - - public List GetIdEntries(string id) - { - static List empty() => new(); - - return Dictionary.TryGetValue(id, out var entries) ? entries.ToList() : empty(); - } - - public void Add(string id, TEntry entry) - { - Dictionary.AddOrUpdate(id, - (_, e) => [e], //Add new Dictionary Value - (id, existingEntries, newEntry) => //Update existing Dictionary Value - { - existingEntries.Add(entry); - return existingEntries; - }, - entry); - } - - public void AddRange(string id, IEnumerable entries) - { - Dictionary.AddOrUpdate>(id, - (_, e) => e.ToHashSet(), //Add new Dictionary Value - (id, existingEntries, newEntries) => //Update existing Dictionary Value - { - foreach (var entry in newEntries) - existingEntries.Add(entry); - return existingEntries; - }, - entries); - } - - public bool Remove(string id, TEntry entry) - { - lock (lockObject) - { - if (Dictionary.TryGetValue(id, out HashSet? entries)) - { - var removed = entries?.Remove(entry) ?? false; - if (removed && entries?.Count == 0) - { - Dictionary.Remove(id, out _); - } - return removed; - } - else return false; + Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME_V2}"); + throw; } } } } + + private class FileCacheV2 + { + [JsonProperty] + private readonly ConcurrentDictionary> Dictionary = new(); + private static object lockObject = new(); + + public List GetIDs() => Dictionary.Keys.ToList(); + + public List GetIdEntries(string id) + { + static List empty() => new(); + + return Dictionary.TryGetValue(id, out var entries) ? entries.ToList() : empty(); + } + + public void Add(string id, TEntry entry) + { + Dictionary.AddOrUpdate(id, + (_, e) => [e], //Add new Dictionary Value + (id, existingEntries, newEntry) => //Update existing Dictionary Value + { + existingEntries.Add(entry); + return existingEntries; + }, + entry); + } + + public void AddRange(string id, IEnumerable entries) + { + Dictionary.AddOrUpdate>(id, + (_, e) => e.ToHashSet(), //Add new Dictionary Value + (id, existingEntries, newEntries) => //Update existing Dictionary Value + { + foreach (var entry in newEntries) + existingEntries.Add(entry); + return existingEntries; + }, + entries); + } + + public bool Remove(string id, TEntry entry) + { + lock (lockObject) + { + if (Dictionary.TryGetValue(id, out HashSet? entries)) + { + var removed = entries?.Remove(entry) ?? false; + if (removed && entries?.Count == 0) + { + Dictionary.Remove(id, out _); + } + return removed; + } + else return false; + } + } + } } diff --git a/Source/LibationFileManager/IInteropFunctions.cs b/Source/LibationFileManager/IInteropFunctions.cs index 1a3d01d5..897ce186 100644 --- a/Source/LibationFileManager/IInteropFunctions.cs +++ b/Source/LibationFileManager/IInteropFunctions.cs @@ -1,18 +1,13 @@ -using System; -using System.Diagnostics; -using System.Threading.Tasks; +using System.Diagnostics; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public interface IInteropFunctions { - public interface IInteropFunctions - { - void SetFolderIcon(string image, string directory); - void DeleteFolderIcon(string directory); - Process RunAsRoot(string exe, string args); - void InstallUpgrade(string upgradeBundle); - bool CanUpgrade { get; } - string ReleaseIdString { get; } - } - + void SetFolderIcon(string image, string directory); + void DeleteFolderIcon(string directory); + Process? RunAsRoot(string exe, string args); + void InstallUpgrade(string upgradeBundle); + bool CanUpgrade { get; } + string ReleaseIdString { get; } } diff --git a/Source/LibationFileManager/InteropFactory.cs b/Source/LibationFileManager/InteropFactory.cs index cfb9598a..63895bb7 100644 --- a/Source/LibationFileManager/InteropFactory.cs +++ b/Source/LibationFileManager/InteropFactory.cs @@ -5,129 +5,127 @@ using System.Linq; using System.Reflection; using Dinah.Core; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public static class InteropFactory { - public static class InteropFactory - { - public static Type? InteropFunctionsType { get; } + public static Type? InteropFunctionsType { get; } - public static IInteropFunctions Create() => _create(); + public static IInteropFunctions Create() => _create(); - //// examples of the pattern which could be useful later - //public static IInteropFunctions Create(string str, int i) => _create(str, i); - //public static IInteropFunctions Create(params object[] values) => _create(values); + //// examples of the pattern which could be useful later + //public static IInteropFunctions Create(string str, int i) => _create(str, i); + //public static IInteropFunctions Create(params object[] values) => _create(values); - private static IInteropFunctions? instance { get; set; } - private static IInteropFunctions _create(params object[] values) - { - instance ??= - InteropFunctionsType is null - ? new NullInteropFunctions() - : Activator.CreateInstance(InteropFunctionsType, values) as IInteropFunctions; + private static IInteropFunctions? instance { get; set; } + private static IInteropFunctions _create(params object[] values) + { + instance ??= + InteropFunctionsType is null + ? new NullInteropFunctions() + : Activator.CreateInstance(InteropFunctionsType, values) as IInteropFunctions; - if (instance is null) - throw new TypeLoadException(); + if (instance is null) + throw new TypeLoadException(); - return instance; - } + return instance; + } - #region load types + #region load types - private const string CONFIG_APP_ENDING = "ConfigApp.dll"; + private const string CONFIG_APP_ENDING = "ConfigApp.dll"; - public static Func MatchesOS { get; } - = Configuration.IsWindows ? a => Path.GetFileName(a).StartsWithInsensitive("win") - : Configuration.IsLinux ? a => Path.GetFileName(a).StartsWithInsensitive("linux") - : Configuration.IsMacOs ? a => Path.GetFileName(a).StartsWithInsensitive("mac") || Path.GetFileName(a).StartsWithInsensitive("osx") - : _ => false; + public static Func MatchesOS { get; } + = Configuration.IsWindows ? a => Path.GetFileName(a).StartsWithInsensitive("win") + : Configuration.IsLinux ? a => Path.GetFileName(a).StartsWithInsensitive("linux") + : Configuration.IsMacOs ? a => Path.GetFileName(a).StartsWithInsensitive("mac") || Path.GetFileName(a).StartsWithInsensitive("osx") + : _ => false; - private static readonly EnumerationOptions enumerationOptions = new() + private static readonly EnumerationOptions enumerationOptions = new() + { + MatchType = MatchType.Simple, + MatchCasing = MatchCasing.CaseInsensitive, + IgnoreInaccessible = true, + RecurseSubdirectories = false, + ReturnSpecialDirectories = false + }; + + static InteropFactory() + { + // searches file names for potential matches; doesn't run anything + var configApp = getOSConfigApp(); + + // nothing to load + if (configApp is null) { - MatchType = MatchType.Simple, - MatchCasing = MatchCasing.CaseInsensitive, - IgnoreInaccessible = true, - RecurseSubdirectories = false, - ReturnSpecialDirectories = false - }; + Serilog.Log.Logger.Error($"Unable to locate *{CONFIG_APP_ENDING}"); + return; + } - static InteropFactory() - { - // searches file names for potential matches; doesn't run anything - var configApp = getOSConfigApp(); + AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve; - // nothing to load - if (configApp is null) - { - Serilog.Log.Logger.Error($"Unable to locate *{CONFIG_APP_ENDING}"); - return; - } + try + { + var configAppAssembly = Assembly.LoadFrom(configApp); + var type = typeof(IInteropFunctions); + InteropFunctionsType = configAppAssembly + .GetTypes() + .FirstOrDefault(type.IsAssignableFrom); + } + catch (Exception e) + { + //None of the interop functions are strictly necessary for Libation to run. + Serilog.Log.Logger.Error(e, "Unable to load types from assembly {configApp}", configApp); + } + } + private static string? getOSConfigApp() + { + // find '*ConfigApp.dll' files + var appName = + Directory.EnumerateFiles(Configuration.ProcessDirectory, $"*{CONFIG_APP_ENDING}", enumerationOptions) + .FirstOrDefault(exe => MatchesOS(exe)); - AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve; + return appName; + } - try - { - var configAppAssembly = Assembly.LoadFrom(configApp); - var type = typeof(IInteropFunctions); - InteropFunctionsType = configAppAssembly - .GetTypes() - .FirstOrDefault(type.IsAssignableFrom); - } - catch (Exception e) - { - //None of the interop functions are strictly necessary for Libation to run. - Serilog.Log.Logger.Error(e, "Unable to load types from assembly {configApp}", configApp); - } - } - private static string? getOSConfigApp() - { - // find '*ConfigApp.dll' files - var appName = - Directory.EnumerateFiles(Configuration.ProcessDirectory, $"*{CONFIG_APP_ENDING}", enumerationOptions) - .FirstOrDefault(exe => MatchesOS(exe)); + private static Dictionary lowEffortCache { get; } = new(); + private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs args) + { + var asmName = new AssemblyName(args.Name); + var here = Configuration.ProcessDirectory; - return appName; - } + var key = $"{asmName}|{here}"; - private static Dictionary lowEffortCache { get; } = new(); - private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs args) - { - var asmName = new AssemblyName(args.Name); - var here = Configuration.ProcessDirectory; + if (lowEffortCache.TryGetValue(key, out var value)) + return value; - var key = $"{asmName}|{here}"; + var assembly = CurrentDomain_AssemblyResolve_internal(asmName, here: here); + lowEffortCache[key] = assembly; - if (lowEffortCache.TryGetValue(key, out var value)) - return value; + // Let the runtime handle any dll not found exceptions + if (assembly is null) + Serilog.Log.Logger.Warning($"Unable to load module {args.Name}"); - var assembly = CurrentDomain_AssemblyResolve_internal(asmName, here: here); - lowEffortCache[key] = assembly; + return assembly; + } - // Let the runtime handle any dll not found exceptions - if (assembly is null) - Serilog.Log.Logger.Warning($"Unable to load module {args.Name}"); + private static Assembly? CurrentDomain_AssemblyResolve_internal(AssemblyName asmName, string here) + { + /* + * Find the requested assembly in the program files directory. + * Assumes that all assemblies are in this application's directory. + * If they're not (e.g. the app is not self-contained), you will need + * to located them. The original way of doing this was to execute the + * config app, wait for the runtime to load all dependencies, and + * then seach the Process.Modules for the assembly name. Code for + * this approach is still in the _Demos projects. + */ + var modulePath = + Directory.EnumerateFiles(here, $"{asmName.Name}.dll", enumerationOptions) + .SingleOrDefault(); - return assembly; - } + return modulePath is null ? null : Assembly.LoadFrom(modulePath); + } - private static Assembly? CurrentDomain_AssemblyResolve_internal(AssemblyName asmName, string here) - { - /* - * Find the requested assembly in the program files directory. - * Assumes that all assemblies are in this application's directory. - * If they're not (e.g. the app is not self-contained), you will need - * to located them. The original way of doing this was to execute the - * config app, wait for the runtime to load all dependencies, and - * then seach the Process.Modules for the assembly name. Code for - * this approach is still in the _Demos projects. - */ - var modulePath = - Directory.EnumerateFiles(here, $"{asmName.Name}.dll", enumerationOptions) - .SingleOrDefault(); - - return modulePath is null ? null : Assembly.LoadFrom(modulePath); - } - - #endregion - } + #endregion } diff --git a/Source/LibationFileManager/LibationFileManager.csproj b/Source/LibationFileManager/LibationFileManager.csproj index b7a50456..1049f7da 100644 --- a/Source/LibationFileManager/LibationFileManager.csproj +++ b/Source/LibationFileManager/LibationFileManager.csproj @@ -2,6 +2,7 @@ net10.0 + enable diff --git a/Source/LibationFileManager/LibationFiles.cs b/Source/LibationFileManager/LibationFiles.cs index dc9bcde0..50ef175d 100644 --- a/Source/LibationFileManager/LibationFiles.cs +++ b/Source/LibationFileManager/LibationFiles.cs @@ -11,7 +11,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("AppScaffolding")] [assembly: InternalsVisibleTo("LibationUiBase.Tests")] -#nullable enable namespace LibationFileManager; /// diff --git a/Source/LibationFileManager/LogFileFilter.cs b/Source/LibationFileManager/LogFileFilter.cs index 955b15a9..f706f300 100644 --- a/Source/LibationFileManager/LogFileFilter.cs +++ b/Source/LibationFileManager/LogFileFilter.cs @@ -9,7 +9,6 @@ using System.Linq; using System.Reflection; using System.Text; -#nullable enable namespace LibationFileManager; /// diff --git a/Source/LibationFileManager/NullInteropFunctions.cs b/Source/LibationFileManager/NullInteropFunctions.cs index ee9e7163..f184a471 100644 --- a/Source/LibationFileManager/NullInteropFunctions.cs +++ b/Source/LibationFileManager/NullInteropFunctions.cs @@ -1,20 +1,18 @@ using System; using System.Diagnostics; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public class NullInteropFunctions : IInteropFunctions { - public class NullInteropFunctions : IInteropFunctions - { - public NullInteropFunctions() { } - public NullInteropFunctions(params object[] values) { } + public NullInteropFunctions() { } + public NullInteropFunctions(params object[] values) { } - public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); - public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); - public bool CanUpgrade => throw new PlatformNotSupportedException(); - public string ReleaseIdString => throw new PlatformNotSupportedException(); - public Process RunAsRoot(string exe, string args) => throw new PlatformNotSupportedException(); - public void InstallUpgrade(string updateBundle) => throw new PlatformNotSupportedException(); - } + public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); + public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); + public bool CanUpgrade => throw new PlatformNotSupportedException(); + public string ReleaseIdString => throw new PlatformNotSupportedException(); + public Process RunAsRoot(string exe, string args) => throw new PlatformNotSupportedException(); + public void InstallUpgrade(string updateBundle) => throw new PlatformNotSupportedException(); } diff --git a/Source/LibationFileManager/PictureStorage.cs b/Source/LibationFileManager/PictureStorage.cs index 2ea746d9..e8ccfa98 100644 --- a/Source/LibationFileManager/PictureStorage.cs +++ b/Source/LibationFileManager/PictureStorage.cs @@ -3,158 +3,155 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public enum PictureSize { Native, _80x80 = 80, _300x300 = 300, _500x500 = 500 } +public class PictureCachedEventArgs : EventArgs { - public enum PictureSize { Native, _80x80 = 80, _300x300 = 300, _500x500 = 500 } - public class PictureCachedEventArgs : EventArgs - { - public PictureDefinition Definition { get; } - public byte[] Picture { get; } + public PictureDefinition Definition { get; } + public byte[] Picture { get; } - internal PictureCachedEventArgs(PictureDefinition definition, byte[] picture) + internal PictureCachedEventArgs(PictureDefinition definition, byte[] picture) + { + Definition = definition; + Picture = picture; + } +} +public struct PictureDefinition : IEquatable +{ + public string PictureId { get; init; } + public PictureSize Size { get; init; } + + public PictureDefinition(string pictureId, PictureSize pictureSize) + { + PictureId = pictureId; + Size = pictureSize; + } + + public bool Equals(PictureDefinition other) + { + return PictureId == other.PictureId && Size == other.Size; + } +} +public static class PictureStorage +{ + // not customizable. don't move to config + private static string ImagesDirectory { get; } + = new DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("Images").FullName; + + private static string getPath(PictureDefinition def) + => Path.Combine(ImagesDirectory, $"{def.PictureId}{def.Size}.jpg"); + + static PictureStorage() + { + new Task(BackgroundDownloader, TaskCreationOptions.LongRunning) + .Start(); + } + + public static event EventHandler? PictureCached; + + private static BlockingCollection DownloadQueue { get; } = new BlockingCollection(); + private static object cacheLocker { get; } = new object(); + private static Dictionary cache { get; } = new Dictionary(); + private static Dictionary defaultImages { get; } = new Dictionary(); + public static (bool isDefault, byte[] bytes) GetPicture(PictureDefinition def) + { + lock (cacheLocker) { - Definition = definition; - Picture = picture; + if (cache.ContainsKey(def)) + return (false, cache[def]); + + var path = getPath(def); + + if (File.Exists(path)) + { + cache[def] = File.ReadAllBytes(path); + return (false, cache[def]); + } + + DownloadQueue.Add(def); + return (true, GetDefaultImage(def.Size)); } } - public struct PictureDefinition : IEquatable + + public static string GetPicturePathSynchronously(PictureDefinition def, CancellationToken cancellationToken = default) { - public string PictureId { get; init; } - public PictureSize Size { get; init; } + GetPictureSynchronously(def, cancellationToken); + return getPath(def); + } - public PictureDefinition(string pictureId, PictureSize pictureSize) + public static byte[] GetPictureSynchronously(PictureDefinition def, CancellationToken cancellationToken = default) + { + lock (cacheLocker) { - PictureId = pictureId; - Size = pictureSize; - } - - public bool Equals(PictureDefinition other) - { - return PictureId == other.PictureId && Size == other.Size; + if (!cache.ContainsKey(def) || cache[def] is null) + { + var path = getPath(def); + var bytes + = File.Exists(path) + ? File.ReadAllBytes(path) + : downloadBytes(def, cancellationToken); + cache[def] = bytes; + } + return cache[def]; } } - public static class PictureStorage - { - // not customizable. don't move to config - private static string ImagesDirectory { get; } - = new DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("Images").FullName; - private static string getPath(PictureDefinition def) - => Path.Combine(ImagesDirectory, $"{def.PictureId}{def.Size}.jpg"); + public static void SetDefaultImage(PictureSize pictureSize, byte[] bytes) + => defaultImages[pictureSize] = bytes; + public static byte[] GetDefaultImage(PictureSize size) + => defaultImages.ContainsKey(size) + ? defaultImages[size] + : new byte[0]; - static PictureStorage() + static void BackgroundDownloader() + { + while (!DownloadQueue.IsCompleted) { - new Task(BackgroundDownloader, TaskCreationOptions.LongRunning) - .Start(); - } + if (!DownloadQueue.TryTake(out var def, System.Threading.Timeout.InfiniteTimeSpan)) + continue; - public static event EventHandler? PictureCached; - - private static BlockingCollection DownloadQueue { get; } = new BlockingCollection(); - private static object cacheLocker { get; } = new object(); - private static Dictionary cache { get; } = new Dictionary(); - private static Dictionary defaultImages { get; } = new Dictionary(); - public static (bool isDefault, byte[] bytes) GetPicture(PictureDefinition def) - { + var bytes = downloadBytes(def); lock (cacheLocker) - { - if (cache.ContainsKey(def)) - return (false, cache[def]); + cache[def] = bytes; - var path = getPath(def); - - if (File.Exists(path)) - { - cache[def] = File.ReadAllBytes(path); - return (false, cache[def]); - } - - DownloadQueue.Add(def); - return (true, GetDefaultImage(def.Size)); - } + PictureCached?.Invoke(nameof(PictureStorage), new PictureCachedEventArgs(def, bytes)); } + } - public static string GetPicturePathSynchronously(PictureDefinition def, CancellationToken cancellationToken = default) - { - GetPictureSynchronously(def, cancellationToken); - return getPath(def); - } + private static HttpClient imageDownloadClient { get; } = new HttpClient(); + private static byte[] downloadBytes(PictureDefinition def, CancellationToken cancellationToken = default) + { + if (def.PictureId is null) + return GetDefaultImage(def.Size); - public static byte[] GetPictureSynchronously(PictureDefinition def, CancellationToken cancellationToken = default) + try { - lock (cacheLocker) - { - if (!cache.ContainsKey(def) || cache[def] is null) - { - var path = getPath(def); - var bytes - = File.Exists(path) - ? File.ReadAllBytes(path) - : downloadBytes(def, cancellationToken); - cache[def] = bytes; - } - return cache[def]; - } - } + var sizeStr = def.Size == PictureSize.Native ? "" : $"._SL{(int)def.Size}_"; - public static void SetDefaultImage(PictureSize pictureSize, byte[] bytes) - => defaultImages[pictureSize] = bytes; - public static byte[] GetDefaultImage(PictureSize size) - => defaultImages.ContainsKey(size) - ? defaultImages[size] - : new byte[0]; - - static void BackgroundDownloader() - { - while (!DownloadQueue.IsCompleted) - { - if (!DownloadQueue.TryTake(out var def, System.Threading.Timeout.InfiniteTimeSpan)) - continue; - - var bytes = downloadBytes(def); - lock (cacheLocker) - cache[def] = bytes; - - PictureCached?.Invoke(nameof(PictureStorage), new PictureCachedEventArgs(def, bytes)); - } - } - - private static HttpClient imageDownloadClient { get; } = new HttpClient(); - private static byte[] downloadBytes(PictureDefinition def, CancellationToken cancellationToken = default) - { - if (def.PictureId is null) + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, "ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg"); + using var response = imageDownloadClient.Send(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength is not long size) return GetDefaultImage(def.Size); - try - { - var sizeStr = def.Size == PictureSize.Native ? "" : $"._SL{(int)def.Size}_"; + var bytes = new byte[size]; + using var respStream = response.Content.ReadAsStream(cancellationToken); + respStream.ReadExactly(bytes); - using var requestMessage = new HttpRequestMessage(HttpMethod.Get, "ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg"); - using var response = imageDownloadClient.Send(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).EnsureSuccessStatusCode(); - - if (response.Content.Headers.ContentLength is not long size) - return GetDefaultImage(def.Size); + // save image file. make sure to not save default image + var path = getPath(def); + File.WriteAllBytes(path, bytes); - var bytes = new byte[size]; - using var respStream = response.Content.ReadAsStream(cancellationToken); - respStream.ReadExactly(bytes); - - // save image file. make sure to not save default image - var path = getPath(def); - File.WriteAllBytes(path, bytes); - - return bytes; - } - catch - { - return GetDefaultImage(def.Size); - } + return bytes; + } + catch + { + return GetDefaultImage(def.Size); } } } \ No newline at end of file diff --git a/Source/LibationFileManager/QuickFilters.cs b/Source/LibationFileManager/QuickFilters.cs index 0255133f..f47fae99 100644 --- a/Source/LibationFileManager/QuickFilters.cs +++ b/Source/LibationFileManager/QuickFilters.cs @@ -5,137 +5,135 @@ using System.Collections.Generic; using System.IO; using System.Linq; -#nullable enable -namespace LibationFileManager +namespace LibationFileManager; + +public static class QuickFilters { - public static class QuickFilters - { - public static event EventHandler? Updated; + public static event EventHandler? Updated; - public static event EventHandler? UseDefaultChanged; + public static event EventHandler? UseDefaultChanged; - public class FilterState - { - public bool UseDefault { get; set; } - public List Filters { get; set; } = new(); - } + public class FilterState + { + public bool UseDefault { get; set; } + public List Filters { get; set; } = new(); + } - public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "QuickFilters.json"); + public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "QuickFilters.json"); - // load json into memory. if file doesn't exist, nothing to do. save() will create if needed - public static FilterState? InMemoryState { get; set; } + // load json into memory. if file doesn't exist, nothing to do. save() will create if needed + public static FilterState? InMemoryState { get; set; } - public static bool UseDefault - { - get => InMemoryState?.UseDefault ?? false; - set - { - if (InMemoryState is null || UseDefault == value) - return; - - lock (locker) - { - InMemoryState.UseDefault = value; - save(false); - } - - UseDefaultChanged?.Invoke(null, EventArgs.Empty); - } - } - - // Note that records overload equality automagically, so should be able to - // compare these the same way as comparing simple strings. - public record NamedFilter(string Filter, string? Name) - { - public string Filter { get; set; } = Filter; - public string? Name { get; set; } = Name; - } - - public static IEnumerable Filters - => InMemoryState?.Filters.AsReadOnly() ?? Enumerable.Empty(); - - public static void Add(NamedFilter namedFilter) - { - if (namedFilter == null) - throw new ArgumentNullException(nameof(namedFilter)); - - if (string.IsNullOrWhiteSpace(namedFilter.Filter)) - return; - namedFilter.Filter = namedFilter.Filter?.Trim() ?? string.Empty; - namedFilter.Name = namedFilter.Name?.Trim() ?? null; + public static bool UseDefault + { + get => InMemoryState?.UseDefault ?? false; + set + { + if (InMemoryState is null || UseDefault == value) + return; lock (locker) { - InMemoryState ??= new(); - // check for duplicates - if (InMemoryState.Filters.Select(x => x.Filter).ContainsInsensative(namedFilter.Filter)) - return; - - InMemoryState.Filters.Add(namedFilter); - save(); - } - } - - public static void Remove(NamedFilter filter) - { - lock (locker) - { - if (InMemoryState is null) - return; - InMemoryState.Filters.Remove(filter); - save(); - } - } - - public static void Edit(NamedFilter oldFilter, NamedFilter newFilter) - { - lock (locker) - { - if (InMemoryState is null || InMemoryState.Filters.IndexOf(oldFilter) < 0) - return; - - InMemoryState.Filters = InMemoryState.Filters.Select(f => f == oldFilter ? newFilter : f).ToList(); - - save(); - } - } - - public static void ReplaceAll(IEnumerable filters) - { - filters = filters - .Where(f => !string.IsNullOrWhiteSpace(f.Filter)) - .Distinct(); - foreach (var filter in filters) - filter.Filter = filter.Filter.Trim(); - lock (locker) - { - InMemoryState ??= new(); - InMemoryState.Filters = new List(filters); - save(); - } - } - - private static object locker { get; } = new(); - - // ONLY call this within lock() - private static void save(bool invokeUpdatedEvent = true) - { - // create json if not exists - void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(InMemoryState, Formatting.Indented)); - try { resave(); } - catch (IOException) - { - try { resave(); } - catch (IOException ex) - { - Serilog.Log.Logger.Error(ex, "Error saving QuickFilters.json"); - throw; - } + InMemoryState.UseDefault = value; + save(false); } - if (invokeUpdatedEvent) - Updated?.Invoke(null, EventArgs.Empty); - } - } + UseDefaultChanged?.Invoke(null, EventArgs.Empty); + } + } + + // Note that records overload equality automagically, so should be able to + // compare these the same way as comparing simple strings. + public record NamedFilter(string Filter, string? Name) + { + public string Filter { get; set; } = Filter; + public string? Name { get; set; } = Name; + } + + public static IEnumerable Filters + => InMemoryState?.Filters.AsReadOnly() ?? Enumerable.Empty(); + + public static void Add(NamedFilter namedFilter) + { + if (namedFilter == null) + throw new ArgumentNullException(nameof(namedFilter)); + + if (string.IsNullOrWhiteSpace(namedFilter.Filter)) + return; + namedFilter.Filter = namedFilter.Filter?.Trim() ?? string.Empty; + namedFilter.Name = namedFilter.Name?.Trim() ?? null; + + lock (locker) + { + InMemoryState ??= new(); + // check for duplicates + if (InMemoryState.Filters.Select(x => x.Filter).ContainsInsensative(namedFilter.Filter)) + return; + + InMemoryState.Filters.Add(namedFilter); + save(); + } + } + + public static void Remove(NamedFilter filter) + { + lock (locker) + { + if (InMemoryState is null) + return; + InMemoryState.Filters.Remove(filter); + save(); + } + } + + public static void Edit(NamedFilter oldFilter, NamedFilter newFilter) + { + lock (locker) + { + if (InMemoryState is null || InMemoryState.Filters.IndexOf(oldFilter) < 0) + return; + + InMemoryState.Filters = InMemoryState.Filters.Select(f => f == oldFilter ? newFilter : f).ToList(); + + save(); + } + } + + public static void ReplaceAll(IEnumerable filters) + { + filters = filters + .Where(f => !string.IsNullOrWhiteSpace(f.Filter)) + .Distinct(); + foreach (var filter in filters) + filter.Filter = filter.Filter.Trim(); + lock (locker) + { + InMemoryState ??= new(); + InMemoryState.Filters = new List(filters); + save(); + } + } + + private static object locker { get; } = new(); + + // ONLY call this within lock() + private static void save(bool invokeUpdatedEvent = true) + { + // create json if not exists + void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(InMemoryState, Formatting.Indented)); + try { resave(); } + catch (IOException) + { + try { resave(); } + catch (IOException ex) + { + Serilog.Log.Logger.Error(ex, "Error saving QuickFilters.json"); + throw; + } + } + + if (invokeUpdatedEvent) + Updated?.Invoke(null, EventArgs.Empty); + } } diff --git a/Source/LibationFileManager/Templates/CombinedDto.cs b/Source/LibationFileManager/Templates/CombinedDto.cs index c72afdf8..d755eb8c 100644 --- a/Source/LibationFileManager/Templates/CombinedDto.cs +++ b/Source/LibationFileManager/Templates/CombinedDto.cs @@ -1,6 +1,5 @@ using AaxDecrypter; -#nullable enable namespace LibationFileManager.Templates; public class CombinedDto diff --git a/Source/LibationFileManager/Templates/ContributorDto.cs b/Source/LibationFileManager/Templates/ContributorDto.cs index 593c7950..60376f3d 100644 --- a/Source/LibationFileManager/Templates/ContributorDto.cs +++ b/Source/LibationFileManager/Templates/ContributorDto.cs @@ -1,7 +1,6 @@ using NameParser; using System; -#nullable enable namespace LibationFileManager.Templates; public class ContributorDto : IFormattable @@ -24,8 +23,8 @@ public class ContributorDto : IFormattable //Single-word names parse as first names. Use it as last name. var lastName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.First : HumanName.Last; - //Because of the above, if the have only a first name, then we'd double the name as "FirstName FirstName", so clear the first name in that situation. - var firstName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.Last : HumanName.First; + //Because of the above, if the have only a first name, then we'd double the name as "FirstName FirstName", so clear the first name in that situation. + var firstName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.Last : HumanName.First; return format .Replace("{T}", HumanName.Title) diff --git a/Source/LibationFileManager/Templates/IListFormat[TList].cs b/Source/LibationFileManager/Templates/IListFormat[TList].cs index e1911a16..3dab3a41 100644 --- a/Source/LibationFileManager/Templates/IListFormat[TList].cs +++ b/Source/LibationFileManager/Templates/IListFormat[TList].cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; -#nullable enable namespace LibationFileManager.Templates; internal partial interface IListFormat where TList : IListFormat diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index 1c689845..dc10fd3a 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -2,12 +2,11 @@ using System.Collections.Generic; using System.Linq; -#nullable enable namespace LibationFileManager.Templates; public class BookDto { - public string? AudibleProductId { get; set; } + public required string AudibleProductId { get; set; } public string? Title { get; set; } public string? Subtitle { get; set; } public string? TitleWithSubtitle { get; set; } diff --git a/Source/LibationFileManager/Templates/NameListFormat.cs b/Source/LibationFileManager/Templates/NameListFormat.cs index efe3c35d..72a4a9c8 100644 --- a/Source/LibationFileManager/Templates/NameListFormat.cs +++ b/Source/LibationFileManager/Templates/NameListFormat.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; -#nullable enable namespace LibationFileManager.Templates; internal partial class NameListFormat : IListFormat diff --git a/Source/LibationFileManager/Templates/SeriesDto.cs b/Source/LibationFileManager/Templates/SeriesDto.cs index a2d7a867..f8b9a490 100644 --- a/Source/LibationFileManager/Templates/SeriesDto.cs +++ b/Source/LibationFileManager/Templates/SeriesDto.cs @@ -1,25 +1,24 @@ using System; using System.Text.RegularExpressions; -#nullable enable namespace LibationFileManager.Templates; public partial record SeriesDto : IFormattable { - public string Name { get; } + public string? Name { get; } public SeriesOrder Order { get; } public string AudibleSeriesId { get; } - public SeriesDto(string name, string? number, string audibleSeriesId) + public SeriesDto(string? name, string? number, string audibleSeriesId) { Name = name; Order = SeriesOrder.Parse(number); AudibleSeriesId = audibleSeriesId; } - public override string ToString() => Name.Trim(); + public override string? ToString() => Name?.Trim(); public string ToString(string? format, IFormatProvider? _) - => string.IsNullOrWhiteSpace(format) ? ToString() + => string.IsNullOrWhiteSpace(format) ? ToString() ?? string.Empty : FormatRegex().Replace(format, MatchEvaluator) .Replace("{N}", Name) .Replace("{ID}", AudibleSeriesId) diff --git a/Source/LibationFileManager/Templates/SeriesListFormat.cs b/Source/LibationFileManager/Templates/SeriesListFormat.cs index 9db3e441..457a9521 100644 --- a/Source/LibationFileManager/Templates/SeriesListFormat.cs +++ b/Source/LibationFileManager/Templates/SeriesListFormat.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Text.RegularExpressions; -#nullable enable namespace LibationFileManager.Templates; internal partial class SeriesListFormat : IListFormat diff --git a/Source/LibationFileManager/Templates/SeriesOrder.cs b/Source/LibationFileManager/Templates/SeriesOrder.cs index 2fe4edbf..85829a34 100644 --- a/Source/LibationFileManager/Templates/SeriesOrder.cs +++ b/Source/LibationFileManager/Templates/SeriesOrder.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -#nullable enable namespace LibationFileManager.Templates; public class SeriesOrder : IFormattable diff --git a/Source/LibationFileManager/Templates/TemplateEditor[T].cs b/Source/LibationFileManager/Templates/TemplateEditor[T].cs index 724f1989..bb1b435f 100644 --- a/Source/LibationFileManager/Templates/TemplateEditor[T].cs +++ b/Source/LibationFileManager/Templates/TemplateEditor[T].cs @@ -3,155 +3,153 @@ using FileManager; using System; using System.IO; -#nullable enable -namespace LibationFileManager.Templates +namespace LibationFileManager.Templates; + +public interface ITemplateEditor { - public interface ITemplateEditor + bool IsFolder { get; } + bool IsFilePath { get; } + LongPath BaseDirectory { get; } + string DefaultTemplate { get; } + string TemplateName { get; } + string TemplateDescription { get; } + Templates EditingTemplate { get; } + bool SetTemplateText(string? templateText); + string? GetFolderName(); + string? GetFileName(); + string? GetName(); +} + +public class TemplateEditor : ITemplateEditor where T : Templates, ITemplate, new() +{ + public bool IsFolder => EditingTemplate is Templates.FolderTemplate; + public bool IsFilePath => EditingTemplate is not Templates.ChapterTitleTemplate; + public LongPath BaseDirectory { get; private init; } + public string DefaultTemplate { get; private init; } + public string TemplateName { get; private init; } + public string TemplateDescription { get; private init; } + private Templates? Folder { get; set; } + private Templates? File { get; set; } + private Templates? Name { get; set; } + public Templates EditingTemplate { - bool IsFolder { get; } - bool IsFilePath { get; } - LongPath BaseDirectory { get; } - string DefaultTemplate { get; } - string TemplateName { get; } - string TemplateDescription { get; } - Templates EditingTemplate { get; } - bool SetTemplateText(string? templateText); - string? GetFolderName(); - string? GetFileName(); - string? GetName(); + get => _editingTemplate; + private set => _editingTemplate = !IsFilePath ? Name = value : IsFolder ? Folder = value : File = value; } - public class TemplateEditor : ITemplateEditor where T : Templates, ITemplate, new() + private Templates _editingTemplate; + + public bool SetTemplateText(string? templateText) { - public bool IsFolder => EditingTemplate is Templates.FolderTemplate; - public bool IsFilePath => EditingTemplate is not Templates.ChapterTitleTemplate; - public LongPath BaseDirectory { get; private init; } - public string DefaultTemplate { get; private init; } - public string TemplateName { get; private init; } - public string TemplateDescription { get; private init; } - private Templates? Folder { get; set; } - private Templates? File { get; set; } - private Templates? Name { get; set; } - public Templates EditingTemplate + if (Templates.TryGetTemplate(templateText, out var template)) { - get => _editingTemplate; - private set => _editingTemplate = !IsFilePath ? Name = value : IsFolder ? Folder = value : File = value; + EditingTemplate = template; + return true; } + return false; + } - private Templates _editingTemplate; + public LibraryBookDto FolderBook { get; } + public LibraryBookDto LibraryBook { get; } - public bool SetTemplateText(string? templateText) + private static readonly LibraryBookDto DefaultLibraryBook + = new() { - if (Templates.TryGetTemplate(templateText, out var template)) - { - EditingTemplate = template; - return true; - } - return false; - } + Account = "myaccount@example.co", + AccountNickname = "my account", + DateAdded = new DateTime(2022, 6, 9, 0, 0, 0), + DatePublished = new DateTime(2017, 2, 27, 0, 0, 0), + AudibleProductId = "B06WLMWF2S", + Title = "A Study in Scarlet", + TitleWithSubtitle = "A Study in Scarlet: A Sherlock Holmes Novel", + Subtitle = "A Sherlock Holmes Novel", + Locale = "us", + YearPublished = 2017, + Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], + Narrators = [new("Stephen Fry", null)], + Series = [new("Sherlock Holmes", "1-6", "B08376S3R2"), new("Book Collection", "1", "B000000000")], + Codec = "AAC-LC", + LibationVersion = Configuration.LibationVersion.ToVersionString(), + FileVersion = "36217811", + BitRate = 128, + SampleRate = 44100, + Channels = 2, + Language = "English" + }; - public LibraryBookDto FolderBook { get; } - public LibraryBookDto LibraryBook { get; } - - private static readonly LibraryBookDto DefaultLibraryBook - = new() - { - Account = "myaccount@example.co", - AccountNickname = "my account", - DateAdded = new DateTime(2022, 6, 9, 0, 0, 0), - DatePublished = new DateTime(2017, 2, 27, 0, 0, 0), - AudibleProductId = "B06WLMWF2S", - Title = "A Study in Scarlet", - TitleWithSubtitle = "A Study in Scarlet: A Sherlock Holmes Novel", - Subtitle = "A Sherlock Holmes Novel", - Locale = "us", - YearPublished = 2017, - Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], - Narrators = [new("Stephen Fry", null)], - Series = [new("Sherlock Holmes", "1-6", "B08376S3R2"), new("Book Collection", "1", "B000000000")], - Codec = "AAC-LC", - LibationVersion = Configuration.LibationVersion?.ToVersionString(), - FileVersion = "36217811", - BitRate = 128, - SampleRate = 44100, - Channels = 2, - Language = "English" - }; - - private static readonly MultiConvertFileProperties DefaultMultipartProperties - = new() - { - OutputFileName = "", - PartsPosition = 4, - PartsTotal = 10, - Title = "A Flight for Life" - }; - - public string? GetFolderName() + private static readonly MultiConvertFileProperties DefaultMultipartProperties + = new() { - /* - * Path must be rooted for windows to allow long file paths. This is - * only necessary for folder templates because they may contain several - * subdirectories. Without rooting, we won't be allowed to create a - * relative path longer than MAX_PATH. - */ - var dir = Folder?.GetFilename(FolderBook, BaseDirectory, ""); - if (dir is null) return null; - return Path.GetRelativePath(BaseDirectory, dir); - } + OutputFileName = "", + PartsPosition = 4, + PartsTotal = 10, + Title = "A Flight for Life" + }; - public string? GetFileName() - => File?.GetFilename(LibraryBook, DefaultMultipartProperties, "", ""); - public string? GetName() - => Name?.GetName(LibraryBook, DefaultMultipartProperties); + public string? GetFolderName() + { + /* + * Path must be rooted for windows to allow long file paths. This is + * only necessary for folder templates because they may contain several + * subdirectories. Without rooting, we won't be allowed to create a + * relative path longer than MAX_PATH. + */ + var dir = Folder?.GetFilename(FolderBook, BaseDirectory, ""); + if (dir is null) return null; + return Path.GetRelativePath(BaseDirectory, dir); + } - private TemplateEditor( - LibraryBookDto? folderDto, - LibraryBookDto? fileDto, - Templates editingTemplate, - LongPath baseDirectory, - string defaultTemplate, - string templateName, - string templateDescription) - { - FolderBook = folderDto ?? DefaultLibraryBook; - LibraryBook = fileDto ?? DefaultLibraryBook; - _editingTemplate = editingTemplate; - BaseDirectory = baseDirectory; - DefaultTemplate = defaultTemplate; - TemplateName = templateName; - TemplateDescription = templateDescription; - } + public string? GetFileName() + => File?.GetFilename(LibraryBook, DefaultMultipartProperties, "", ""); + public string? GetName() + => Name?.GetName(LibraryBook, DefaultMultipartProperties); - public static ITemplateEditor CreateFilenameEditor(LongPath baseDir, string templateText, LibraryBookDto? folderDto = null, LibraryBookDto? fileDto = null) - { - if (!Templates.TryGetTemplate(templateText, out var template)) - throw new ArgumentException($"Failed to parse {nameof(templateText)}"); + private TemplateEditor( + LibraryBookDto? folderDto, + LibraryBookDto? fileDto, + Templates editingTemplate, + LongPath baseDirectory, + string defaultTemplate, + string templateName, + string templateDescription) + { + FolderBook = folderDto ?? DefaultLibraryBook; + LibraryBook = fileDto ?? DefaultLibraryBook; + _editingTemplate = editingTemplate; + BaseDirectory = baseDirectory; + DefaultTemplate = defaultTemplate; + TemplateName = templateName; + TemplateDescription = templateDescription; + } - var templateEditor = new TemplateEditor(folderDto, fileDto, template, baseDir, T.DefaultTemplate, T.Name, T.Description); + public static ITemplateEditor CreateFilenameEditor(LongPath baseDir, string templateText, LibraryBookDto? folderDto = null, LibraryBookDto? fileDto = null) + { + if (!Templates.TryGetTemplate(templateText, out var template)) + throw new ArgumentException($"Failed to parse {nameof(templateText)}"); - if (!templateEditor.IsFolder && !templateEditor.IsFilePath) - throw new InvalidOperationException($"This method is only for File and Folder templates. Use {nameof(CreateNameEditor)} for name templates"); + var templateEditor = new TemplateEditor(folderDto, fileDto, template, baseDir, T.DefaultTemplate, T.Name, T.Description); - if (templateEditor.IsFolder) - templateEditor.File = Templates.File; - else - templateEditor.Folder = Templates.Folder; + if (!templateEditor.IsFolder && !templateEditor.IsFilePath) + throw new InvalidOperationException($"This method is only for File and Folder templates. Use {nameof(CreateNameEditor)} for name templates"); - return templateEditor; - } + if (templateEditor.IsFolder) + templateEditor.File = Templates.File; + else + templateEditor.Folder = Templates.Folder; - public static ITemplateEditor CreateNameEditor(string templateText, LibraryBookDto? libraryBookDto = null) - { - if (!Templates.TryGetTemplate(templateText, out var nameTemplate)) - throw new ArgumentException($"Failed to parse {nameof(templateText)}"); + return templateEditor; + } - var templateEditor = new TemplateEditor(null, libraryBookDto, nameTemplate, "", T.DefaultTemplate, T.Name, T.Description); + public static ITemplateEditor CreateNameEditor(string templateText, LibraryBookDto? libraryBookDto = null) + { + if (!Templates.TryGetTemplate(templateText, out var nameTemplate)) + throw new ArgumentException($"Failed to parse {nameof(templateText)}"); - if (templateEditor.IsFolder || templateEditor.IsFilePath) - throw new InvalidOperationException($"This method is only for name templates. Use {nameof(CreateFilenameEditor)} for file templates"); + var templateEditor = new TemplateEditor(null, libraryBookDto, nameTemplate, "", T.DefaultTemplate, T.Name, T.Description); - return templateEditor; - } + if (templateEditor.IsFolder || templateEditor.IsFilePath) + throw new InvalidOperationException($"This method is only for name templates. Use {nameof(CreateFilenameEditor)} for file templates"); + + return templateEditor; } } diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index ea1e7bac..fe4d5774 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -1,61 +1,59 @@ using FileManager.NamingTemplate; -#nullable enable -namespace LibationFileManager.Templates +namespace LibationFileManager.Templates; + +public sealed class TemplateTags : ITemplateTag { - public sealed class TemplateTags : ITemplateTag + public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; + public string TagName { get; } + public string DefaultValue { get; } + public string Description { get; } + public string Display { get; } + + private TemplateTags(string tagName, string description, string? defaultValue = null, string? display = null) { - public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; - public string TagName { get; } - public string DefaultValue { get; } - public string Description { get; } - public string Display { get; } - - private TemplateTags(string tagName, string description, string? defaultValue = null, string? display = null) - { - TagName = tagName; - Description = description; - DefaultValue = defaultValue ?? $"<{tagName}>"; - Display = display ?? $"<{tagName}>"; - } - - public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters"); - public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title"); - public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #"); - public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros"); - - public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID"); - public static TemplateTags Title { get; } = new TemplateTags("title", "Full title with subtitle"); - public static TemplateTags TitleShort { get; } = new TemplateTags("title short", "Title. Stop at first colon"); - public static TemplateTags AudibleTitle { get; } = new TemplateTags("audible title", "Audible's title (does not include subtitle)"); - public static TemplateTags AudibleSubtitle { get; } = new TemplateTags("audible subtitle", "Audible's subtitle"); - public static TemplateTags Author { get; } = new TemplateTags("author", "Author(s)"); - public static TemplateTags FirstAuthor { get; } = new TemplateTags("first author", "First author"); - public static TemplateTags Narrator { get; } = new TemplateTags("narrator", "Narrator(s)"); - public static TemplateTags FirstNarrator { get; } = new TemplateTags("first narrator", "First narrator"); - public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)"); - public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series"); - public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for "); - public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook"); - public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook"); - public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels in the last downloaded audiobook"); - public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audio codec of the last downloaded audiobook"); - public static TemplateTags FileVersion { get; } = new TemplateTags("file version", "Audible's file version number of the last downloaded audiobook"); - public static TemplateTags LibationVersion { get; } = new TemplateTags("libation version", "Libation version used during last download of the audiobook"); - public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book"); - public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book"); - public static TemplateTags Locale { get; } = new("locale", "Region/country"); - public static TemplateTags YearPublished { get; } = new("year", "Year published"); - public static TemplateTags Language { get; } = new("language", "Book's language"); - public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG"); - - public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"", ""); - public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"", ""); - public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"", ""); - public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<-if series>", "...<-if series>"); - public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<-if podcast>", "...<-if podcast>"); - public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<-if podcastparent>", "...<-if podcastparent>"); - public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<-if bookseries>", "...<-if bookseries>"); - public static TemplateTags Has { get; } = new TemplateTags("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<-has>", "...<-has>"); + TagName = tagName; + Description = description; + DefaultValue = defaultValue ?? $"<{tagName}>"; + Display = display ?? $"<{tagName}>"; } + + public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters"); + public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title"); + public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #"); + public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros"); + + public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID"); + public static TemplateTags Title { get; } = new TemplateTags("title", "Full title with subtitle"); + public static TemplateTags TitleShort { get; } = new TemplateTags("title short", "Title. Stop at first colon"); + public static TemplateTags AudibleTitle { get; } = new TemplateTags("audible title", "Audible's title (does not include subtitle)"); + public static TemplateTags AudibleSubtitle { get; } = new TemplateTags("audible subtitle", "Audible's subtitle"); + public static TemplateTags Author { get; } = new TemplateTags("author", "Author(s)"); + public static TemplateTags FirstAuthor { get; } = new TemplateTags("first author", "First author"); + public static TemplateTags Narrator { get; } = new TemplateTags("narrator", "Narrator(s)"); + public static TemplateTags FirstNarrator { get; } = new TemplateTags("first narrator", "First narrator"); + public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)"); + public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series"); + public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for "); + public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook"); + public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook"); + public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels in the last downloaded audiobook"); + public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audio codec of the last downloaded audiobook"); + public static TemplateTags FileVersion { get; } = new TemplateTags("file version", "Audible's file version number of the last downloaded audiobook"); + public static TemplateTags LibationVersion { get; } = new TemplateTags("libation version", "Libation version used during last download of the audiobook"); + public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book"); + public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book"); + public static TemplateTags Locale { get; } = new("locale", "Region/country"); + public static TemplateTags YearPublished { get; } = new("year", "Year published"); + public static TemplateTags Language { get; } = new("language", "Book's language"); + public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG"); + + public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"", ""); + public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"", ""); + public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"", ""); + public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<-if series>", "...<-if series>"); + public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<-if podcast>", "...<-if podcast>"); + public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<-if podcastparent>", "...<-if podcastparent>"); + public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<-if bookseries>", "...<-if bookseries>"); + public static TemplateTags Has { get; } = new TemplateTags("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<-has>", "...<-has>"); } diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 185b0d6c..19971cd0 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using AaxDecrypter; @@ -9,457 +8,455 @@ using FileManager; using FileManager.NamingTemplate; using NameParser; -#nullable enable -namespace LibationFileManager.Templates +namespace LibationFileManager.Templates; + +public interface ITemplate { - public interface ITemplate + static abstract string Name { get; } + static abstract string Description { get; } + static abstract string DefaultTemplate { get; } + static abstract IEnumerable TagCollections { get; } +} + +public abstract class Templates +{ + public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\"; + public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: or "; + + //Assigning the properties in the static constructor will require all + //Templates users to have a valid configuration file. To allow tests + //to work without access to Configuration, only load templates on demand. + private static FolderTemplate? _folder; + private static FileTemplate? _file; + private static ChapterFileTemplate? _chapterFile; + private static ChapterTitleTemplate? _chapterTitle; + + public static FolderTemplate Folder => _folder ??= GetTemplate(Configuration.Instance.FolderTemplate); + public static FileTemplate File => _file ??= GetTemplate(Configuration.Instance.FileTemplate); + public static ChapterFileTemplate ChapterFile => _chapterFile ??= GetTemplate(Configuration.Instance.ChapterFileTemplate); + public static ChapterTitleTemplate ChapterTitle => _chapterTitle ??= GetTemplate(Configuration.Instance.ChapterTitleTemplate); + + #region Template Parsing + + public static T GetTemplate(string? templateText) where T : Templates, ITemplate, new() + => TryGetTemplate(templateText, out var template) ? template : GetDefaultTemplate(); + + public static bool TryGetTemplate(string? templateText, out T template) where T : Templates, ITemplate, new() { - static abstract string Name { get; } - static abstract string Description { get; } - static abstract string DefaultTemplate { get; } - static abstract IEnumerable TagCollections { get; } + var namingTemplate = NamingTemplate.Parse(templateText, T.TagCollections); + template = new() { NamingTemplate = namingTemplate }; + return !namingTemplate.Errors.Any(); } - public abstract class Templates + private static T GetDefaultTemplate() where T : Templates, ITemplate, new() + => new() { NamingTemplate = NamingTemplate.Parse(T.DefaultTemplate, T.TagCollections) }; + + static Templates() { - public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\"; - public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: or "; + Configuration.Instance.PropertyChanged += + [PropertyChangeFilter(nameof(Configuration.FolderTemplate))] + (_, e) => _folder = GetTemplate(e.NewValue as string); - //Assigning the properties in the static constructor will require all - //Templates users to have a valid configuration file. To allow tests - //to work without access to Configuration, only load templates on demand. - private static FolderTemplate? _folder; - private static FileTemplate? _file; - private static ChapterFileTemplate? _chapterFile; - private static ChapterTitleTemplate? _chapterTitle; + Configuration.Instance.PropertyChanged += + [PropertyChangeFilter(nameof(Configuration.FileTemplate))] + (_, e) => _file = GetTemplate(e.NewValue as string); - public static FolderTemplate Folder => _folder ??= GetTemplate(Configuration.Instance.FolderTemplate); - public static FileTemplate File => _file ??= GetTemplate(Configuration.Instance.FileTemplate); - public static ChapterFileTemplate ChapterFile => _chapterFile ??= GetTemplate(Configuration.Instance.ChapterFileTemplate); - public static ChapterTitleTemplate ChapterTitle => _chapterTitle ??= GetTemplate(Configuration.Instance.ChapterTitleTemplate); + Configuration.Instance.PropertyChanged += + [PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))] + (_, e) => _chapterFile = GetTemplate(e.NewValue as string); - #region Template Parsing + Configuration.Instance.PropertyChanged += + [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] + (_, e) => _chapterTitle = GetTemplate(e.NewValue as string); - public static T GetTemplate(string? templateText) where T : Templates, ITemplate, new() - => TryGetTemplate(templateText, out var template) ? template : GetDefaultTemplate(); + HumanName.Suffixes.Add("ret"); + HumanName.Titles.Add("professor"); + } - public static bool TryGetTemplate(string? templateText, out T template) where T : Templates, ITemplate, new() + #endregion + + #region Template Properties + + public IEnumerable TagsRegistered + => NamingTemplate?.TagsRegistered.Cast() ?? Enumerable.Empty(); + public IEnumerable TagsInUse + => NamingTemplate?.TagsInUse.Cast() ?? Enumerable.Empty(); + public string TemplateText => NamingTemplate?.TemplateText ?? ""; + + protected NamingTemplate NamingTemplate + { + get => field ?? throw new NullReferenceException(nameof(NamingTemplate)); + private init => field = value; + } + + #endregion + + #region validation + + public virtual IEnumerable Errors => NamingTemplate.Errors; + public bool IsValid => !Errors.Any(); + + public virtual IEnumerable Warnings => NamingTemplate.Warnings; + public bool HasWarnings => Warnings.Any(); + + #endregion + + #region to file name + + public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps) + { + ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); + ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); + return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps, new CombinedDto(libraryBookDto, multiChapProps)).Select(p => p.Value)); + } + + public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false) + { + ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); + ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir)); + ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); + + replacements ??= Configuration.Instance.ReplacementCharacters; + return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto); + } + + public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false) + { + ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); + ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); + ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir)); + ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); + + replacements ??= Configuration.Instance.ReplacementCharacters; + return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, multiChapProps); + } + + protected virtual IEnumerable GetTemplatePartsStrings(List parts, ReplacementCharacters replacements) + => parts.Select(p => replacements.ReplaceFilenameChars(p.Value)); + + private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, LibraryBookDto lbDto, MultiConvertFileProperties? multiDto = null) + { + fileExtension = FileUtility.GetStandardizedExtension(fileExtension); + + var parts = NamingTemplate.Evaluate(lbDto, multiDto, new CombinedDto(lbDto, multiDto)).ToList(); + var pathParts = GetPathParts(GetTemplatePartsStrings(parts, replacements)); + + //Remove 1 character from the end of the longest filename part until + //the total filename is less than max filename length + for (int i = 0; i < pathParts.Count; i++) { - var namingTemplate = NamingTemplate.Parse(templateText, T.TagCollections); - template = new() { NamingTemplate = namingTemplate }; - return !namingTemplate.Errors.Any(); - } + var part = pathParts[i]; - private static T GetDefaultTemplate() where T : Templates, ITemplate, new() - => new() { NamingTemplate = NamingTemplate.Parse(T.DefaultTemplate, T.TagCollections) }; + //If file already exists, GetValidFilename will append " (n)" to the filename. + //This could cause the filename length to exceed MaxFilenameLength, so reduce + //allowable filename length by 5 chars, allowing for up to 99 duplicates. + var maxFilenameLength = LongPath.MaxFilenameLength - + (i < pathParts.Count - 1 || string.IsNullOrEmpty(fileExtension) ? 0 : fileExtension.Length + 5); - static Templates() - { - Configuration.Instance.PropertyChanged += - [PropertyChangeFilter(nameof(Configuration.FolderTemplate))] - (_, e) => _folder = GetTemplate(e.NewValue as string); - - Configuration.Instance.PropertyChanged += - [PropertyChangeFilter(nameof(Configuration.FileTemplate))] - (_, e) => _file = GetTemplate(e.NewValue as string); - - Configuration.Instance.PropertyChanged += - [PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))] - (_, e) => _chapterFile = GetTemplate(e.NewValue as string); - - Configuration.Instance.PropertyChanged += - [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] - (_, e) => _chapterTitle = GetTemplate(e.NewValue as string); - - HumanName.Suffixes.Add("ret"); - HumanName.Titles.Add("professor"); - } - - #endregion - - #region Template Properties - - public IEnumerable TagsRegistered - => NamingTemplate?.TagsRegistered.Cast() ?? Enumerable.Empty(); - public IEnumerable TagsInUse - => NamingTemplate?.TagsInUse.Cast() ?? Enumerable.Empty(); - public string TemplateText => NamingTemplate?.TemplateText ?? ""; - - protected NamingTemplate NamingTemplate - { - get => field ?? throw new NullReferenceException(nameof(NamingTemplate)); - private init => field = value; - } - - #endregion - - #region validation - - public virtual IEnumerable Errors => NamingTemplate.Errors; - public bool IsValid => !Errors.Any(); - - public virtual IEnumerable Warnings => NamingTemplate.Warnings; - public bool HasWarnings => Warnings.Any(); - - #endregion - - #region to file name - - public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps) - { - ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); - ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); - return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps, new CombinedDto(libraryBookDto, multiChapProps)).Select(p => p.Value)); - } - - public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false) - { - ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); - ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir)); - ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); - - replacements ??= Configuration.Instance.ReplacementCharacters; - return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto); - } - - public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false) - { - ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); - ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); - ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir)); - ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); - - replacements ??= Configuration.Instance.ReplacementCharacters; - return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, multiChapProps); - } - - protected virtual IEnumerable GetTemplatePartsStrings(List parts, ReplacementCharacters replacements) - => parts.Select(p => replacements.ReplaceFilenameChars(p.Value)); - - private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, LibraryBookDto lbDto, MultiConvertFileProperties? multiDto = null) - { - fileExtension = FileUtility.GetStandardizedExtension(fileExtension); - - var parts = NamingTemplate.Evaluate(lbDto, multiDto, new CombinedDto(lbDto, multiDto)).ToList(); - var pathParts = GetPathParts(GetTemplatePartsStrings(parts, replacements)); - - //Remove 1 character from the end of the longest filename part until - //the total filename is less than max filename length - for (int i = 0; i < pathParts.Count; i++) + while (part.Sum(GetFilenameLength) > maxFilenameLength) { - var part = pathParts[i]; + int maxLength = part.Max(p => p.Length); + var maxEntry = part.First(p => p.Length == maxLength); - //If file already exists, GetValidFilename will append " (n)" to the filename. - //This could cause the filename length to exceed MaxFilenameLength, so reduce - //allowable filename length by 5 chars, allowing for up to 99 duplicates. - var maxFilenameLength = LongPath.MaxFilenameLength - - (i < pathParts.Count - 1 || string.IsNullOrEmpty(fileExtension) ? 0 : fileExtension.Length + 5); - - while (part.Sum(GetFilenameLength) > maxFilenameLength) - { - int maxLength = part.Max(p => p.Length); - var maxEntry = part.First(p => p.Length == maxLength); - - var maxIndex = part.IndexOf(maxEntry); - part.RemoveAt(maxIndex); - part.Insert(maxIndex, maxEntry.Remove(maxLength - 1, 1)); - } + var maxIndex = part.IndexOf(maxEntry); + part.RemoveAt(maxIndex); + part.Insert(maxIndex, maxEntry.Remove(maxLength - 1, 1)); } - - var fullPath = Path.Combine(pathParts.Select(fileParts => string.Concat(fileParts)).Prepend(baseDir).ToArray()); - - return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting); } - private static int GetFilenameLength(string filename) - => Configuration.Instance.BooksCanWrite255UnicodeChars ? filename.Length - : System.Text.Encoding.UTF8.GetByteCount(filename); + var fullPath = Path.Combine(pathParts.Select(fileParts => string.Concat(fileParts)).Prepend(baseDir).ToArray()); - /// - /// Organize template parts into directories. Any Extra slashes will be - /// returned as empty directories and are taken care of by Path.Combine() - /// - /// A List of template directories. Each directory is a list of template part strings - private static List> GetPathParts(IEnumerable templateParts) + return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting); + } + + private static int GetFilenameLength(string filename) + => Configuration.Instance.BooksCanWrite255UnicodeChars ? filename.Length + : System.Text.Encoding.UTF8.GetByteCount(filename); + + /// + /// Organize template parts into directories. Any Extra slashes will be + /// returned as empty directories and are taken care of by Path.Combine() + /// + /// A List of template directories. Each directory is a list of template part strings + private static List> GetPathParts(IEnumerable templateParts) + { + List> directories = new(); + List dir = new(); + + foreach (var part in templateParts) { - List> directories = new(); - List dir = new(); - - foreach (var part in templateParts) + int slashIndex, lastIndex = 0; + while ((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1) { - int slashIndex, lastIndex = 0; - while ((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1) - { - dir.Add(part[lastIndex..slashIndex]); - RemoveSpaces(dir); - directories.Add(dir); - dir = new(); + dir.Add(part[lastIndex..slashIndex]); + RemoveSpaces(dir); + directories.Add(dir); + dir = new(); - lastIndex = slashIndex + 1; - } - dir.Add(part[lastIndex..]); + lastIndex = slashIndex + 1; } - RemoveSpaces(dir); - directories.Add(dir); + dir.Add(part[lastIndex..]); + } + RemoveSpaces(dir); + directories.Add(dir); - return directories; + return directories; + } + + /// + /// Remove spaces from the filename parts to ensure that after concatenation + ///
-
There is no leading or trailing white space + ///
-
There are no multispace instances + ///
+ private static void RemoveSpaces(List parts) + { + while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[0])) + parts.RemoveAt(0); + + while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[^1])) + parts.RemoveAt(parts.Count - 1); + + if (parts.Count == 0) return; + + parts[0] = parts[0].TrimStart(); + parts[^1] = parts[^1].TrimEnd(); + + //Replace all multispace substrings with single space + for (int i = 0; i < parts.Count; i++) + { + string original; + do + { + original = parts[i]; + parts[i] = original.Replace(" ", " "); + } while (original.Length != parts[i].Length); } - /// - /// Remove spaces from the filename parts to ensure that after concatenation - ///
-
There is no leading or trailing white space - ///
-
There are no multispace instances - ///
- private static void RemoveSpaces(List parts) + //Remove instances of double spaces at part boundaries + for (int i = 1; i < parts.Count; i++) { - while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[0])) - parts.RemoveAt(0); - - while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[^1])) - parts.RemoveAt(parts.Count - 1); - - if (parts.Count == 0) return; - - parts[0] = parts[0].TrimStart(); - parts[^1] = parts[^1].TrimEnd(); - - //Replace all multispace substrings with single space - for (int i = 0; i < parts.Count; i++) + if (parts[i - 1].EndsWith(' ') && parts[i].StartsWith(' ')) { - string original; - do - { - original = parts[i]; - parts[i] = original.Replace(" ", " "); - } while (original.Length != parts[i].Length); - } + parts[i] = parts[i].Substring(1); - //Remove instances of double spaces at part boundaries - for (int i = 1; i < parts.Count; i++) - { - if (parts[i - 1].EndsWith(' ') && parts[i].StartsWith(' ')) + if (parts[i].Length == 0) { - parts[i] = parts[i].Substring(1); - - if (parts[i].Length == 0) - { - parts.RemoveAt(i); - i--; - } + parts.RemoveAt(i); + i--; } } } + } - #endregion + #endregion - #region Registered Template Properties + #region Registered Template Properties - private static readonly PropertyTagCollection filePropertyTags = - new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter, FloatFormatter) + private static readonly PropertyTagCollection filePropertyTags = + new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter, FloatFormatter) + { + //Don't allow formatting of Id + { TemplateTags.Id, lb => lb.AudibleProductId, v => v ?? "" }, + { TemplateTags.Title, lb => lb.TitleWithSubtitle }, + { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, + { TemplateTags.AudibleTitle, lb => lb.Title }, + { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, + { TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter }, + { TemplateTags.FirstAuthor, lb => lb.FirstAuthor, FormattableFormatter }, + { TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter }, + { TemplateTags.FirstNarrator, lb => lb.FirstNarrator, FormattableFormatter }, + { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, + { TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter }, + { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, FormattableFormatter }, + { TemplateTags.Language, lb => lb.Language }, + //Don't allow formatting of LanguageShort + { TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort }, + { TemplateTags.Account, lb => lb.Account }, + { TemplateTags.AccountNickname, lb => lb.AccountNickname }, + { TemplateTags.Locale, lb => lb.Locale }, + { TemplateTags.YearPublished, lb => lb.YearPublished }, + { TemplateTags.DatePublished, lb => lb.DatePublished }, + { TemplateTags.DateAdded, lb => lb.DateAdded }, + { TemplateTags.FileDate, lb => lb.FileDate }, + }; + + private static readonly PropertyTagCollection audioFilePropertyTags = + new(caseSensative: true, StringFormatter, IntegerFormatter) + { + { TemplateTags.Bitrate, lb => lb.BitRate }, + { TemplateTags.SampleRate, lb => lb.SampleRate }, + { TemplateTags.Channels, lb => lb.Channels }, + { TemplateTags.Codec, lb => lb.Codec }, + { TemplateTags.FileVersion, lb => lb.FileVersion }, + { TemplateTags.LibationVersion, lb => lb.LibationVersion }, + }; + + private static readonly List chapterPropertyTags = new() + { + new PropertyTagCollection(caseSensative: true, StringFormatter) { - //Don't allow formatting of Id - { TemplateTags.Id, lb => lb.AudibleProductId, v => v ?? "" }, { TemplateTags.Title, lb => lb.TitleWithSubtitle }, { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, { TemplateTags.AudibleTitle, lb => lb.Title }, { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, - { TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter }, - { TemplateTags.FirstAuthor, lb => lb.FirstAuthor, FormattableFormatter }, - { TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter }, - { TemplateTags.FirstNarrator, lb => lb.FirstNarrator, FormattableFormatter }, { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter }, - { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, FormattableFormatter }, - { TemplateTags.Language, lb => lb.Language }, - //Don't allow formatting of LanguageShort - { TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort }, - { TemplateTags.Account, lb => lb.Account }, - { TemplateTags.AccountNickname, lb => lb.AccountNickname }, - { TemplateTags.Locale, lb => lb.Locale }, - { TemplateTags.YearPublished, lb => lb.YearPublished }, - { TemplateTags.DatePublished, lb => lb.DatePublished }, - { TemplateTags.DateAdded, lb => lb.DateAdded }, - { TemplateTags.FileDate, lb => lb.FileDate }, - }; - - private static readonly PropertyTagCollection audioFilePropertyTags = - new(caseSensative: true, StringFormatter, IntegerFormatter) + }, + new PropertyTagCollection(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter) { - { TemplateTags.Bitrate, lb => lb.BitRate }, - { TemplateTags.SampleRate, lb => lb.SampleRate }, - { TemplateTags.Channels, lb => lb.Channels }, - { TemplateTags.Codec, lb => lb.Codec }, - { TemplateTags.FileVersion, lb => lb.FileVersion }, - { TemplateTags.LibationVersion, lb => lb.LibationVersion }, - }; + { TemplateTags.ChCount, m => m.PartsTotal }, + { TemplateTags.ChNumber, m => m.PartsPosition }, + { TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1)) }, + { TemplateTags.ChTitle, m => m.Title }, + { TemplateTags.FileDate, m => m.FileDate } + } + }; - private static readonly List chapterPropertyTags = new() + private static readonly ConditionalTagCollection conditionalTags = new() + { + { TemplateTags.IfSeries, lb => lb.IsSeries || lb.IsPodcastParent }, + { TemplateTags.IfPodcast, lb => lb.IsPodcast || lb.IsPodcastParent }, + { TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast && !lb.IsPodcastParent }, + }; + + private static readonly ConditionalTagCollection combinedConditionalTags = new() + { + { TemplateTags.Has, HasValue} + }; + + private static bool HasValue(ITemplateTag tag, CombinedDto dtos, string condition) + { + foreach (var c in chapterPropertyTags.OfType>().Append(filePropertyTags).Append(audioFilePropertyTags)) { - new PropertyTagCollection(caseSensative: true, StringFormatter) + if (c.TryGetValue(condition, dtos.LibraryBook, out var value)) { - { TemplateTags.Title, lb => lb.TitleWithSubtitle }, - { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, - { TemplateTags.AudibleTitle, lb => lb.Title }, - { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, - { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, - { TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter }, - }, - new PropertyTagCollection(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter) - { - { TemplateTags.ChCount, m => m.PartsTotal }, - { TemplateTags.ChNumber, m => m.PartsPosition }, - { TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1)) }, - { TemplateTags.ChTitle, m => m.Title }, - { TemplateTags.FileDate, m => m.FileDate } - } - }; - - private static readonly ConditionalTagCollection conditionalTags = new() - { - { TemplateTags.IfSeries, lb => lb.IsSeries || lb.IsPodcastParent }, - { TemplateTags.IfPodcast, lb => lb.IsPodcast || lb.IsPodcastParent }, - { TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast && !lb.IsPodcastParent }, - }; - - private static readonly ConditionalTagCollection combinedConditionalTags = new() - { - { TemplateTags.Has, HasValue} - }; - - private static bool HasValue(ITemplateTag tag, CombinedDto dtos, string condition) - { - foreach (var c in chapterPropertyTags.OfType>().Append(filePropertyTags).Append(audioFilePropertyTags)) - { - if (c.TryGetValue(condition, dtos.LibraryBook, out var value)) - { - return !string.IsNullOrWhiteSpace(value); - } - } - - if (dtos.MultiConvert is null) - return false; - - foreach (var c in chapterPropertyTags.OfType>()) - { - if (c.TryGetValue(condition, dtos.MultiConvert, out var value)) - { - return !string.IsNullOrWhiteSpace(value); - } + return !string.IsNullOrWhiteSpace(value); } + } + if (dtos.MultiConvert is null) return false; - } - private static readonly ConditionalTagCollection folderConditionalTags = new() + foreach (var c in chapterPropertyTags.OfType>()) { - { TemplateTags.IfPodcastParent, lb => lb.IsPodcastParent } - }; - - #endregion - - #region Tag Formatters - - private static string? getTitleShort(string? title) - => title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title; - - private static string getLanguageShort(string? language) - { - if (language is null) - return ""; - - language = language.Trim(); - if (language.Length <= 3) - return language.ToUpper(); - return language[..3].ToUpper(); - } - - private static string FormattableFormatter(ITemplateTag templateTag, IFormattable? value, string formatString) - => value?.ToString(formatString, null) ?? ""; - - private static string StringFormatter(ITemplateTag templateTag, string value, string formatString) - { - if (value is null) return ""; - else if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value.ToUpper(); - else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value.ToLower(); - else return value; - } - - private static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString) - => FloatFormatter(templateTag, value, formatString); - - private static string FloatFormatter(ITemplateTag templateTag, float value, string formatString) - { - if (int.TryParse(formatString, out var numDigits) && numDigits > 0) + if (c.TryGetValue(condition, dtos.MultiConvert, out var value)) { - //Zero-pad the integer part - var strValue = value.ToString(); - var decIndex = strValue.IndexOf(System.Globalization.NumberFormatInfo.CurrentInfo.NumberDecimalSeparator); - var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex); - - return new string('0', zeroPad) + strValue; + return !string.IsNullOrWhiteSpace(value); } - return value.ToString(formatString); } - private static string DateTimeFormatter(ITemplateTag templateTag, DateTime value, string formatString) + return false; + } + + private static readonly ConditionalTagCollection folderConditionalTags = new() + { + { TemplateTags.IfPodcastParent, lb => lb.IsPodcastParent } + }; + + #endregion + + #region Tag Formatters + + private static string? getTitleShort(string? title) + => title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title; + + private static string getLanguageShort(string? language) + { + if (language is null) + return ""; + + language = language.Trim(); + if (language.Length <= 3) + return language.ToUpper(); + return language[..3].ToUpper(); + } + + private static string FormattableFormatter(ITemplateTag templateTag, IFormattable? value, string formatString) + => value?.ToString(formatString, null) ?? ""; + + private static string StringFormatter(ITemplateTag templateTag, string value, string formatString) + { + if (value is null) return ""; + else if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value.ToUpper(); + else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value.ToLower(); + else return value; + } + + private static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString) + => FloatFormatter(templateTag, value, formatString); + + private static string FloatFormatter(ITemplateTag templateTag, float value, string formatString) + { + if (int.TryParse(formatString, out var numDigits) && numDigits > 0) { - if (string.IsNullOrEmpty(formatString)) - return value.ToString(TemplateTags.DEFAULT_DATE_FORMAT); - return value.ToString(formatString); + //Zero-pad the integer part + var strValue = value.ToString(); + var decIndex = strValue.IndexOf(System.Globalization.NumberFormatInfo.CurrentInfo.NumberDecimalSeparator); + var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex); + + return new string('0', zeroPad) + strValue; } + return value.ToString(formatString); + } - #endregion + private static string DateTimeFormatter(ITemplateTag templateTag, DateTime value, string formatString) + { + if (string.IsNullOrEmpty(formatString)) + return value.ToString(TemplateTags.DEFAULT_DATE_FORMAT); + return value.ToString(formatString); + } - public class FolderTemplate : Templates, ITemplate - { - public static string Name { get; } = "Folder Template"; - public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? ""; - public static string DefaultTemplate { get; } = " [<id>]"; - public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags, combinedConditionalTags]; + #endregion - public override IEnumerable<string> Errors - => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; + public class FolderTemplate : Templates, ITemplate + { + public static string Name { get; } = "Folder Template"; + public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? ""; + public static string DefaultTemplate { get; } = "<title short> [<id>]"; + public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags, combinedConditionalTags]; - protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) - => parts - .Select(tp => tp.TemplateTag is null - //FolderTemplate literals can have directory separator characters - ? replacements.ReplacePathChars(tp.Value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)) - : replacements.ReplaceFilenameChars(tp.Value) - ).ToList(); - } + public override IEnumerable<string> Errors + => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; - public class FileTemplate : Templates, ITemplate - { - public static string Name { get; } = "File Template"; - public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? ""; - public static string DefaultTemplate { get; } = "<title> [<id>]"; - public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, combinedConditionalTags]; - } + protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) + => parts + .Select(tp => tp.TemplateTag is null + //FolderTemplate literals can have directory separator characters + ? replacements.ReplacePathChars(tp.Value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)) + : replacements.ReplaceFilenameChars(tp.Value) + ).ToList(); + } - public class ChapterFileTemplate : Templates, ITemplate - { - public static string Name { get; } = "Chapter File Template"; - public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? ""; - public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; - public static IEnumerable<TagCollection> TagCollections { get; } - = chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags).Append(combinedConditionalTags); + public class FileTemplate : Templates, ITemplate + { + public static string Name { get; } = "File Template"; + public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? ""; + public static string DefaultTemplate { get; } = "<title> [<id>]"; + public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, combinedConditionalTags]; + } - public override IEnumerable<string> Warnings - => NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName)) - ? base.Warnings - : base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG); - } + public class ChapterFileTemplate : Templates, ITemplate + { + public static string Name { get; } = "Chapter File Template"; + public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? ""; + public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; + public static IEnumerable<TagCollection> TagCollections { get; } + = chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags).Append(combinedConditionalTags); - public class ChapterTitleTemplate : Templates, ITemplate - { - public static string Name { get; } = "Chapter Title Template"; - public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)) ?? ""; - public static string DefaultTemplate => "<ch#> - <title short>: <ch title>"; - public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags).Append(combinedConditionalTags); + public override IEnumerable<string> Warnings + => NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName)) + ? base.Warnings + : base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG); + } - protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) - => parts.Select(p => p.Value); - } + public class ChapterTitleTemplate : Templates, ITemplate + { + public static string Name { get; } = "Chapter Title Template"; + public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)) ?? ""; + public static string DefaultTemplate => "<ch#> - <title short>: <ch title>"; + public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags).Append(combinedConditionalTags); + + protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) + => parts.Select(p => p.Value); } } diff --git a/Source/LibationFileManager/WindowsDirectory.cs b/Source/LibationFileManager/WindowsDirectory.cs index e2c94240..e9282720 100644 --- a/Source/LibationFileManager/WindowsDirectory.cs +++ b/Source/LibationFileManager/WindowsDirectory.cs @@ -1,41 +1,39 @@ using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; using System.Threading; -using System.Threading.Tasks; -namespace LibationFileManager +namespace LibationFileManager; + +public static class WindowsDirectory { - public static class WindowsDirectory + public static void SetCoverAsFolderIcon(string? pictureId, string directory, CancellationToken cancellationToken) { - - public static void SetCoverAsFolderIcon(string pictureId, string directory, CancellationToken cancellationToken) + try { + //Currently only works for Windows and macOS + if (!Configuration.Instance.UseCoverAsFolderIcon) + return; + if (string.IsNullOrEmpty(pictureId)) + { + Serilog.Log.Logger.Warning("No picture ID provided to set cover art as folder icon. {@DebugInfo}", new { directory }); + return; + } + + // get path of cover art in Images dir. Download first if not exists + var coverArtPath = PictureStorage.GetPicturePathSynchronously(new(pictureId, PictureSize._300x300), cancellationToken); + InteropFactory.Create().SetFolderIcon(image: coverArtPath, directory: directory); + } + catch (Exception ex) + { + // Failure to 'set cover as folder icon' should not be considered a failure to download the book + Serilog.Log.Logger.Error(ex, "Error setting cover art as folder icon. {@DebugInfo}", new { directory }); + try { - //Currently only works for Windows and macOS - if (!Configuration.Instance.UseCoverAsFolderIcon) - return; - - // get path of cover art in Images dir. Download first if not exists - var coverArtPath = PictureStorage.GetPicturePathSynchronously(new(pictureId, PictureSize._300x300), cancellationToken); - InteropFactory.Create().SetFolderIcon(image: coverArtPath, directory: directory); + InteropFactory.Create().DeleteFolderIcon(directory); } - catch (Exception ex) + catch { - // Failure to 'set cover as folder icon' should not be considered a failure to download the book - Serilog.Log.Logger.Error(ex, "Error setting cover art as folder icon. {@DebugInfo}", new { directory }); - - try - { - InteropFactory.Create().DeleteFolderIcon(directory); - } - catch - { - Serilog.Log.Logger.Error(ex, "Error rolling back: setting cover art as folder icon. {@DebugInfo}", new { directory }); - } + Serilog.Log.Logger.Error(ex, "Error rolling back: setting cover art as folder icon. {@DebugInfo}", new { directory }); } } } diff --git a/Source/LibationSearchEngine/IndexRule.cs b/Source/LibationSearchEngine/IndexRule.cs index 79ff21cc..adabb17a 100644 --- a/Source/LibationSearchEngine/IndexRule.cs +++ b/Source/LibationSearchEngine/IndexRule.cs @@ -18,10 +18,10 @@ public enum FieldType public class IndexRule { public FieldType FieldType { get; } - public Func<LibraryBook, string> GetValue { get; } + public Func<LibraryBook, string?> GetValue { get; } public ReadOnlyCollection<string> FieldNames { get; } - public IndexRule(FieldType fieldType, Func<LibraryBook, string> valueGetter, params string[] fieldNames) + public IndexRule(FieldType fieldType, Func<LibraryBook, string?> valueGetter, params string[] fieldNames) { ArgumentValidator.EnsureNotNull(valueGetter, nameof(valueGetter)); ArgumentValidator.EnsureNotNull(fieldNames, nameof(fieldNames)); diff --git a/Source/LibationSearchEngine/IndexRuleCollection.cs b/Source/LibationSearchEngine/IndexRuleCollection.cs index 5033897f..39da5f6b 100644 --- a/Source/LibationSearchEngine/IndexRuleCollection.cs +++ b/Source/LibationSearchEngine/IndexRuleCollection.cs @@ -16,10 +16,10 @@ public class IndexRuleCollection : IEnumerable<IndexRule> public IEnumerable<string> StringFieldNames => rules.Where(x => x.FieldType is FieldType.String).SelectMany(r => r.FieldNames); public IEnumerable<string> NumberFieldNames => rules.Where(x => x.FieldType is FieldType.Number).SelectMany(r => r.FieldNames); - public void Add(FieldType fieldType, Func<LibraryBook, string> getter, params string[] fieldNames) + public void Add(FieldType fieldType, Func<LibraryBook, string?> getter, params string[] fieldNames) => rules.Add(new IndexRule(fieldType, getter, fieldNames)); - public IndexRule GetRuleByFieldName(string fieldName) + public IndexRule? GetRuleByFieldName(string fieldName) => rules.SingleOrDefault(r => r.FieldNames.Any(n => n.Equals(fieldName, StringComparison.OrdinalIgnoreCase))); public IEnumerator<IndexRule> GetEnumerator() => rules.GetEnumerator(); diff --git a/Source/LibationSearchEngine/LibationSearchEngine.csproj b/Source/LibationSearchEngine/LibationSearchEngine.csproj index d2e28ad2..98aa2f10 100644 --- a/Source/LibationSearchEngine/LibationSearchEngine.csproj +++ b/Source/LibationSearchEngine/LibationSearchEngine.csproj @@ -2,6 +2,7 @@ <PropertyGroup> <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> diff --git a/Source/LibationSearchEngine/LuceneExtensions.cs b/Source/LibationSearchEngine/LuceneExtensions.cs index 32e7ab46..4b6308c7 100644 --- a/Source/LibationSearchEngine/LuceneExtensions.cs +++ b/Source/LibationSearchEngine/LuceneExtensions.cs @@ -27,8 +27,8 @@ namespace LibationSearchEngine internal static void AddIndexRule(this Document document, IndexRule rule, LibraryBook libraryBook) { - string value = rule.GetValue(libraryBook); - if (value is null) return; + if (rule.GetValue(libraryBook) is not string value) + return; foreach (var name in rule.FieldNames) { diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index 03ff8192..f4acb19e 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -42,7 +42,7 @@ namespace LibationSearchEngine { FieldType.String, lb => lb.Book.Publisher, nameof(Book.Publisher) }, { FieldType.String, lb => lb.Book.SeriesNames(), "SeriesNames", "Narrator", "Series" }, { FieldType.String, lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)), "SeriesId" }, - { FieldType.String, lb => lb.Book.AllCategoryIds() is null ? null : string.Join(", ", lb.Book.AllCategoryIds()), "CategoriesId", "CategoryId" }, + { FieldType.String, lb => lb.Book.AllCategoryIds() is not { } categories ? null : string.Join(", ", categories), "CategoriesId", "CategoryId" }, { FieldType.String, lb => lb.Book.AllCategoryNames() is null ? null : string.Join(", ", lb.Book.AllCategoryNames()), "Category", "Categories", "CategoriesNames" }, { FieldType.String, lb => lb.Book.UserDefinedItem.Tags, TAGS.FirstCharToUpper() }, { FieldType.String, lb => lb.Book.Locale, "Locale", "Region" }, @@ -93,7 +93,7 @@ namespace LibationSearchEngine } } - public SearchEngine(string directory = null) + public SearchEngine(string? directory = null) { SearchEngineDirectory = directory ?? new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName; @@ -102,13 +102,14 @@ namespace LibationSearchEngine /// <summary>Long running. Use await Task.Run(() => UpdateBook(productId))</summary> public void UpdateBook(LibationContext context, string productId) { - var libraryBook = context.GetLibraryBook_Flat_NoTracking(productId); - var term = new Term(_ID_, productId); + if (context.GetLibraryBook_Flat_NoTracking(productId) is not { } libraryBook) + return; - var document = createBookIndexDocument(libraryBook); + var document = createBookIndexDocument(libraryBook); var createNewIndex = false; - using var index = getIndex(); + var term = new Term(_ID_, productId); + using var index = getIndex(); using var analyzer = new StandardAnalyzer(Version); using var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED); ixWriter.DeleteDocuments(term); @@ -123,7 +124,8 @@ namespace LibationSearchEngine var allConcat = FieldIndexRules .Select(rule => rule.GetValue(libraryBook)) - .Aggregate((a, b) => $"{a} {b}"); + .OfType<string>() + .Aggregate((a, b) => $"{a} {b}"); doc.AddAnalyzed(ALL, allConcat); foreach (var rule in FieldIndexRules) @@ -152,17 +154,21 @@ namespace LibationSearchEngine book.Book.AudibleProductId, d => { - var lib = FieldIndexRules.GetRuleByFieldName("IsLiberated"); - var libError = FieldIndexRules.GetRuleByFieldName("LiberatedError"); - var lastDl = FieldIndexRules.GetRuleByFieldName(nameof(UserDefinedItem.LastDownloaded)); - - d.RemoveRule(lib); - d.RemoveRule(libError); - d.RemoveRule(lastDl); - - d.AddIndexRule(lib, book); - d.AddIndexRule(libError, book); - d.AddIndexRule(lastDl, book); + if (FieldIndexRules.GetRuleByFieldName("IsLiberated") is { } lib) + { + d.RemoveRule(lib); + d.AddIndexRule(lib, book); + } + if (FieldIndexRules.GetRuleByFieldName("LiberatedError") is { } libError) + { + d.RemoveRule(libError); + d.AddIndexRule(libError, book); + } + if (FieldIndexRules.GetRuleByFieldName(nameof(UserDefinedItem.LastDownloaded)) is { } lastDl) + { + d.RemoveRule(lastDl); + d.AddIndexRule(lastDl, book); + } }); public void UpdateUserRatings(LibraryBook book) @@ -170,10 +176,11 @@ namespace LibationSearchEngine book.Book.AudibleProductId, d => { - var rating = FieldIndexRules.GetRuleByFieldName("UserRating"); - - d.RemoveRule(rating); - d.AddIndexRule(rating, book); + if (FieldIndexRules.GetRuleByFieldName("UserRating") is { } rating) + { + d.RemoveRule(rating); + d.AddIndexRule(rating, book); + } }); private void updateDocument(string productId, Action<Document> action) diff --git a/Source/LibationUiBase/BaseUtil.cs b/Source/LibationUiBase/BaseUtil.cs index 9680bb56..7e49fdd2 100644 --- a/Source/LibationUiBase/BaseUtil.cs +++ b/Source/LibationUiBase/BaseUtil.cs @@ -6,20 +6,20 @@ namespace LibationUiBase public static class BaseUtil { /// <summary>A delegate that loads image bytes into the the UI framework's image format.</summary> - public static Func<byte[], PictureSize, object?> LoadImage => s_LoadImage ?? DefaultLoadImageImpl; + public static Func<byte[]?, PictureSize, object?> LoadImage => s_LoadImage ?? DefaultLoadImageImpl; /// <summary>A delegate that loads a named resource into the the UI framework's image format.</summary> public static Func<string, object?> LoadResourceImage => s_LoadResourceImage ?? DefaultLoadResourceImageImpl; - public static void SetLoadImageDelegate(Func<byte[], PictureSize, object?> tryLoadImage) + public static void SetLoadImageDelegate(Func<byte[]?, PictureSize, object?> tryLoadImage) => s_LoadImage = tryLoadImage; public static void SetLoadResourceImageDelegate(Func<string, object?> tryLoadResourceImage) => s_LoadResourceImage = tryLoadResourceImage; - private static Func<byte[], PictureSize, object?>? s_LoadImage; + private static Func<byte[]?, PictureSize, object?>? s_LoadImage; private static Func<string, object?>? s_LoadResourceImage; - private static object? DefaultLoadImageImpl(byte[] imageBytes, PictureSize size) + private static object? DefaultLoadImageImpl(byte[]? imageBytes, PictureSize size) { Serilog.Log.Error("{LoadImage} called without a delegate set. Picture size: {PictureSize}", nameof(LoadImage), size); return null; diff --git a/Source/LibationUiBase/GridView/GridEntry.cs b/Source/LibationUiBase/GridView/GridEntry.cs index 623714a6..9cc1e806 100644 --- a/Source/LibationUiBase/GridView/GridEntry.cs +++ b/Source/LibationUiBase/GridView/GridEntry.cs @@ -256,13 +256,23 @@ namespace LibationUiBase.GridView protected void LoadCover() { // Get cover art. If it's default, subscribe to PictureCached - (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); - if (isDefault) - PictureStorage.PictureCached += PictureStorage_PictureCached; + var picId = Book.PictureId ?? Book.PictureLarge; + if (picId is null) + { + // no picture id at all, use built-in default + _lazyCover = new Lazy<object?>(() => BaseUtil.LoadImage(null, PictureSize._80x80)); + } + else + { + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(picId, PictureSize._80x80)); - // Mutable property. Set the field so PropertyChanged isn't fired. - _lazyCover = new Lazy<object?>(() => BaseUtil.LoadImage(picture, PictureSize._80x80)); + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + _lazyCover = new Lazy<object?>(() => BaseUtil.LoadImage(picture, PictureSize._80x80)); + } } private void PictureStorage_PictureCached(object? sender, PictureCachedEventArgs e) diff --git a/Source/LibationUiBase/GridView/LastDownloadStatus.cs b/Source/LibationUiBase/GridView/LastDownloadStatus.cs index 7c1028b7..d5d1beb4 100644 --- a/Source/LibationUiBase/GridView/LastDownloadStatus.cs +++ b/Source/LibationUiBase/GridView/LastDownloadStatus.cs @@ -25,14 +25,14 @@ namespace LibationUiBase.GridView public void OpenReleaseUrl() { if (IsValid) - Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{LastDownloadedVersion!.ToVersionString()}"); + Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{LastDownloadedVersion.ToVersionString()}"); } public override string ToString() => IsValid ? $""" {dateString()} {versionString()} {LastDownloadedFormat} - Libation v{LastDownloadedVersion!.ToVersionString()} + Libation v{LastDownloadedVersion.ToVersionString()} """ : ""; private string versionString() => LastDownloadedFileVersion is string ver ? $"(File v.{ver})" : ""; diff --git a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs index 6b5cc7f9..445460be 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs @@ -104,7 +104,11 @@ public class ProcessBookViewModel : ReactiveObject Author = LibraryBook.Book.AuthorNames; Narrator = LibraryBook.Book.NarratorNames; - (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._80x80)); + var pictureId = LibraryBook.Book.PictureId ?? LibraryBook.Book.PictureLarge; + if (string.IsNullOrEmpty(pictureId)) + return; + + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(pictureId, PictureSize._80x80)); if (isDefault) PictureStorage.PictureCached += PictureStorage_PictureCached; @@ -258,15 +262,16 @@ public class ProcessBookViewModel : ReactiveObject private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt) => Cover = BaseUtil.LoadImage(coverArt, PictureSize._80x80); - private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e) + private byte[]? AudioDecodable_RequestCoverArt(object? sender, EventArgs e) { - var quality - = Configuration.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null - ? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native) - : new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500); + var pictureId = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High + ? LibraryBook.Book.PictureLarge ?? LibraryBook.Book.PictureId + : LibraryBook.Book.PictureId; - byte[] coverData = PictureStorage.GetPictureSynchronously(quality); + if (pictureId is null) + return null; + byte[] coverData = PictureStorage.GetPictureSynchronously(new PictureDefinition(pictureId, PictureSize.Native)); AudioDecodable_CoverImageDiscovered(this, coverData); return coverData; } diff --git a/Source/LibationUiBase/SeriesView/AyceButton.cs b/Source/LibationUiBase/SeriesView/AyceButton.cs index 2d0919d7..79da7c54 100644 --- a/Source/LibationUiBase/SeriesView/AyceButton.cs +++ b/Source/LibationUiBase/SeriesView/AyceButton.cs @@ -68,7 +68,7 @@ namespace LibationUiBase.SeriesView { Api api = await accountBook.GetApiAsync(); - if (await api.RemoveItemFromLibraryAsync(Item.ProductId)) + if (Item.ProductId is not null && await api.RemoveItemFromLibraryAsync(Item.ProductId)) { var lb = DbContexts.GetLibraryBook_Flat_NoTracking(Item.ProductId); int result = await LibraryCommands.PermanentlyDeleteBooksAsync([lb]); @@ -78,6 +78,9 @@ namespace LibationUiBase.SeriesView private async Task AddToLibraryAsync(LibraryBook accountBook) { + if (Item.ProductId is null) + return; + Api api = await accountBook.GetApiAsync(); if (!await api.AddItemToLibraryAsync(Item.ProductId)) return; @@ -91,13 +94,13 @@ namespace LibationUiBase.SeriesView item = await api.GetLibraryBookAsync(Item.ProductId, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS); } - if (item is null) return; + if (item?.Relationships is null) return; if (item.IsEpisodes) { var seriesParent = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true) .Select(lb => lb.Book) - .FirstOrDefault(b => b.IsEpisodeParent() && b.AudibleProductId.In(item.Relationships.Select((Relationship r) => r.Asin))); + .FirstOrDefault(b => b.IsEpisodeParent() && b.AudibleProductId.In(item.Relationships.Select(r => r.Asin))); if (seriesParent is null) return; diff --git a/Source/LibationUiBase/SeriesView/SeriesItem.cs b/Source/LibationUiBase/SeriesView/SeriesItem.cs index 37f725c6..6886e2ea 100644 --- a/Source/LibationUiBase/SeriesView/SeriesItem.cs +++ b/Source/LibationUiBase/SeriesView/SeriesItem.cs @@ -18,7 +18,7 @@ namespace LibationUiBase.SeriesView { public object? Cover { get; private set; } public SeriesOrder Order { get; } - public string Title => Item.TitleWithSubtitle; + public string? Title => Item.TitleWithSubtitle; public SeriesButton Button { get; } public Item Item { get; } @@ -26,8 +26,8 @@ namespace LibationUiBase.SeriesView { Item = item; Order = new SeriesOrder(order); - Button = Item.Plans.Any(p => p.IsAyce) ? new AyceButton(item, inLibrary) : new WishlistButton(item, inLibrary, inWishList); - LoadCover(item.PictureId); + Button = Item.Plans?.Any(p => p.IsAyce) is true ? new AyceButton(item, inLibrary) : new WishlistButton(item, inLibrary, inWishList); + LoadCover(item.PictureId ?? Item.PictureLarge); Button.PropertyChanged += DownloadButton_PropertyChanged; } @@ -41,8 +41,10 @@ namespace LibationUiBase.SeriesView private void DownloadButton_PropertyChanged(object? sender, PropertyChangedEventArgs e) => RaisePropertyChanged(nameof(Button)); - private void LoadCover(string pictureId) + private void LoadCover(string? pictureId) { + if (string.IsNullOrEmpty(pictureId)) + return; var (isDefault, picture) = PictureStorage.GetPicture(new PictureDefinition(pictureId, PictureSize._80x80)); if (isDefault) { @@ -93,11 +95,12 @@ namespace LibationUiBase.SeriesView //Books that are part of series have RelationshipType.Series //Podcast episodes have RelationshipType.Episode var childrenAsins = series.Relationships - .Where(r => r.RelationshipType is RelationshipType.Series or RelationshipType.Episode && r.RelationshipToProduct is RelationshipToProduct.Child) + ?.Where(r => r.RelationshipType is RelationshipType.Series or RelationshipType.Episode && r.RelationshipToProduct is RelationshipToProduct.Child) .Select(r => r.Asin) + .OfType<string>() .ToList(); - if (childrenAsins.Count > 0) + if (childrenAsins?.Count > 0) { var children = await api.GetCatalogProductsAsync(childrenAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS, 50, semaphore); @@ -124,7 +127,7 @@ namespace LibationUiBase.SeriesView foreach (var item in items[series].Where(i => !string.IsNullOrEmpty(i.PictureId))) { - var order = item.Series.Single(s => s.Asin == series.Asin).Sequence; + var order = item.Series?.Single(s => s.Asin == series.Asin).Sequence ?? "-1"; //Match the account/book in the database var inLibrary = fullLib.Any(lb => lb.Account == libraryBook.Account && lb.Book.AudibleProductId == item.ProductId && !lb.AbsentFromLastScan); var inWishList = wishlistAsins.Contains(item.Asin); diff --git a/Source/LibationUiBase/SeriesView/WishlistButton.cs b/Source/LibationUiBase/SeriesView/WishlistButton.cs index 3f5a4b99..ba6eb2e7 100644 --- a/Source/LibationUiBase/SeriesView/WishlistButton.cs +++ b/Source/LibationUiBase/SeriesView/WishlistButton.cs @@ -46,7 +46,7 @@ namespace LibationUiBase.SeriesView public override async Task PerformClickAsync(LibraryBook accountBook) { - if (!Enabled || !HasButtonAction) return; + if (!Enabled || !HasButtonAction || Item.Asin is null) return; Enabled = false; diff --git a/Source/LibationUiBase/Upgrader.cs b/Source/LibationUiBase/Upgrader.cs index 3166bf18..d43eb125 100644 --- a/Source/LibationUiBase/Upgrader.cs +++ b/Source/LibationUiBase/Upgrader.cs @@ -123,8 +123,8 @@ namespace LibationUiBase return Task.FromResult<UpgradeProperties?>(new UpgradeProperties( "http://fake.url/to/bundle.zip", "", - Path.GetFileName(MockUpgradeBundle), - LibationScaffolding.BuildVersion, + Path.GetFileName(MockUpgradeBundle) ?? "", + LibationScaffolding.BuildVersion ?? new(1, 0, 0, 0), "<RELEASE NOTES>")); } diff --git a/Source/LibationWinForms/AccessibleDataGridViewButtonCell.cs b/Source/LibationWinForms/AccessibleDataGridViewButtonCell.cs index cc879e76..34a7be5e 100644 --- a/Source/LibationWinForms/AccessibleDataGridViewButtonCell.cs +++ b/Source/LibationWinForms/AccessibleDataGridViewButtonCell.cs @@ -1,46 +1,42 @@ -using LibationWinForms.GridView; -using System.Windows.Forms; +using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public class AccessibleDataGridViewButtonCell : DataGridViewButtonCell { - public class AccessibleDataGridViewButtonCell : DataGridViewButtonCell - { - protected string AccessibilityName { get; } + protected string AccessibilityName { get; } - /// <summary> - /// Get or set description for accessibility. eg: screen readers. Also sets the ToolTipText - /// </summary> - protected string AccessibilityDescription - { - get => field; - set - { - field = value; - ToolTipText = value; - } - } - - protected override AccessibleObject CreateAccessibilityInstance() => new ButtonCellAccessibilityObject(this, name: AccessibilityName, description: AccessibilityDescription); - - public AccessibleDataGridViewButtonCell(string accessibilityName) : base() - { - AccessibilityName = accessibilityName; - FlatStyle = Application.IsDarkModeEnabled ? FlatStyle.Flat : FlatStyle.System; + /// <summary> + /// Get or set description for accessibility. eg: screen readers. Also sets the ToolTipText + /// </summary> + protected string? AccessibilityDescription + { + get => field; + set + { + field = value; + ToolTipText = value; } + } - protected class ButtonCellAccessibilityObject : DataGridViewButtonCellAccessibleObject - { - private string _name; - public override string Name => _name; + protected override AccessibleObject CreateAccessibilityInstance() => new ButtonCellAccessibilityObject(this, name: AccessibilityName, description: AccessibilityDescription); - private string _description; - public override string Description => _description; + public AccessibleDataGridViewButtonCell(string accessibilityName) : base() + { + AccessibilityName = accessibilityName; + FlatStyle = Application.IsDarkModeEnabled ? FlatStyle.Flat : FlatStyle.System; + } - public ButtonCellAccessibilityObject(DataGridViewCell owner, string name, string description) : base(owner) - { - _name = name; - _description = description; - } - } - } + protected class ButtonCellAccessibilityObject : DataGridViewButtonCellAccessibleObject + { + private string _name; + public override string Name => _name; + public override string? Description { get; } + + public ButtonCellAccessibilityObject(DataGridViewCell owner, string name, string? description) : base(owner) + { + _name = name; + Description = description; + } + } } diff --git a/Source/LibationWinForms/AccessibleDataGridViewComboBoxCell.cs b/Source/LibationWinForms/AccessibleDataGridViewComboBoxCell.cs index 769af451..fcbc984f 100644 --- a/Source/LibationWinForms/AccessibleDataGridViewComboBoxCell.cs +++ b/Source/LibationWinForms/AccessibleDataGridViewComboBoxCell.cs @@ -1,45 +1,42 @@ using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public class AccessibleDataGridViewComboBoxCell : DataGridViewComboBoxCell { - public class AccessibleDataGridViewComboBoxCell : DataGridViewComboBoxCell + protected string AccessibilityName { get; } + + /// <summary> + /// Get or set description for accessibility. eg: screen readers. Also sets the ToolTipText + /// </summary> + protected string? AccessibilityDescription { - protected string AccessibilityName { get; } - - /// <summary> - /// Get or set description for accessibility. eg: screen readers. Also sets the ToolTipText - /// </summary> - protected string AccessibilityDescription - { - get => field; - set - { - field = value; - ToolTipText = value; - } - } - - protected override AccessibleObject CreateAccessibilityInstance() => new ComboBoxCellAccessibilityObject(this, name: AccessibilityName, description: AccessibilityDescription); - - public AccessibleDataGridViewComboBoxCell(string accessibilityName) : base() - { - FlatStyle = Application.IsDarkModeEnabled ? FlatStyle.Flat : FlatStyle.Standard; - AccessibilityName = accessibilityName; - } - - protected class ComboBoxCellAccessibilityObject : DataGridViewComboBoxCellAccessibleObject + get => field; + set { - private string _name; - public override string Name => _name; + field = value; + ToolTipText = value; + } + } - private string _description; - public override string Description => _description; + protected override AccessibleObject CreateAccessibilityInstance() => new ComboBoxCellAccessibilityObject(this, name: AccessibilityName, description: AccessibilityDescription); - public ComboBoxCellAccessibilityObject(DataGridViewCell owner, string name, string description) : base(owner) - { - _name = name; - _description = description; - } - } - } + public AccessibleDataGridViewComboBoxCell(string accessibilityName) : base() + { + FlatStyle = Application.IsDarkModeEnabled ? FlatStyle.Flat : FlatStyle.Standard; + AccessibilityName = accessibilityName; + } + + protected class ComboBoxCellAccessibilityObject : DataGridViewComboBoxCellAccessibleObject + { + private string _name; + public override string Name => _name; + public override string? Description { get; } + + public ComboBoxCellAccessibilityObject(DataGridViewCell owner, string name, string? description) : base(owner) + { + _name = name; + Description = description; + } + } } diff --git a/Source/LibationWinForms/AccessibleDataGridViewTextBoxCell.cs b/Source/LibationWinForms/AccessibleDataGridViewTextBoxCell.cs index 7877c9c4..4d8a2e74 100644 --- a/Source/LibationWinForms/AccessibleDataGridViewTextBoxCell.cs +++ b/Source/LibationWinForms/AccessibleDataGridViewTextBoxCell.cs @@ -1,93 +1,90 @@ using System.ComponentModel; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public class AccessibleDataGridViewColumn : DataGridViewColumn { - public class AccessibleDataGridViewColumn : DataGridViewColumn + [DefaultValue(null)] + [Category("Accessibility")] + [Description("Accessibility Object Name")] + public string? AccessibilityName { get => field; set { field = value; cellTemplate?.AccessibilityName = value; } } + + [DefaultValue(null)] + [Category("Accessibility")] + [Description("Accessibility Object Description")] + public string? AccessibilityDescription { get => field; set { field = value; cellTemplate?.AccessibilityDescription = value; } } + private readonly AccessibleDataGridViewTextBoxCell? cellTemplate; + + public AccessibleDataGridViewColumn() { - [DefaultValue(null)] - [Category("Accessibility")] - [Description("Accessibility Object Name")] - public string AccessibilityName { get => field; set { field = value; cellTemplate.AccessibilityName = value; } } + CellTemplate = cellTemplate = new AccessibleDataGridViewTextBoxCell(); + } + public AccessibleDataGridViewColumn(AccessibleDataGridViewTextBoxCell cellTemplate) : base(cellTemplate) + { + this.cellTemplate = cellTemplate; + } - [DefaultValue(null)] - [Category("Accessibility")] - [Description("Accessibility Object Description")] - public string AccessibilityDescription { get => field; set { field = value; cellTemplate.AccessibilityDescription = value; } } - private readonly AccessibleDataGridViewTextBoxCell cellTemplate; + public override object Clone() + { + //This is necessary for the designer to work properly + var col = (AccessibleDataGridViewColumn)base.Clone(); + col.AccessibilityDescription = AccessibilityDescription; + col.AccessibilityName = AccessibilityName; + return col; + } +} - public AccessibleDataGridViewColumn() +public class AccessibleDataGridViewTextBoxCell : DataGridViewTextBoxCell +{ + public string? AccessibilityName + { + get => field; + set { - CellTemplate = cellTemplate = new AccessibleDataGridViewTextBoxCell(); - } - public AccessibleDataGridViewColumn(AccessibleDataGridViewTextBoxCell cellTemplate) : base(cellTemplate) - { - this.cellTemplate = cellTemplate; - } - - public override object Clone() - { - //This is necessary for the designer to work properly - var col = (AccessibleDataGridViewColumn)base.Clone(); - col.AccessibilityDescription = AccessibilityDescription; - col.AccessibilityName = AccessibilityName; - return col; + field = value; + (AccessibilityObject as TextBoxCellAccessibilityObject)?.SetName(field); } } - public class AccessibleDataGridViewTextBoxCell : DataGridViewTextBoxCell - { - private string _accessibilityName; + /// <summary> + /// Get or set description for accessibility. eg: screen readers. Also sets the ToolTipText + /// </summary> + public string? AccessibilityDescription + { + get => field; + set + { + field = value; + (AccessibilityObject as TextBoxCellAccessibilityObject)?.SetDescription(field); + ToolTipText = value; + } + } - public string AccessibilityName - { - get => _accessibilityName; - set - { - _accessibilityName = value; - (AccessibilityObject as TextBoxCellAccessibilityObject).SetName(_accessibilityName); - } - } + protected override AccessibleObject CreateAccessibilityInstance() => new TextBoxCellAccessibilityObject(this, name: AccessibilityName, description: AccessibilityDescription); - /// <summary> - /// Get or set description for accessibility. eg: screen readers. Also sets the ToolTipText - /// </summary> - public string AccessibilityDescription - { - get => field; - set - { - field = value; - (AccessibilityObject as TextBoxCellAccessibilityObject).SetDescription(field); - ToolTipText = value; - } - } + public AccessibleDataGridViewTextBoxCell(string accessibilityName) : base() + { + AccessibilityName = accessibilityName; + } - protected override AccessibleObject CreateAccessibilityInstance() => new TextBoxCellAccessibilityObject(this, name: AccessibilityName, description: AccessibilityDescription); + public AccessibleDataGridViewTextBoxCell() { } - public AccessibleDataGridViewTextBoxCell(string accessibilityName) : base() - { - _accessibilityName = accessibilityName; - } + protected class TextBoxCellAccessibilityObject : DataGridViewTextBoxCellAccessibleObject + { + private string? _name; + public override string? Name => _name; - public AccessibleDataGridViewTextBoxCell() { } + private string? _description; + public override string? Description => _description; - protected class TextBoxCellAccessibilityObject : DataGridViewTextBoxCellAccessibleObject - { - private string _name; - public override string Name => _name; + public void SetName(string? name) => _name = name; + public void SetDescription(string? description) => _description = description; - private string _description; - public override string Description => _description; - - public void SetName(string name) => _name = name; - public void SetDescription(string description) => _description = description; - - public TextBoxCellAccessibilityObject(DataGridViewCell owner, string name, string description) : base(owner) - { - _name = name; - _description = description; - } - } - } + public TextBoxCellAccessibilityObject(DataGridViewCell owner, string? name, string? description) : base(owner) + { + _name = name; + _description = description; + } + } } diff --git a/Source/LibationWinForms/BitrateDataGridTextBoxColumn.cs b/Source/LibationWinForms/BitrateDataGridTextBoxColumn.cs index 8095acee..388bd4e1 100644 --- a/Source/LibationWinForms/BitrateDataGridTextBoxColumn.cs +++ b/Source/LibationWinForms/BitrateDataGridTextBoxColumn.cs @@ -1,21 +1,20 @@ using System.Drawing; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public class BitrateDataGridTextBoxColumn : AccessibleDataGridViewColumn { - public class BitrateDataGridTextBoxColumn : AccessibleDataGridViewColumn + public BitrateDataGridTextBoxColumn() : base(new BitrateDataGridViewTextBoxCell()) { } + private class BitrateDataGridViewTextBoxCell : AccessibleDataGridViewTextBoxCell { - public BitrateDataGridTextBoxColumn() : base(new BitrateDataGridViewTextBoxCell()) { } - private class BitrateDataGridViewTextBoxCell : AccessibleDataGridViewTextBoxCell + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { - protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + if (value is int bitrate) { - if (value is int bitrate) - { - formattedValue = bitrate > 0 ? $"{bitrate} kbps" : ""; - } - base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); + formattedValue = bitrate > 0 ? $"{bitrate} kbps" : ""; } + base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); } } } diff --git a/Source/LibationWinForms/ClearableTextBox.cs b/Source/LibationWinForms/ClearableTextBox.cs index 2ab232bd..e645e654 100644 --- a/Source/LibationWinForms/ClearableTextBox.cs +++ b/Source/LibationWinForms/ClearableTextBox.cs @@ -1,56 +1,58 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Drawing; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public partial class ClearableTextBox : UserControl { - public partial class ClearableTextBox : UserControl + public event EventHandler? TextCleared; + [AllowNull] + public override string Text { get => textBox1.Text; set => textBox1.Text = value; } + [AllowNull] + public override Font Font { - public event EventHandler TextCleared; - public override string Text { get => textBox1.Text; set => textBox1.Text = value; } - public override Font Font + get => textBox1.Font; + set { - get => textBox1.Font; - set - { - base.Font = textBox1.Font = button1.Font = value; - OnSizeChanged(EventArgs.Empty); - } - } - - public int SelectionStart - { - get => textBox1.SelectionStart; - set => textBox1.SelectionStart = value; - } - - protected override void OnGotFocus(EventArgs e) - { - base.OnGotFocus(e); - textBox1.Focus(); - } - - public ClearableTextBox() - { - InitializeComponent(); - textBox1.KeyDown += (_, e) => OnKeyDown(e); - textBox1.KeyUp += (_, e) => OnKeyUp(e); - textBox1.KeyPress += (_, e) => OnKeyPress(e); - textBox1.TextChanged += (_, e) => OnTextChanged(e); - } - - protected override void OnSizeChanged(EventArgs e) - { - base.OnSizeChanged(e); - Height = button1.Width = button1.Height = textBox1.Height; - textBox1.Width = Width - button1.Width; - button1.Location = new Point(textBox1.Width, 0); - } - - private void button1_Click(object sender, System.EventArgs e) - { - textBox1.Clear(); - TextCleared?.Invoke(this, EventArgs.Empty); + base.Font = textBox1.Font = button1.Font = value; + OnSizeChanged(EventArgs.Empty); } } + + public int SelectionStart + { + get => textBox1.SelectionStart; + set => textBox1.SelectionStart = value; + } + + protected override void OnGotFocus(EventArgs e) + { + base.OnGotFocus(e); + textBox1.Focus(); + } + + public ClearableTextBox() + { + InitializeComponent(); + textBox1.KeyDown += (_, e) => OnKeyDown(e); + textBox1.KeyUp += (_, e) => OnKeyUp(e); + textBox1.KeyPress += (_, e) => OnKeyPress(e); + textBox1.TextChanged += (_, e) => OnTextChanged(e); + } + + protected override void OnSizeChanged(EventArgs e) + { + base.OnSizeChanged(e); + Height = button1.Width = button1.Height = textBox1.Height; + textBox1.Width = Width - button1.Width; + button1.Location = new Point(textBox1.Width, 0); + } + + private void button1_Click(object sender, System.EventArgs e) + { + textBox1.Clear(); + TextCleared?.Invoke(this, EventArgs.Empty); + } } diff --git a/Source/LibationWinForms/Dialogs/AboutDialog.cs b/Source/LibationWinForms/Dialogs/AboutDialog.cs index eafc6cff..3b974af0 100644 --- a/Source/LibationWinForms/Dialogs/AboutDialog.cs +++ b/Source/LibationWinForms/Dialogs/AboutDialog.cs @@ -5,76 +5,75 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class AboutDialog : Form { - public partial class AboutDialog : Form + public AboutDialog() { - public AboutDialog() + InitializeComponent(); + this.SetLibationIcon(); + releaseNotesLbl.Text = $"Libation {AppScaffolding.LibationScaffolding.Variety} v{AppScaffolding.LibationScaffolding.BuildVersion}"; + pictureBox1.Image = Application.IsDarkModeEnabled ? Properties.Resources.cheers_dark : Properties.Resources.cheers; + rmcrackanLbl.Tag = LibationContributor.PrimaryContributors.Single(c => c.Name == rmcrackanLbl.Text); + MBucariLbl.Tag = LibationContributor.PrimaryContributors.Single(c => c.Name == MBucariLbl.Text); + + foreach (var contributor in LibationContributor.AdditionalContributors) { - InitializeComponent(); - this.SetLibationIcon(); - releaseNotesLbl.Text = $"Libation {AppScaffolding.LibationScaffolding.Variety} v{AppScaffolding.LibationScaffolding.BuildVersion}"; - pictureBox1.Image = Application.IsDarkModeEnabled ? Properties.Resources.cheers_dark : Properties.Resources.cheers; - rmcrackanLbl.Tag = LibationContributor.PrimaryContributors.Single(c => c.Name == rmcrackanLbl.Text); - MBucariLbl.Tag = LibationContributor.PrimaryContributors.Single(c => c.Name == MBucariLbl.Text); - - foreach (var contributor in LibationContributor.AdditionalContributors) - { - var label = new LinkLabel { Tag = contributor, Text = contributor.Name, AutoSize = true }; - label.LinkClicked += ContributorLabel_LinkClicked; - label.SetLinkLabelColors(); - flowLayoutPanel1.Controls.Add(label); - } - rmcrackanLbl.SetLinkLabelColors(); - MBucariLbl.SetLinkLabelColors(); - releaseNotesLbl.SetLinkLabelColors(); - getLibationLbl.SetLinkLabelColors(); - - var toolTip = new ToolTip(); - toolTip.SetToolTip(releaseNotesLbl, "View Release Notes"); + var label = new LinkLabel { Tag = contributor, Text = contributor.Name, AutoSize = true }; + label.LinkClicked += ContributorLabel_LinkClicked; + label.SetLinkLabelColors(); + flowLayoutPanel1.Controls.Add(label); } + rmcrackanLbl.SetLinkLabelColors(); + MBucariLbl.SetLinkLabelColors(); + releaseNotesLbl.SetLinkLabelColors(); + getLibationLbl.SetLinkLabelColors(); - private void ContributorLabel_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - if (sender is LinkLabel lbl && lbl.Tag is LibationContributor contributor) - { - Dinah.Core.Go.To.Url(contributor.Link.AbsoluteUri); - e.Link.Visited = true; - } - } - - private void releaseNotesLbl_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - => Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToVersionString()}"); - - private async void checkForUpgradeBtn_Click(object sender, EventArgs e) - { - var form1 = Owner as Form1; - var upgrader = new Upgrader(); - upgrader.DownloadBegin += (_, _) => form1.Invoke(() => form1.upgradeLbl.Visible = form1.upgradePb.Visible = true); - upgrader.DownloadProgress += (_, e) => form1.Invoke(() => form1.upgradePb.Value = int.Max(0, int.Min(100, (int)(e.ProgressPercentage ?? 0)))); - upgrader.DownloadCompleted += (_, _) => form1.Invoke(() => form1.upgradeLbl.Visible = form1.upgradePb.Visible = false); - - checkForUpgradeBtn.Enabled = false; - Version latestVersion = null; - await upgrader.CheckForUpgradeAsync(OnUpgradeAvailable); - - checkForUpgradeBtn.Enabled = latestVersion is null; - - checkForUpgradeBtn.Text = latestVersion is null ? "Libation is up to date. Check Again." : $"Version {latestVersion:3} is available"; - - Task OnUpgradeAvailable(UpgradeEventArgs e) - { - var notificationResult = new UpgradeNotificationDialog(e.UpgradeProperties).ShowDialog(this); - - e.Ignore = notificationResult == DialogResult.Ignore; - e.InstallUpgrade = notificationResult == DialogResult.Yes; - latestVersion = e.UpgradeProperties.LatestRelease; - - return Task.CompletedTask; - } - } - - private void getLibationLbl_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.WebsiteUrl); + var toolTip = new ToolTip(); + toolTip.SetToolTip(releaseNotesLbl, "View Release Notes"); } + + private void ContributorLabel_LinkClicked(object? sender, LinkLabelLinkClickedEventArgs e) + { + if (sender is LinkLabel lbl && lbl.Tag is LibationContributor contributor) + { + Dinah.Core.Go.To.Url(contributor.Link.AbsoluteUri); + e.Link?.Visited = true; + } + } + + private void releaseNotesLbl_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + => Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToVersionString()}"); + + private async void checkForUpgradeBtn_Click(object sender, EventArgs e) + { + var form1 = Owner as Form1; + var upgrader = new Upgrader(); + upgrader.DownloadBegin += (_, _) => form1?.Invoke(() => form1.upgradeLbl.Visible = form1.upgradePb.Visible = true); + upgrader.DownloadProgress += (_, e) => form1?.Invoke(() => form1.upgradePb.Value = int.Max(0, int.Min(100, (int)(e.ProgressPercentage ?? 0)))); + upgrader.DownloadCompleted += (_, _) => form1?.Invoke(() => form1.upgradeLbl.Visible = form1.upgradePb.Visible = false); + + checkForUpgradeBtn.Enabled = false; + Version? latestVersion = null; + await upgrader.CheckForUpgradeAsync(OnUpgradeAvailable); + + checkForUpgradeBtn.Enabled = latestVersion is null; + + checkForUpgradeBtn.Text = latestVersion is null ? "Libation is up to date. Check Again." : $"Version {latestVersion:3} is available"; + + Task OnUpgradeAvailable(UpgradeEventArgs e) + { + var notificationResult = new UpgradeNotificationDialog(e.UpgradeProperties).ShowDialog(this); + + e.Ignore = notificationResult == DialogResult.Ignore; + e.InstallUpgrade = notificationResult == DialogResult.Yes; + latestVersion = e.UpgradeProperties.LatestRelease; + + return Task.CompletedTask; + } + } + + private void getLibationLbl_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.WebsiteUrl); } diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.cs b/Source/LibationWinForms/Dialogs/AccountsDialog.cs index 9d39bda8..4165d582 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.cs +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.cs @@ -1,353 +1,357 @@ -using System; +using AudibleApi; +using AudibleUtilities; +using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Windows.Forms; -using AudibleApi; -using AudibleUtilities; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class AccountsDialog : Form { - public partial class AccountsDialog : Form + private const string COL_Delete = nameof(DeleteAccount); + private const string COL_Export = nameof(ExportAccount); + private const string COL_LibraryScan = nameof(LibraryScan); + private const string COL_AccountId = nameof(AccountId); + private const string COL_AccountName = nameof(AccountName); + private const string COL_Locale = nameof(Locale); + + public AccountsDialog() { - private const string COL_Delete = nameof(DeleteAccount); - private const string COL_Export = nameof(ExportAccount); - private const string COL_LibraryScan = nameof(LibraryScan); - private const string COL_AccountId = nameof(AccountId); - private const string COL_AccountName = nameof(AccountName); - private const string COL_Locale = nameof(Locale); + InitializeComponent(); + dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; + dataGridView1.Columns[COL_AccountName]?.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; - public AccountsDialog() + populateDropDown(); + + populateGridValues(); + this.SetLibationIcon(); + } + + private void populateDropDown() + => (dataGridView1.Columns[COL_Locale] as DataGridViewComboBoxColumn)?.DataSource + = Localization.Locales + .Select(l => l.Name) + .OrderBy(a => a).ToList(); + + private void populateGridValues() + { + // WARNING: accounts persister will write ANY EDIT to object immediately to file + // here: copy strings and dispose of persister + // only persist in 'save' step + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings.Accounts; + if (!accounts.Any()) + return; + + foreach (var account in accounts) + AddAccountToGrid(account); + } + + private void AddAccountToGrid(Account account) + { + var row = dataGridView1.Rows.Add( + "X", + "Export", + account.LibraryScan, + account.AccountId, + account.Locale?.Name ?? "", + account.AccountName ?? ""); + + dataGridView1[COL_Export, row].ToolTipText = "Export account authorization to audible-cli"; + } + + private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e) + { + e.Row.Cells[COL_Delete].Value = "X"; + e.Row.Cells[COL_LibraryScan].Value = true; + } + + private void DataGridView1_CellContentClick(object sender, DataGridViewCellEventArgs e) + { + var dgv = (DataGridView)sender; + + var col = dgv.Columns[e.ColumnIndex]; + if (col is DataGridViewButtonColumn && e.RowIndex >= 0) { - InitializeComponent(); - dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - dataGridView1.Columns[COL_AccountName].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; - - populateDropDown(); - - populateGridValues(); - this.SetLibationIcon(); + var row = dgv.Rows[e.RowIndex]; + switch (col.Name) + { + case COL_Delete: + // if final/edit row: do nothing + if (e.RowIndex < dgv.RowCount - 1) + dgv.Rows.Remove(row); + break; + case COL_Export: + // if final/edit row: do nothing + if (e.RowIndex < dgv.RowCount - 1 && RowToAccountDto(row) is AccountDto accountDto) + Export(accountDto); + break; + //case COL_MoveUp: + // // if top: do nothing + // if (e.RowIndex < 1) + // break; + // dgv.Rows.Remove(row); + // dgv.Rows.Insert(e.RowIndex - 1, row); + // break; + //case COL_MoveDown: + // // if final/edit row or bottom filter row: do nothing + // if (e.RowIndex >= dgv.RowCount - 2) + // break; + // dgv.Rows.Remove(row); + // dgv.Rows.Insert(e.RowIndex + 1, row); + // break; + } } + } - private void populateDropDown() - => (dataGridView1.Columns[COL_Locale] as DataGridViewComboBoxColumn).DataSource - = Localization.Locales - .Select(l => l.Name) - .OrderBy(a => a).ToList(); + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); + } - private void populateGridValues() + private record AccountDto (string AccountId, string? AccountName, string LocaleName,bool LibraryScan); + + private void saveBtn_Click(object sender, EventArgs e) + { + try { - // WARNING: accounts persister will write ANY EDIT to object immediately to file - // here: copy strings and dispose of persister - // only persist in 'save' step - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var accounts = persister.AccountsSettings.Accounts; - if (!accounts.Any()) + if (!inputIsValid()) return; - foreach (var account in accounts) - AddAccountToGrid(account); - } - - private void AddAccountToGrid(Account account) - { - var row = dataGridView1.Rows.Add( - "X", - "Export", - account.LibraryScan, - account.AccountId, - account.Locale.Name, - account.AccountName); - - dataGridView1[COL_Export, row].ToolTipText = "Export account authorization to audible-cli"; - } - - private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e) - { - e.Row.Cells[COL_Delete].Value = "X"; - e.Row.Cells[COL_LibraryScan].Value = true; - } - - private void DataGridView1_CellContentClick(object sender, DataGridViewCellEventArgs e) - { - var dgv = (DataGridView)sender; - - var col = dgv.Columns[e.ColumnIndex]; - if (col is DataGridViewButtonColumn && e.RowIndex >= 0) - { - var row = dgv.Rows[e.RowIndex]; - switch (col.Name) - { - case COL_Delete: - // if final/edit row: do nothing - if (e.RowIndex < dgv.RowCount - 1) - dgv.Rows.Remove(row); - break; - case COL_Export: - // if final/edit row: do nothing - if (e.RowIndex < dgv.RowCount - 1) - Export((string)row.Cells[COL_AccountId].Value, (string)row.Cells[COL_Locale].Value); - break; - //case COL_MoveUp: - // // if top: do nothing - // if (e.RowIndex < 1) - // break; - // dgv.Rows.Remove(row); - // dgv.Rows.Insert(e.RowIndex - 1, row); - // break; - //case COL_MoveDown: - // // if final/edit row or bottom filter row: do nothing - // if (e.RowIndex >= dgv.RowCount - 2) - // break; - // dgv.Rows.Remove(row); - // dgv.Rows.Insert(e.RowIndex + 1, row); - // break; - } - } - } - - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); - } - - private class AccountDto - { - public string AccountId { get; set; } - public string AccountName { get; set; } - public string LocaleName { get; set; } - public bool LibraryScan { get; set; } - } - - private void saveBtn_Click(object sender, EventArgs e) - { - try - { - if (!inputIsValid()) - return; - - // without transaction, accounts persister will write ANY EDIT immediately to file - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - - persister.BeginTransation(); - persist(persister.AccountsSettings); - persister.CommitTransation(); - - this.DialogResult = DialogResult.OK; - this.Close(); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert(this, "Error attempting to save accounts", "Error saving accounts", ex); - } - } - - private bool inputIsValid() - { - var dtos = getRowDtos(); - - foreach (var dto in dtos) - { - if (string.IsNullOrWhiteSpace(dto.AccountId)) - { - MessageBox.Show(this, "Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error); - return false; - } - - if (string.IsNullOrWhiteSpace(dto.LocaleName)) - { - MessageBox.Show(this, "Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error); - return false; - } - } - - return true; - } - - private void persist(AccountsSettings accountsSettings) - { - var existingAccounts = accountsSettings.Accounts; - var dtos = getRowDtos(); - - // editing account id is a special case. an account is defined by its account id, therefore this is really a different account. the user won't care about this distinction though. - // these will be caught below by normal means and re-created minus the convenience of persisting identity tokens - - // delete - for (var i = existingAccounts.Count - 1; i >= 0; i--) - { - var existing = existingAccounts[i]; - if (!dtos.Any(dto => - dto.AccountId?.ToLower().Trim() == existing.AccountId.ToLower() - && dto.LocaleName == existing.Locale?.Name)) - { - accountsSettings.Delete(existing); - } - } - - // upsert each. validation occurs through Account and AccountsSettings - foreach (var dto in dtos) - { - var acct = accountsSettings.Upsert(dto.AccountId, dto.LocaleName); - acct.LibraryScan = dto.LibraryScan; - acct.AccountName - = string.IsNullOrWhiteSpace(dto.AccountName) - ? $"{dto.AccountId} - {dto.LocaleName}" - : dto.AccountName.Trim(); - } - } - - private List<AccountDto> getRowDtos() - => dataGridView1.Rows - .Cast<DataGridViewRow>() - .Where(r => !r.IsNewRow) - .Select(r => new AccountDto - { - AccountId = (string)r.Cells[COL_AccountId].Value, - AccountName = (string)r.Cells[COL_AccountName].Value, - LocaleName = (string)r.Cells[COL_Locale].Value, - LibraryScan = (bool)r.Cells[COL_LibraryScan].Value - }) - .ToList(); - - private string GetAudibleCliAppDataPath() - => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audible"); - - private void Export(string accountId, string locale) - { // without transaction, accounts persister will write ANY EDIT immediately to file using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == accountId && a.Locale.Name == locale); + persister.BeginTransation(); + persist(persister.AccountsSettings); + persister.CommitTransation(); - if (account is null) - return; - - if (account.IdentityTokens?.IsValid != true) - { - MessageBox.Show(this, "This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated"); - return; - } - - SaveFileDialog sfd = new(); - sfd.Filter = "JSON File|*.json"; - - string audibleAppDataDir = GetAudibleCliAppDataPath(); - - if (Directory.Exists(audibleAppDataDir)) - sfd.InitialDirectory = audibleAppDataDir; - - if (sfd.ShowDialog() != DialogResult.OK) return; - - try - { - var mkbAuth = Mkb79Auth.FromAccount(account); - var jsonText = mkbAuth.ToJson(); - - File.WriteAllText(sfd.FileName, jsonText); - - MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{sfd.FileName}", "Success!"); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - this, - $"An error occurred while exporting account:\r\n{account.AccountName}", - "Error Exporting Account", - ex); - } + this.DialogResult = DialogResult.OK; + this.Close(); } - - private async void importBtn_Click(object sender, EventArgs e) + catch (Exception ex) { - OpenFileDialog ofd = new(); - ofd.Filter = "JSON File|*.json"; - ofd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - - string audibleAppDataDir = GetAudibleCliAppDataPath(); - - if (Directory.Exists(audibleAppDataDir)) - ofd.InitialDirectory = audibleAppDataDir; - - if (ofd.ShowDialog() != DialogResult.OK) return; - - try - { - var jsonText = File.ReadAllText(ofd.FileName); - var mkbAuth = Mkb79Auth.FromJson(jsonText); - var account = await mkbAuth.ToAccountAsync(); - - // without transaction, accounts persister will write ANY EDIT immediately to file - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - - if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name)) - { - MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account"); - return; - } - - persister.AccountsSettings.Add(account); - - AddAccountToGrid(account); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - this, - $"An error occurred while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?", - "Error Importing Account", - ex); - } + MessageBoxLib.ShowAdminAlert(this, "Error attempting to save accounts", "Error saving accounts", ex); } - #region Accessable Columns - - public class DeleteColumn : DataGridViewButtonColumn - { - public DeleteColumn() : base() - { - this.CellTemplate = new DeleteColumnCell(); - } - } - - public class ExportColumn : DataGridViewButtonColumn - { - public ExportColumn() : base() - { - this.CellTemplate = new ExportColumnCell(); - } - } - - public class LocaleColumn : DataGridViewComboBoxColumn - { - public LocaleColumn() : base() - { - this.CellTemplate = new LocaleColumnCell(); - } - } - - public class DeleteColumnCell : AccessibleDataGridViewButtonCell - { - public DeleteColumnCell() : base("Delete account from Libation") - { - ToolTipText = AccessibilityName; - } - } - - public class LocaleColumnCell : AccessibleDataGridViewComboBoxCell - { - public LocaleColumnCell() : base("Select Audible account region") - { - ToolTipText = AccessibilityName; - } - } - - public class ExportColumnCell : AccessibleDataGridViewButtonCell - { - public ExportColumnCell() : base("Export account to mkb79/audible-cli format") - { - ToolTipText = AccessibilityName; - } - } - #endregion } + + private bool inputIsValid() + { + if (getRows().Any(r => GetAccountId(r) is null)) + { + MessageBox.Show(this, "Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error); + return false; + } + + if (getRows().Any(r => GetLocale(r) is null)) + { + MessageBox.Show(this, "Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error); + return false; + } + return true; + } + + private void persist(AccountsSettings accountsSettings) + { + var existingAccounts = accountsSettings.Accounts; + var dtos = getRowDtos(); + + // editing account id is a special case. an account is defined by its account id, therefore this is really a different account. the user won't care about this distinction though. + // these will be caught below by normal means and re-created minus the convenience of persisting identity tokens + + // delete + for (var i = existingAccounts.Count - 1; i >= 0; i--) + { + var existing = existingAccounts[i]; + if (!dtos.Any(dto => + dto.AccountId?.ToLower().Trim() == existing.AccountId.ToLower() + && dto.LocaleName == existing.Locale?.Name)) + { + accountsSettings.Delete(existing); + } + } + + // upsert each. validation occurs through Account and AccountsSettings + foreach (var dto in dtos) + { + var acct = accountsSettings.Upsert(dto.AccountId, dto.LocaleName); + acct.LibraryScan = dto.LibraryScan; + acct.AccountName + = string.IsNullOrWhiteSpace(dto.AccountName) + ? $"{dto.AccountId} - {dto.LocaleName}" + : dto.AccountName.Trim(); + } + } + + private IEnumerable<DataGridViewRow> getRows() + => dataGridView1.Rows + .Cast<DataGridViewRow>() + .Where(r => !r.IsNewRow); + + private List<AccountDto> getRowDtos() + => getRows() + .Select(RowToAccountDto) + .OfType<AccountDto>() + .ToList(); + + private static string? GetAccountId(DataGridViewRow row) + => row.Cells[COL_AccountId]?.Value as string; + + private static string? GetLocale(DataGridViewRow row) + => row.Cells[COL_Locale]?.Value as string; + + private static bool? GetLibraryScan(DataGridViewRow row) + => row.Cells[COL_LibraryScan]?.Value as bool?; + + private static string? GetAccountName(DataGridViewRow row) + => row.Cells[COL_AccountName]?.Value as string; + + private static AccountDto? RowToAccountDto(DataGridViewRow row) + => GetAccountId(row) is string accountId + && GetLocale(row) is string localeName + && GetLibraryScan(row) is bool libraryScan + ? new AccountDto(accountId, GetAccountName(row), localeName, libraryScan) + : null; + + private string GetAudibleCliAppDataPath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audible"); + + private void Export(AccountDto accountDto) + { + // without transaction, accounts persister will write ANY EDIT immediately to file + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + + var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == accountDto.AccountId && a.Locale?.Name == accountDto.LocaleName); + + if (account is null) + return; + + if (account.IdentityTokens?.IsValid != true) + { + MessageBox.Show(this, "This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated"); + return; + } + + SaveFileDialog sfd = new(); + sfd.Filter = "JSON File|*.json"; + + string audibleAppDataDir = GetAudibleCliAppDataPath(); + + if (Directory.Exists(audibleAppDataDir)) + sfd.InitialDirectory = audibleAppDataDir; + + if (sfd.ShowDialog() != DialogResult.OK) return; + + try + { + var mkbAuth = Mkb79Auth.FromAccount(account); + var jsonText = mkbAuth.ToJson(); + + File.WriteAllText(sfd.FileName, jsonText); + + MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{sfd.FileName}", "Success!"); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + this, + $"An error occurred while exporting account:\r\n{account.AccountName}", + "Error Exporting Account", + ex); + } + } + + private async void importBtn_Click(object sender, EventArgs e) + { + OpenFileDialog ofd = new(); + ofd.Filter = "JSON File|*.json"; + ofd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + string audibleAppDataDir = GetAudibleCliAppDataPath(); + + if (Directory.Exists(audibleAppDataDir)) + ofd.InitialDirectory = audibleAppDataDir; + + if (ofd.ShowDialog() != DialogResult.OK) return; + + try + { + var jsonText = File.ReadAllText(ofd.FileName); + var mkbAuth = Mkb79Auth.FromJson(jsonText) ?? throw new Exception("File did not contain valid mkb79/audible-cli account data."); + var account = await mkbAuth.ToAccountAsync(); + + // without transaction, accounts persister will write ANY EDIT immediately to file + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + + if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name)) + { + MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale?.Name}", "Cannot Add Duplicate Account"); + return; + } + + persister.AccountsSettings.Add(account); + + AddAccountToGrid(account); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + this, + $"An error occurred while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?", + "Error Importing Account", + ex); + } + } + #region Accessable Columns + + public class DeleteColumn : DataGridViewButtonColumn + { + public DeleteColumn() : base() + { + this.CellTemplate = new DeleteColumnCell(); + } + } + + public class ExportColumn : DataGridViewButtonColumn + { + public ExportColumn() : base() + { + this.CellTemplate = new ExportColumnCell(); + } + } + + public class LocaleColumn : DataGridViewComboBoxColumn + { + public LocaleColumn() : base() + { + this.CellTemplate = new LocaleColumnCell(); + } + } + + public class DeleteColumnCell : AccessibleDataGridViewButtonCell + { + public DeleteColumnCell() : base("Delete account from Libation") + { + ToolTipText = AccessibilityName; + } + } + + public class LocaleColumnCell : AccessibleDataGridViewComboBoxCell + { + public LocaleColumnCell() : base("Select Audible account region") + { + ToolTipText = AccessibilityName; + } + } + + public class ExportColumnCell : AccessibleDataGridViewButtonCell + { + public ExportColumnCell() : base("Export account to mkb79/audible-cli format") + { + ToolTipText = AccessibilityName; + } + } + #endregion } diff --git a/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs b/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs index 44ea50fa..7981655d 100644 --- a/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs +++ b/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs @@ -6,148 +6,158 @@ using DataLayer; using Dinah.Core; using LibationFileManager; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class BookDetailsDialog : Form { - public partial class BookDetailsDialog : Form + public class liberatedComboBoxItem { - public class liberatedComboBoxItem + public LiberatedStatus Status { get; set; } + public string? Text { get; set; } + public override string? ToString() => Text; + } + + public string? NewTags { get; private set; } + public LiberatedStatus BookLiberatedStatus { get; private set; } + public LiberatedStatus? PdfLiberatedStatus { get; private set; } + + private Book? Book => LibraryBook?.Book; + + public BookDetailsDialog() + { + InitializeComponent(); + this.SetLibationIcon(); + audibleLink.SetLinkLabelColors(); + } + + public LibraryBook? LibraryBook + { + get => field; + set { - public LiberatedStatus Status { get; set; } - public string Text { get; set; } - public override string ToString() => Text; + field = value; + initDetails(); + initTags(); + initLiberated(); + } + } + + // 1st draft: lazily cribbed from GridEntry.ctor() + private void initDetails() + { + if (Book is null) + return; + audibleLink.LinkVisited = false; + this.Text = Book.TitleWithSubtitle; + dolbyAtmosPb.Visible = Book.IsSpatial; + dolbyAtmosPb.Image = Application.IsDarkModeEnabled ? Properties.Resources.Dolby_Atmos_Vertical_80_dark : Properties.Resources.Dolby_Atmos_Vertical_80; + + var picture = (Book.PictureId ?? Book.PictureLarge) is not string picId ? null + : PictureStorage.GetPicture(new(picId, PictureSize._80x80)).bytes; + this.coverPb.Image = WinFormsUtil.TryLoadImageOrDefault(picture, PictureSize._80x80); + + var title = string.IsNullOrEmpty(Book.Subtitle) ? Book.Title : $"{Book.Title}\r\n {Book.Subtitle}"; + var t = $""" + Title: {title} + Author(s): {Book.AuthorNames} + Narrator(s): {Book.NarratorNames} + Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")} + Category: {string.Join(", ", Book.LowestCategoryNames())} + Purchase Date: {LibraryBook!.DateAdded:d} + Language: {Book.Language} + Audible ID: {Book.AudibleProductId} + """; + + var seriesNames = Book.SeriesNames(); + if (!string.IsNullOrWhiteSpace(seriesNames)) + t += $"\r\nSeries: {seriesNames}"; + + var bookRating = Book.Rating?.ToStarString(); + if (!string.IsNullOrWhiteSpace(bookRating)) + t += $"\r\nBook Rating:\r\n{bookRating}"; + + var myRating = Book.UserDefinedItem.Rating?.ToStarString(); + if (!string.IsNullOrWhiteSpace(myRating)) + t += $"\r\nMy Rating:\r\n{myRating}"; + + this.detailsTb.Text = t; + } + private void initTags() => this.newTagsTb.Text = Book?.UserDefinedItem.Tags; + private void initLiberated() + { + if (Book is null) + return; + { + var status = Book.UserDefinedItem.BookStatus; + this.bookLiberatedCb.Items.Clear(); + this.bookLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.Liberated, Text = "Downloaded" }); + this.bookLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" }); + + // this should only appear if is already an error. User should not be able to set status to error, only away from error + if (status == LiberatedStatus.Error) + this.bookLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.Error, Text = "Error" }); + + setDefaultComboBox(this.bookLiberatedCb, status); } - public string NewTags { get; private set; } - public LiberatedStatus BookLiberatedStatus { get; private set; } - public LiberatedStatus? PdfLiberatedStatus { get; private set; } - - private Book Book => LibraryBook.Book; - - public BookDetailsDialog() { - InitializeComponent(); - this.SetLibationIcon(); - audibleLink.SetLinkLabelColors(); - } - - public LibraryBook LibraryBook - { - get => field; - set + var status = Book.UserDefinedItem.PdfStatus; + this.pdfLiberatedCb.Items.Clear(); + this.pdfLiberatedCb.Enabled = status is not null; + if (status is not null) { - field = value; - initDetails(); - initTags(); - initLiberated(); + this.pdfLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.Liberated, Text = "Downloaded" }); + this.pdfLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" }); + + setDefaultComboBox(this.pdfLiberatedCb, status); } } - - // 1st draft: lazily cribbed from GridEntry.ctor() - private void initDetails() + } + private static void setDefaultComboBox(ComboBox comboBox, LiberatedStatus? status) + { + if (!status.HasValue) { - audibleLink.LinkVisited = false; - this.Text = Book.TitleWithSubtitle; - dolbyAtmosPb.Visible = Book.IsSpatial; - dolbyAtmosPb.Image = Application.IsDarkModeEnabled ? Properties.Resources.Dolby_Atmos_Vertical_80_dark : Properties.Resources.Dolby_Atmos_Vertical_80; - - (_, var picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); - this.coverPb.Image = WinFormsUtil.TryLoadImageOrDefault(picture, PictureSize._80x80); - - var title = string.IsNullOrEmpty(Book.Subtitle) ? Book.Title : $"{Book.Title}\r\n {Book.Subtitle}"; - var t = $""" - Title: {title} - Author(s): {Book.AuthorNames} - Narrator(s): {Book.NarratorNames} - Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")} - Category: {string.Join(", ", Book.LowestCategoryNames())} - Purchase Date: {LibraryBook.DateAdded:d} - Language: {Book.Language} - Audible ID: {Book.AudibleProductId} - """; - - var seriesNames = Book.SeriesNames(); - if (!string.IsNullOrWhiteSpace(seriesNames)) - t += $"\r\nSeries: {seriesNames}"; - - var bookRating = Book.Rating?.ToStarString(); - if (!string.IsNullOrWhiteSpace(bookRating)) - t += $"\r\nBook Rating:\r\n{bookRating}"; - - var myRating = Book.UserDefinedItem.Rating?.ToStarString(); - if (!string.IsNullOrWhiteSpace(myRating)) - t += $"\r\nMy Rating:\r\n{myRating}"; - - this.detailsTb.Text = t; - } - private void initTags() => this.newTagsTb.Text = Book.UserDefinedItem.Tags; - private void initLiberated() - { - { - var status = Book.UserDefinedItem.BookStatus; - this.bookLiberatedCb.Items.Clear(); - this.bookLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.Liberated, Text = "Downloaded" }); - this.bookLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" }); - - // this should only appear if is already an error. User should not be able to set status to error, only away from error - if (status == LiberatedStatus.Error) - this.bookLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.Error, Text = "Error" }); - - setDefaultComboBox(this.bookLiberatedCb, status); - } - - { - var status = Book.UserDefinedItem.PdfStatus; - this.pdfLiberatedCb.Items.Clear(); - this.pdfLiberatedCb.Enabled = status is not null; - if (status is not null) - { - this.pdfLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.Liberated, Text = "Downloaded" }); - this.pdfLiberatedCb.Items.Add(new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" }); - - setDefaultComboBox(this.pdfLiberatedCb, status); - } - } - } - private static void setDefaultComboBox(ComboBox comboBox, LiberatedStatus? status) - { - if (!status.HasValue) - { - comboBox.SelectedIndex = 0; - return; - } - - var item = comboBox.Items.Cast<liberatedComboBoxItem>().SingleOrDefault(item => item.Status == status.Value); - if (item is not null) - comboBox.SelectedItem = item; - else - comboBox.SelectedIndex = 0; + comboBox.SelectedIndex = 0; + return; } - private async void saveBtn_Click(object sender, EventArgs e) - { - NewTags = this.newTagsTb.Text; - BookLiberatedStatus = ((liberatedComboBoxItem)this.bookLiberatedCb.SelectedItem).Status; + var item = comboBox.Items.Cast<liberatedComboBoxItem>().SingleOrDefault(item => item.Status == status.Value); + if (item is not null) + comboBox.SelectedItem = item; + else + comboBox.SelectedIndex = 0; + } - if (this.pdfLiberatedCb.Enabled) - PdfLiberatedStatus = ((liberatedComboBoxItem)this.pdfLiberatedCb.SelectedItem).Status; + private async void saveBtn_Click(object sender, EventArgs e) + { + NewTags = this.newTagsTb.Text; + if (bookLiberatedCb.SelectedItem is liberatedComboBoxItem bStatus) + BookLiberatedStatus = bStatus.Status; - Invoke(() => saveBtn.Enabled = cancelBtn.Enabled = false); - await LibraryBook.UpdateUserDefinedItemAsync(NewTags, BookLiberatedStatus, PdfLiberatedStatus); - Invoke(() => saveBtn.Enabled = cancelBtn.Enabled = true); - } + if (pdfLiberatedCb.Enabled && pdfLiberatedCb.SelectedItem is liberatedComboBoxItem pStatus) + PdfLiberatedStatus = pStatus.Status; - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); - } + if (LibraryBook is null) + return; - private void audibleLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - var locale = AudibleApi.Localization.Get(Book.Locale); - var link = $"https://www.audible.{locale.TopDomain}/pd/{Book.AudibleProductId}"; - Go.To.Url(link); - e.Link.Visited = true; - } - } + Invoke(() => saveBtn.Enabled = cancelBtn.Enabled = false); + await LibraryBook.UpdateUserDefinedItemAsync(NewTags, BookLiberatedStatus, PdfLiberatedStatus); + Invoke(() => saveBtn.Enabled = cancelBtn.Enabled = true); + } + + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); + } + + private void audibleLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + if (Book is null) + return; + var locale = AudibleApi.Localization.Get(Book.Locale); + var link = $"https://www.audible.{locale.TopDomain}/pd/{Book.AudibleProductId}"; + Go.To.Url(link); + e.Link?.Visited = true; + } } diff --git a/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs b/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs index 65e8e53c..7548228a 100644 --- a/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs +++ b/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs @@ -10,240 +10,254 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class BookRecordsDialog : Form { - public partial class BookRecordsDialog : Form + private readonly Func<ScrollBar> VScrollBar; + private readonly LibraryBook? libraryBook; + private SortBindingList<BookRecordEntry>? bookRecordEntries; + + public BookRecordsDialog() { - private readonly Func<ScrollBar> VScrollBar; - private readonly LibraryBook libraryBook; - private SortBindingList<BookRecordEntry> bookRecordEntries; - - public BookRecordsDialog() + InitializeComponent(); + dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; + if (!DesignMode) { - InitializeComponent(); - dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - if (!DesignMode) - { - //Prevent the designer from auto-generating columns - dataGridView1.AutoGenerateColumns = false; - dataGridView1.DataSource = syncBindingSource; - } - - this.SetLibationIcon(); - - VScrollBar = - typeof(DataGridView) - .GetProperty("VerticalScrollBar", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - .GetMethod - .CreateDelegate<Func<ScrollBar>>(dataGridView1); - - this.RestoreSizeAndLocation(LibationFileManager.Configuration.Instance); - FormClosing += (_, _) => this.SaveSizeAndLocation(LibationFileManager.Configuration.Instance); + //Prevent the designer from auto-generating columns + dataGridView1.AutoGenerateColumns = false; + dataGridView1.DataSource = syncBindingSource; } - public BookRecordsDialog(LibraryBook libraryBook) : this() - { - this.libraryBook = libraryBook; + this.SetLibationIcon(); - Text = $"{libraryBook.Book.TitleWithSubtitle} - Clips and Bookmarks"; + VScrollBar = + typeof(DataGridView) + .GetProperty("VerticalScrollBar", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.GetMethod + ?.CreateDelegate<Func<ScrollBar>>(dataGridView1) ?? throw new MissingMemberException(nameof(DataGridView), "VerticalScrollBar"); + + this.RestoreSizeAndLocation(LibationFileManager.Configuration.Instance); + FormClosing += (_, _) => this.SaveSizeAndLocation(LibationFileManager.Configuration.Instance); + } + + public BookRecordsDialog(LibraryBook libraryBook) : this() + { + this.libraryBook = libraryBook; + + Text = $"{libraryBook.Book.TitleWithSubtitle} - Clips and Bookmarks"; + } + + private async void BookRecordsDialog_Shown(object sender, EventArgs e) + { + if (libraryBook is null) + return; + try + { + var api = await libraryBook.GetApiAsync(); + var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId); + + bookRecordEntries = new SortBindingList<BookRecordEntry>(records.Select(r => new BookRecordEntry(r))); } - - private async void BookRecordsDialog_Shown(object sender, EventArgs e) + catch(Exception ex) { - try - { - var api = await libraryBook.GetApiAsync(); - var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId); - - bookRecordEntries = new SortBindingList<BookRecordEntry>(records.Select(r => new BookRecordEntry(r))); - } - catch(Exception ex) - { - Serilog.Log.Error(ex, "Failed to retrieve records for {libraryBook}", libraryBook); - bookRecordEntries = new(); - } - finally - { - syncBindingSource.DataSource = bookRecordEntries; - - //Autosize columns and resize form to column width so no horizontal scroll bar is necessary. - dataGridView1.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells); - var columnWidth = dataGridView1.Columns.OfType<DataGridViewColumn>().Sum(c => c.Width); - Width = Width - dataGridView1.Width + columnWidth + dataGridView1.Margin.Right + (VScrollBar().Visible? VScrollBar().ClientSize.Width : 0); - } + Serilog.Log.Error(ex, "Failed to retrieve records for {libraryBook}", libraryBook); + bookRecordEntries = new(); } - - #region Buttons - - private void setControlEnabled(object control, bool enabled) + finally { - if (control is Control c) - { - if (c.InvokeRequired) - c.Invoke(new MethodInvoker(() => - { - c.Enabled = enabled; - c.Focus(); - })); - else + syncBindingSource.DataSource = bookRecordEntries; + + //Autosize columns and resize form to column width so no horizontal scroll bar is necessary. + dataGridView1.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells); + var columnWidth = dataGridView1.Columns.OfType<DataGridViewColumn>().Sum(c => c.Width); + Width = Width - dataGridView1.Width + columnWidth + dataGridView1.Margin.Right + (VScrollBar().Visible? VScrollBar().ClientSize.Width : 0); + } + } + + #region Buttons + + private void setControlEnabled(object control, bool enabled) + { + if (control is Control c) + { + if (c.InvokeRequired) + c.Invoke(new MethodInvoker(() => { c.Enabled = enabled; c.Focus(); - } - } - } - - private async void exportCheckedBtn_Click(object sender, EventArgs e) - { - setControlEnabled(sender, false); - await saveRecords(bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record)); - setControlEnabled(sender, true); - } - - private async void exportAllBtn_Click(object sender, EventArgs e) - { - setControlEnabled(sender, false); - await saveRecords(bookRecordEntries.Select(r => r.Record)); - setControlEnabled(sender, true); - } - - private void uncheckAllBtn_Click(object sender, EventArgs e) - { - foreach (var record in bookRecordEntries) - record.IsChecked = false; - } - - private void checkAllBtn_Click(object sender, EventArgs e) - { - foreach (var record in bookRecordEntries) - record.IsChecked = true; - } - - private async void deleteCheckedBtn_Click(object sender, EventArgs e) - { - var records = bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record).ToList(); - - if (!records.Any()) return; - - setControlEnabled(sender, false); - - bool success = false; - try + })); + else { - var api = await libraryBook.GetApiAsync(); - success = await api.DeleteRecordsAsync(libraryBook.Book.AudibleProductId, records); - records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId); - - var removed = bookRecordEntries.ExceptBy(records, r => r.Record).ToList(); - - foreach (var r in removed) - bookRecordEntries.Remove(r); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, ex.Message); - } - finally { setControlEnabled(sender, true); } - - if (!success) - MessageBox.Show(this, $"Libation was unable to delete the {records.Count} selected records", "Deletion Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - - private async void reloadAllBtn_Click(object sender, EventArgs e) - { - setControlEnabled(sender, false); - - try - { - var api = await libraryBook.GetApiAsync(); - var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId); - - bookRecordEntries = new SortBindingList<BookRecordEntry>(records.Select(r => new BookRecordEntry(r))); - syncBindingSource.DataSource = bookRecordEntries; - } - catch (Exception ex) - { - Serilog.Log.Error(ex, ex.Message); - MessageBox.Show(this, $"Libation was unable to to reload records", "Reload Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - finally { setControlEnabled(sender, true); } - } - - #endregion - - private async Task saveRecords(IEnumerable<IRecord> records) - { - if (!records.Any()) return; - - try - { - var saveFileDialog = - Invoke(() => new SaveFileDialog - { - Title = "Where to export records", - AddExtension = true, - FileName = $"{libraryBook.Book.TitleWithSubtitle} - Records", - DefaultExt = "xlsx", - Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*" - }); - - if (Invoke(saveFileDialog.ShowDialog) != DialogResult.OK) - return; - - // FilterIndex is 1-based, NOT 0-based - switch (saveFileDialog.FilterIndex) - { - case 1: // xlsx - default: - await Task.Run(() => RecordExporter.ToXlsx(saveFileDialog.FileName, records)); - break; - case 2: // csv - await Task.Run(() => RecordExporter.ToCsv(saveFileDialog.FileName, records)); - break; - case 3: // json - await Task.Run(() => RecordExporter.ToJson(saveFileDialog.FileName, libraryBook, records)); - break; - } - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex); + c.Enabled = enabled; + c.Focus(); } } - - protected override void OnKeyDown(KeyEventArgs e) - { - if (e.KeyCode == Keys.Escape) Close(); - base.OnKeyDown(e); - } - - #region DataGridView Bindings - - private class BookRecordEntry : LibationUiBase.ReactiveObject - { - private const string DateFormat = "yyyy-MM-dd HH\\:mm"; - public IRecord Record { get; } - public bool IsChecked { get => field; set => RaiseAndSetIfChanged(ref field, value); } - public string Type => Record.GetType().Name; - public string Start => formatTimeSpan(Record.Start); - public string Created => Record.Created.ToString(DateFormat); - public string Modified => Record is IAnnotation annotation ? annotation.Created.ToString(DateFormat) : string.Empty; - public string End => Record is IRangeAnnotation range ? formatTimeSpan(range.End) : string.Empty; - public string Note => Record is IRangeAnnotation range ? range.Text : string.Empty; - public string Title => Record is Clip range ? range.Title : string.Empty; - public BookRecordEntry(IRecord record) => Record = record; - - private static string formatTimeSpan(TimeSpan timeSpan) - { - int h = (int)timeSpan.TotalHours; - int m = timeSpan.Minutes; - int s = timeSpan.Seconds; - int ms = timeSpan.Milliseconds; - - return ms == 0 ? $"{h:d2}:{m:d2}:{s:d2}" : $"{h:d2}:{m:d2}:{s:d2}.{ms:d3}"; - } - } - - #endregion } + + private async void exportCheckedBtn_Click(object sender, EventArgs e) + { + if (bookRecordEntries is null) + return; + setControlEnabled(sender, false); + await saveRecords(bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record)); + setControlEnabled(sender, true); + } + + private async void exportAllBtn_Click(object sender, EventArgs e) + { + if (bookRecordEntries is null) + return; + setControlEnabled(sender, false); + await saveRecords(bookRecordEntries.Select(r => r.Record)); + setControlEnabled(sender, true); + } + + private void uncheckAllBtn_Click(object sender, EventArgs e) + { + if (bookRecordEntries is null) + return; + foreach (var record in bookRecordEntries) + record.IsChecked = false; + } + + private void checkAllBtn_Click(object sender, EventArgs e) + { + if (bookRecordEntries is null) + return; + foreach (var record in bookRecordEntries) + record.IsChecked = true; + } + + private async void deleteCheckedBtn_Click(object sender, EventArgs e) + { + if (bookRecordEntries is null || libraryBook is null) + return; + var records = bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record).ToList(); + + if (!records.Any()) return; + + setControlEnabled(sender, false); + + bool success = false; + try + { + var api = await libraryBook.GetApiAsync(); + success = await api.DeleteRecordsAsync(libraryBook.Book.AudibleProductId, records); + records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId); + + var removed = bookRecordEntries.ExceptBy(records, r => r.Record).ToList(); + + foreach (var r in removed) + bookRecordEntries.Remove(r); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, ex.Message); + } + finally { setControlEnabled(sender, true); } + + if (!success) + MessageBox.Show(this, $"Libation was unable to delete the {records.Count} selected records", "Deletion Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + private async void reloadAllBtn_Click(object sender, EventArgs e) + { + if (libraryBook is null) + return; + setControlEnabled(sender, false); + + try + { + var api = await libraryBook.GetApiAsync(); + var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId); + + bookRecordEntries = new SortBindingList<BookRecordEntry>(records.Select(r => new BookRecordEntry(r))); + syncBindingSource.DataSource = bookRecordEntries; + } + catch (Exception ex) + { + Serilog.Log.Error(ex, ex.Message); + MessageBox.Show(this, $"Libation was unable to to reload records", "Reload Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally { setControlEnabled(sender, true); } + } + + #endregion + + private async Task saveRecords(IEnumerable<IRecord> records) + { + if (libraryBook is null || !records.Any()) + return; + + try + { + var saveFileDialog = + Invoke(() => new SaveFileDialog + { + Title = "Where to export records", + AddExtension = true, + FileName = $"{libraryBook.Book.TitleWithSubtitle} - Records", + DefaultExt = "xlsx", + Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*" + }); + + if (Invoke(saveFileDialog.ShowDialog) != DialogResult.OK) + return; + + // FilterIndex is 1-based, NOT 0-based + switch (saveFileDialog.FilterIndex) + { + case 1: // xlsx + default: + await Task.Run(() => RecordExporter.ToXlsx(saveFileDialog.FileName, records)); + break; + case 2: // csv + await Task.Run(() => RecordExporter.ToCsv(saveFileDialog.FileName, records)); + break; + case 3: // json + await Task.Run(() => RecordExporter.ToJson(saveFileDialog.FileName, libraryBook, records)); + break; + } + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex); + } + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.KeyCode == Keys.Escape) Close(); + base.OnKeyDown(e); + } + + #region DataGridView Bindings + + private class BookRecordEntry : LibationUiBase.ReactiveObject + { + private const string DateFormat = "yyyy-MM-dd HH\\:mm"; + public IRecord Record { get; } + public bool IsChecked { get => field; set => RaiseAndSetIfChanged(ref field, value); } + public string Type => Record.GetType().Name; + public string Start => formatTimeSpan(Record.Start); + public string Created => Record.Created.ToString(DateFormat); + public string Modified => Record is IAnnotation annotation ? annotation.Created.ToString(DateFormat) : string.Empty; + public string End => Record is IRangeAnnotation range ? formatTimeSpan(range.End) : string.Empty; + public string Note => (Record as IRangeAnnotation)?.Text ?? string.Empty; + public string Title => (Record as Clip)?.Title ?? string.Empty; + public BookRecordEntry(IRecord record) => Record = record; + + private static string formatTimeSpan(TimeSpan timeSpan) + { + int h = (int)timeSpan.TotalHours; + int m = timeSpan.Minutes; + int s = timeSpan.Seconds; + int ms = timeSpan.Milliseconds; + + return ms == 0 ? $"{h:d2}:{m:d2}:{s:d2}" : $"{h:d2}:{m:d2}:{s:d2}.{ms:d3}"; + } + } + + #endregion } diff --git a/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs b/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs index 5edab385..efc8408e 100644 --- a/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs +++ b/Source/LibationWinForms/Dialogs/DirectoryOrCustomSelectControl.cs @@ -1,109 +1,107 @@ using System; -using System.Linq; using System.Collections.Generic; using System.Windows.Forms; using LibationFileManager; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class DirectoryOrCustomSelectControl : UserControl { - public partial class DirectoryOrCustomSelectControl : UserControl + public bool SelectedDirectoryIsKnown => knownDirectoryRb.Checked; + public bool SelectedDirectoryIsCustom => customDirectoryRb.Checked; + public string? SelectedDirectory + => SelectedDirectoryIsKnown ? directorySelectControl.SelectedDirectory + : SelectedDirectoryIsCustom ? customTb.Text.Trim() + : null; + + public DirectoryOrCustomSelectControl() { - public bool SelectedDirectoryIsKnown => knownDirectoryRb.Checked; - public bool SelectedDirectoryIsCustom => customDirectoryRb.Checked; - public string SelectedDirectory - => SelectedDirectoryIsKnown ? directorySelectControl.SelectedDirectory - : SelectedDirectoryIsCustom ? customTb.Text.Trim() - : null; + InitializeComponent(); - public DirectoryOrCustomSelectControl() + // doing this after InitializeComponent will fire event + this.knownDirectoryRb.Checked = true; + } + + /// <summary>Set items for combobox</summary> + /// <param name="knownDirectories">List rather than IEnumerable so that client can determine display order</param> + /// <param name="defaultDirectory"></param> + public void SetDirectoryItems(List<Configuration.KnownDirectories> knownDirectories, Configuration.KnownDirectories? defaultDirectory = Configuration.KnownDirectories.UserProfile, string? subDirectory = null) + => this.directorySelectControl.SetDirectoryItems(knownDirectories, defaultDirectory, subDirectory); + + /// <summary>set selection</summary> + /// <param name="directory"></param> + public void SelectDirectory(Configuration.KnownDirectories directory) + { + // if None: take no action + if (directory != Configuration.KnownDirectories.None) + selectDir(directory, null); + } + + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + //Workaround for anchoring bug in user controls + //https://github.com/dotnet/winforms/issues/6381 + customBtn.Location = new System.Drawing.Point(Width - customBtn.Width, customTb.Location.Y); + customBtn.Height = customTb.Height; + directorySelectControl.Width = Width - directorySelectControl.Location.X; + customTb.Width = Width - customTb.Location.X - customBtn.Width - customTb.Margin.Left; + } + + /// <summary>set selection</summary> + public void SelectDirectory(string? directory) + { + directory = directory?.Trim() ?? ""; + + // remove SubDirectory setting to find known directories + var noSubDir = this.directorySelectControl.RemoveSubDirectoryFromPath(directory); + var knownDir = Configuration.GetKnownDirectory(noSubDir); + // DO NOT remove SubDirectory setting for custom + var customDir = directory; + selectDir(knownDir, customDir); + } + + private void selectDir(Configuration.KnownDirectories knownDir, string? customDir) + { + var isKnown + = knownDir != Configuration.KnownDirectories.None + // this could be a well known dir which isn't an option in this particular dropdown. This will always be true of LibationFiles + && this.directorySelectControl.SelectDirectory(knownDir); + + customDirectoryRb.Checked = !isKnown; + knownDirectoryRb.Checked = isKnown; + this.customTb.Text = isKnown ? "" : customDir; + } + + private string? dirSearchTitle; + public void SetSearchTitle(string dirSearchTitle) => this.dirSearchTitle = dirSearchTitle?.Trim(); + + private void customBtn_Click(object sender, EventArgs e) + { + using var dialog = new FolderBrowserDialog { - InitializeComponent(); + Description = string.IsNullOrWhiteSpace(dirSearchTitle) ? "Search" : $"Search for {dirSearchTitle}", + SelectedPath = this.customTb.Text + }; + dialog.ShowDialog(); + if (!string.IsNullOrWhiteSpace(dialog.SelectedPath)) + this.customTb.Text = dialog.SelectedPath; + } - // doing this after InitializeComponent will fire event - this.knownDirectoryRb.Checked = true; - } + private void radioButton_CheckedChanged(object sender, EventArgs e) + { + var isCustom = this.customDirectoryRb.Checked; - /// <summary>Set items for combobox</summary> - /// <param name="knownDirectories">List rather than IEnumerable so that client can determine display order</param> - /// <param name="defaultDirectory"></param> - public void SetDirectoryItems(List<Configuration.KnownDirectories> knownDirectories, Configuration.KnownDirectories? defaultDirectory = Configuration.KnownDirectories.UserProfile, string subDirectory = null) - => this.directorySelectControl.SetDirectoryItems(knownDirectories, defaultDirectory, subDirectory); + customTb.Enabled = isCustom; + customBtn.Enabled = isCustom; - /// <summary>set selection</summary> - /// <param name="directory"></param> - public void SelectDirectory(Configuration.KnownDirectories directory) - { - // if None: take no action - if (directory != Configuration.KnownDirectories.None) - selectDir(directory, null); - } + directorySelectControl.Enabled = !isCustom; + } - protected override void OnResize(EventArgs e) - { - base.OnResize(e); - //Workaround for anchoring bug in user controls - //https://github.com/dotnet/winforms/issues/6381 - customBtn.Location = new System.Drawing.Point(Width - customBtn.Width, customTb.Location.Y); - customBtn.Height = customTb.Height; - directorySelectControl.Width = Width - directorySelectControl.Location.X; - customTb.Width = Width - customTb.Location.X - customBtn.Width - customTb.Margin.Left; - } + private void DirectoryOrCustomSelectControl_Load(object sender, EventArgs e) + { + if (this.DesignMode) + return; - /// <summary>set selection</summary> - public void SelectDirectory(string directory) - { - directory = directory?.Trim() ?? ""; - - // remove SubDirectory setting to find known directories - var noSubDir = this.directorySelectControl.RemoveSubDirectoryFromPath(directory); - var knownDir = Configuration.GetKnownDirectory(noSubDir); - // DO NOT remove SubDirectory setting for custom - var customDir = directory; - selectDir(knownDir, customDir); - } - - private void selectDir(Configuration.KnownDirectories knownDir, string customDir) - { - var isKnown - = knownDir != Configuration.KnownDirectories.None - // this could be a well known dir which isn't an option in this particular dropdown. This will always be true of LibationFiles - && this.directorySelectControl.SelectDirectory(knownDir); - - customDirectoryRb.Checked = !isKnown; - knownDirectoryRb.Checked = isKnown; - this.customTb.Text = isKnown ? "" : customDir; - } - - private string dirSearchTitle; - public void SetSearchTitle(string dirSearchTitle) => this.dirSearchTitle = dirSearchTitle?.Trim(); - - private void customBtn_Click(object sender, EventArgs e) - { - using var dialog = new FolderBrowserDialog - { - Description = string.IsNullOrWhiteSpace(dirSearchTitle) ? "Search" : $"Search for {dirSearchTitle}", - SelectedPath = this.customTb.Text - }; - dialog.ShowDialog(); - if (!string.IsNullOrWhiteSpace(dialog.SelectedPath)) - this.customTb.Text = dialog.SelectedPath; - } - - private void radioButton_CheckedChanged(object sender, EventArgs e) - { - var isCustom = this.customDirectoryRb.Checked; - - customTb.Enabled = isCustom; - customBtn.Enabled = isCustom; - - directorySelectControl.Enabled = !isCustom; - } - - private void DirectoryOrCustomSelectControl_Load(object sender, EventArgs e) - { - if (this.DesignMode) - return; - - } } } diff --git a/Source/LibationWinForms/Dialogs/DirectorySelectControl.cs b/Source/LibationWinForms/Dialogs/DirectorySelectControl.cs index 6dc81654..cba3b5e0 100644 --- a/Source/LibationWinForms/Dialogs/DirectorySelectControl.cs +++ b/Source/LibationWinForms/Dialogs/DirectorySelectControl.cs @@ -5,114 +5,117 @@ using System.Windows.Forms; using Dinah.Core; using LibationFileManager; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class DirectorySelectControl : UserControl { - public partial class DirectorySelectControl : UserControl + private class DirectoryComboBoxItem { - private class DirectoryComboBoxItem + public string? Description { get; } + public Configuration.KnownDirectories Value { get; } + private readonly DirectorySelectControl _parentControl; + + public string FullPath => _parentControl.AddSubDirectoryToPath(Configuration.GetKnownDirectoryPath(Value)); + + /// <summary>Displaying relative paths is confusing. UI should display absolute equivalent</summary> + public string UiDisplayPath => Value == Configuration.KnownDirectories.AppDir ? _parentControl.AddSubDirectoryToPath(Configuration.AppDir_Absolute) : FullPath; + + public DirectoryComboBoxItem(DirectorySelectControl parentControl, Configuration.KnownDirectories knownDirectory) { - public string Description { get; } - public Configuration.KnownDirectories Value { get; } - private readonly DirectorySelectControl _parentControl; + _parentControl = parentControl; - public string FullPath => _parentControl.AddSubDirectoryToPath(Configuration.GetKnownDirectoryPath(Value)); - - /// <summary>Displaying relative paths is confusing. UI should display absolute equivalent</summary> - public string UiDisplayPath => Value == Configuration.KnownDirectories.AppDir ? _parentControl.AddSubDirectoryToPath(Configuration.AppDir_Absolute) : FullPath; - - public DirectoryComboBoxItem(DirectorySelectControl parentControl, Configuration.KnownDirectories knownDirectory) - { - _parentControl = parentControl; - - Value = knownDirectory; - Description = Value.GetDescription(); - } - - public override string ToString() => Description; + Value = knownDirectory; + Description = Value.GetDescription(); } - public string SelectedDirectory => selectedItem?.FullPath; - - private string _subDirectory; - internal string AddSubDirectoryToPath(string path) => string.IsNullOrWhiteSpace(_subDirectory) ? path : System.IO.Path.Combine(path, _subDirectory); - internal string RemoveSubDirectoryFromPath(string path) - { - if (string.IsNullOrWhiteSpace(_subDirectory)) - return path; - - path = path?.Trim() ?? ""; - if (string.IsNullOrWhiteSpace(path)) - return path; - - var bottomDir = System.IO.Path.GetFileName(path); - if (_subDirectory.EqualsInsensitive(bottomDir)) - return System.IO.Path.GetDirectoryName(path); - - return path; - } - protected override void OnResize(EventArgs e) - { - base.OnResize(e); - //For some reason anchors don't work when the parent form scales up, even with AutoScale - directoryComboBox.Width = textBox1.Width = Width; - } - - private DirectoryComboBoxItem selectedItem => (DirectoryComboBoxItem)this.directoryComboBox.SelectedItem; - - public DirectorySelectControl() => InitializeComponent(); - - /// <summary>Set items for combobox</summary> - /// <param name="knownDirectories">List rather than IEnumerable so that client can determine display order</param> - /// <param name="defaultDirectory">Optional default item to select</param> - public void SetDirectoryItems(List<Configuration.KnownDirectories> knownDirectories, Configuration.KnownDirectories? defaultDirectory = null, string subDirectory = null) - { - // set this 1st so all DirectoryComboBoxItems can reference it - _subDirectory = subDirectory; - - this.directoryComboBox.Items.Clear(); - - foreach (var dir in knownDirectories.Where(d => d != Configuration.KnownDirectories.None).Distinct()) - this.directoryComboBox.Items.Add(new DirectoryComboBoxItem(this, dir)); - - SelectDirectory(defaultDirectory); - } - - /// <summary>set selection</summary> - /// <param name="directory"></param> - /// <returns>True is there was a matching entry</returns> - public bool SelectDirectory(string directory) - { - directory = directory?.Trim() ?? ""; - - var noSubDir = RemoveSubDirectoryFromPath(directory); - var knownDir = Configuration.GetKnownDirectory(noSubDir); - return SelectDirectory(knownDir); - } - - /// <summary>set selection</summary> - /// <param name="directory"></param> - /// <returns>True is there was a matching entry</returns> - public bool SelectDirectory(Configuration.KnownDirectories? directory) - { - if (directory is null || directory == Configuration.KnownDirectories.None) - return false; - - // set default - var item = this.directoryComboBox.Items.Cast<DirectoryComboBoxItem>().SingleOrDefault(item => item.Value == directory.Value); - if (item is null) - return false; - - this.directoryComboBox.SelectedItem = item; - return true; - } - - private void DirectorySelectControl_Load(object sender, EventArgs e) - { - if (this.DesignMode) - return; - - } - - private void directoryComboBox_SelectedIndexChanged(object sender, EventArgs e) => this.textBox1.Text = selectedItem.UiDisplayPath; + public override string? ToString() => Description; } + + public string? SelectedDirectory => selectedItem?.FullPath; + + private string? _subDirectory; + internal string AddSubDirectoryToPath(string? path) + => string.IsNullOrWhiteSpace(_subDirectory) + ? string.IsNullOrWhiteSpace(path) ? "" : path + : string.IsNullOrWhiteSpace(path) ? _subDirectory : System.IO.Path.Combine(path, _subDirectory); + + internal string RemoveSubDirectoryFromPath(string path) + { + if (string.IsNullOrWhiteSpace(_subDirectory)) + return path; + + path = path?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(path)) + return path; + + var bottomDir = System.IO.Path.GetFileName(path); + if (_subDirectory.EqualsInsensitive(bottomDir)) + return System.IO.Path.GetDirectoryName(path) ?? ""; + + return path; + } + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + //For some reason anchors don't work when the parent form scales up, even with AutoScale + directoryComboBox.Width = textBox1.Width = Width; + } + + private DirectoryComboBoxItem? selectedItem => directoryComboBox.SelectedItem as DirectoryComboBoxItem; + + public DirectorySelectControl() => InitializeComponent(); + + /// <summary>Set items for combobox</summary> + /// <param name="knownDirectories">List rather than IEnumerable so that client can determine display order</param> + /// <param name="defaultDirectory">Optional default item to select</param> + public void SetDirectoryItems(List<Configuration.KnownDirectories> knownDirectories, Configuration.KnownDirectories? defaultDirectory = null, string? subDirectory = null) + { + // set this 1st so all DirectoryComboBoxItems can reference it + _subDirectory = subDirectory; + + this.directoryComboBox.Items.Clear(); + + foreach (var dir in knownDirectories.Where(d => d != Configuration.KnownDirectories.None).Distinct()) + this.directoryComboBox.Items.Add(new DirectoryComboBoxItem(this, dir)); + + SelectDirectory(defaultDirectory); + } + + /// <summary>set selection</summary> + /// <param name="directory"></param> + /// <returns>True is there was a matching entry</returns> + public bool SelectDirectory(string directory) + { + directory = directory?.Trim() ?? ""; + + var noSubDir = RemoveSubDirectoryFromPath(directory); + var knownDir = Configuration.GetKnownDirectory(noSubDir); + return SelectDirectory(knownDir); + } + + /// <summary>set selection</summary> + /// <param name="directory"></param> + /// <returns>True is there was a matching entry</returns> + public bool SelectDirectory(Configuration.KnownDirectories? directory) + { + if (directory is null || directory == Configuration.KnownDirectories.None) + return false; + + // set default + var item = this.directoryComboBox.Items.Cast<DirectoryComboBoxItem>().SingleOrDefault(item => item.Value == directory.Value); + if (item is null) + return false; + + this.directoryComboBox.SelectedItem = item; + return true; + } + + private void DirectorySelectControl_Load(object sender, EventArgs e) + { + if (this.DesignMode) + return; + + } + + private void directoryComboBox_SelectedIndexChanged(object sender, EventArgs e) => this.textBox1.Text = selectedItem?.UiDisplayPath; } diff --git a/Source/LibationWinForms/Dialogs/EditQuickFilters.cs b/Source/LibationWinForms/Dialogs/EditQuickFilters.cs index 99f3ac5a..ac4593d3 100644 --- a/Source/LibationWinForms/Dialogs/EditQuickFilters.cs +++ b/Source/LibationWinForms/Dialogs/EditQuickFilters.cs @@ -4,141 +4,142 @@ using System.Linq; using System.Windows.Forms; using LibationFileManager; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class EditQuickFilters : Form { - public partial class EditQuickFilters : Form + private const string BLACK_UP_POINTING_TRIANGLE = "\u25B2"; + private const string BLACK_DOWN_POINTING_TRIANGLE = "\u25BC"; + private const string COL_Original = nameof(Original); + private const string COL_Delete = nameof(Delete); + private const string COL_Filter = nameof(Filter); + private const string COL_FilterName = nameof(FilterName); + private const string COL_MoveUp = nameof(MoveUp); + private const string COL_MoveDown = nameof(MoveDown); + + internal class DisableButtonCell : AccessibleDataGridViewButtonCell { - private const string BLACK_UP_POINTING_TRIANGLE = "\u25B2"; - private const string BLACK_DOWN_POINTING_TRIANGLE = "\u25BC"; - private const string COL_Original = nameof(Original); - private const string COL_Delete = nameof(Delete); - private const string COL_Filter = nameof(Filter); - private const string COL_FilterName = nameof(FilterName); - private const string COL_MoveUp = nameof(MoveUp); - private const string COL_MoveDown = nameof(MoveDown); + private int LastRowIndex + => DataGridView?.Rows.Count is null or 0 ? 0 + : DataGridView.Rows[^1].IsNewRow ? DataGridView.Rows[^1].Index - 1 + : DataGridView.Rows[^1].Index; - internal class DisableButtonCell : AccessibleDataGridViewButtonCell - { - private int LastRowIndex => DataGridView.Rows[^1].IsNewRow ? DataGridView.Rows[^1].Index - 1 : DataGridView.Rows[^1].Index; + public DisableButtonCell() : base("Edit Filter button") { } - public DisableButtonCell() : base("Edit Filter button") { } + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + { + var isMoveUp = OwningColumn?.Name == COL_MoveUp; + var isMoveDown = OwningColumn?.Name == COL_MoveDown; + var isDelete = OwningColumn?.Name == COL_Delete; + var isNewRow = OwningRow?.IsNewRow ?? false; - protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + if (isNewRow + || (isMoveUp && rowIndex == 0) + || (isMoveDown && rowIndex == LastRowIndex)) { - var isMoveUp = OwningColumn.Name == COL_MoveUp; - var isMoveDown = OwningColumn.Name == COL_MoveDown; - var isDelete = OwningColumn.Name == COL_Delete; - var isNewRow = OwningRow.IsNewRow; + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts ^ (DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground)); - if (isNewRow - || (isMoveUp && rowIndex == 0) - || (isMoveDown && rowIndex == LastRowIndex)) - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts ^ (DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground)); - - ButtonRenderer.DrawButton(graphics, cellBounds, value as string, cellStyle.Font, false, System.Windows.Forms.VisualStyles.PushButtonState.Disabled); - } - else - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); - - if (isMoveUp) - AccessibilityDescription = "Move up"; - else if (isMoveDown) - AccessibilityDescription = "Move down"; - else if (isDelete) - AccessibilityDescription = "Delete"; - } + ButtonRenderer.DrawButton(graphics, cellBounds, value as string, cellStyle.Font, false, System.Windows.Forms.VisualStyles.PushButtonState.Disabled); } - } - - public EditQuickFilters() - { - InitializeComponent(); - dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - dataGridView1.Columns[COL_Filter].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; - - populateGridValues(); - this.SetLibationIcon(); - } - - private void populateGridValues() - { - var filters = QuickFilters.Filters; - if (!filters.Any()) - return; - - foreach (var filter in filters) - dataGridView1.Rows.Add(filter.Filter, "X", filter.Name, filter.Filter, BLACK_UP_POINTING_TRIANGLE, BLACK_DOWN_POINTING_TRIANGLE); - //dataGridView1.Rows.Add(filter, "X", filter, BLACK_UP_POINTING_TRIANGLE, BLACK_DOWN_POINTING_TRIANGLE); - } - - private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e) - { - e.Row.Cells[COL_Delete].Value = "X"; - e.Row.Cells[COL_MoveUp].Value = BLACK_UP_POINTING_TRIANGLE; - e.Row.Cells[COL_MoveDown].Value = BLACK_DOWN_POINTING_TRIANGLE; - } - - private void saveBtn_Click(object sender, EventArgs e) - { - var list = dataGridView1.Rows - .OfType<DataGridViewRow>() - .Select(r => new QuickFilters.NamedFilter( - r.Cells[COL_Filter].Value?.ToString(), - r.Cells[COL_FilterName].Value?.ToString())) - .ToList(); - QuickFilters.ReplaceAll(list); - - this.DialogResult = DialogResult.OK; - this.Close(); - } - - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); - } - - private void DataGridView1_CellContentClick(object sender, DataGridViewCellEventArgs e) - { - var dgv = (DataGridView)sender; - - var col = dgv.Columns[e.ColumnIndex]; - if (col is DataGridViewButtonColumn && e.RowIndex >= 0 && !dgv.Rows[e.RowIndex].IsNewRow) + else { - var row = dgv.Rows[e.RowIndex]; - switch (col.Name) - { - case COL_Delete: - // if final/edit row: do nothing - if (e.RowIndex < dgv.RowCount - 1) - dgv.Rows.Remove(row); - break; - case COL_MoveUp: - // if top: do nothing - if (e.RowIndex < 1) - break; - dgv.Rows.Remove(row); - dgv.Rows.Insert(e.RowIndex - 1, row); - break; - case COL_MoveDown: - // if final/edit row or bottom filter row: do nothing - if (e.RowIndex >= dgv.RowCount - 2) - break; - dgv.Rows.Remove(row); - dgv.Rows.Insert(e.RowIndex + 1, row); - break; - } + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); + + if (isMoveUp) + AccessibilityDescription = "Move up"; + else if (isMoveDown) + AccessibilityDescription = "Move down"; + else if (isDelete) + AccessibilityDescription = "Delete"; } } } - public class DisableButtonColumn : DataGridViewButtonColumn - { - public DisableButtonColumn() - { - CellTemplate = new EditQuickFilters.DisableButtonCell(); - } - } + public EditQuickFilters() + { + InitializeComponent(); + dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; + dataGridView1.Columns[COL_Filter]?.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + + populateGridValues(); + this.SetLibationIcon(); + } + + private void populateGridValues() + { + var filters = QuickFilters.Filters; + if (!filters.Any()) + return; + + foreach (var filter in filters) + dataGridView1.Rows.Add(filter.Filter, "X", filter.Name ?? "", filter.Filter, BLACK_UP_POINTING_TRIANGLE, BLACK_DOWN_POINTING_TRIANGLE); + //dataGridView1.Rows.Add(filter, "X", filter, BLACK_UP_POINTING_TRIANGLE, BLACK_DOWN_POINTING_TRIANGLE); + } + + private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e) + { + e.Row.Cells[COL_Delete].Value = "X"; + e.Row.Cells[COL_MoveUp].Value = BLACK_UP_POINTING_TRIANGLE; + e.Row.Cells[COL_MoveDown].Value = BLACK_DOWN_POINTING_TRIANGLE; + } + + private void saveBtn_Click(object sender, EventArgs e) + { + var list = dataGridView1.Rows + .OfType<DataGridViewRow>() + .Select(r => new { Filter = r.Cells[COL_Filter].Value?.ToString(), FilterName = r.Cells[COL_FilterName].Value?.ToString() }) + .Select(r => r.Filter is null ? null : new QuickFilters.NamedFilter(r.Filter, r.FilterName)) + .OfType<QuickFilters.NamedFilter>() + .ToArray(); + QuickFilters.ReplaceAll(list); + + this.DialogResult = DialogResult.OK; + this.Close(); + } + + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); + } + + private void DataGridView1_CellContentClick(object sender, DataGridViewCellEventArgs e) + { + var dgv = (DataGridView)sender; + + var col = dgv.Columns[e.ColumnIndex]; + if (col is DataGridViewButtonColumn && e.RowIndex >= 0 && !dgv.Rows[e.RowIndex].IsNewRow) + { + var row = dgv.Rows[e.RowIndex]; + switch (col.Name) + { + case COL_Delete: + // if final/edit row: do nothing + if (e.RowIndex < dgv.RowCount - 1) + dgv.Rows.Remove(row); + break; + case COL_MoveUp: + // if top: do nothing + if (e.RowIndex < 1) + break; + dgv.Rows.Remove(row); + dgv.Rows.Insert(e.RowIndex - 1, row); + break; + case COL_MoveDown: + // if final/edit row or bottom filter row: do nothing + if (e.RowIndex >= dgv.RowCount - 2) + break; + dgv.Rows.Remove(row); + dgv.Rows.Insert(e.RowIndex + 1, row); + break; + } + } + } +} +public class DisableButtonColumn : DataGridViewButtonColumn +{ + public DisableButtonColumn() + { + CellTemplate = new EditQuickFilters.DisableButtonCell(); + } } diff --git a/Source/LibationWinForms/Dialogs/EditReplacementChars.cs b/Source/LibationWinForms/Dialogs/EditReplacementChars.cs index a8c9321c..31b7f5ca 100644 --- a/Source/LibationWinForms/Dialogs/EditReplacementChars.cs +++ b/Source/LibationWinForms/Dialogs/EditReplacementChars.cs @@ -5,140 +5,139 @@ using System.Collections.Generic; using System.Linq; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class EditReplacementChars : Form { - public partial class EditReplacementChars : Form + Configuration? config; + public EditReplacementChars() { - Configuration config; - public EditReplacementChars() - { - InitializeComponent(); - dataGridView1_Resize(this, EventArgs.Empty); - } + InitializeComponent(); + dataGridView1_Resize(this, EventArgs.Empty); + } - public EditReplacementChars(Configuration config) : this() - { - this.config = config; - LoadTable(config.ReplacementCharacters.Replacements); - } + public EditReplacementChars(Configuration config) : this() + { + this.config = config; + LoadTable(config.ReplacementCharacters.Replacements); + } - private void LoadTable(IReadOnlyList<Replacement> replacements) + private void LoadTable(IReadOnlyList<Replacement> replacements) + { + dataGridView1.Rows.Clear(); + for (int i = 0; i < replacements.Count; i++) { - dataGridView1.Rows.Clear(); - for (int i = 0; i < replacements.Count; i++) + var r = replacements[i]; + + int row = dataGridView1.Rows.Add(r.CharacterToReplace.ToString(), r.ReplacementString, r.Description); + dataGridView1.Rows[row].Tag = r with { }; + + + if (r.Mandatory) { - var r = replacements[i]; - - int row = dataGridView1.Rows.Add(r.CharacterToReplace.ToString(), r.ReplacementString, r.Description); - dataGridView1.Rows[row].Tag = r with { }; - - - if (r.Mandatory) - { - dataGridView1.Rows[row].Cells[charToReplaceCol.Index].ReadOnly = true; - dataGridView1.Rows[row].Cells[descriptionCol.Index].ReadOnly = true; - dataGridView1.Rows[row].Cells[charToReplaceCol.Index].Style.BackColor = System.Drawing.Color.LightGray; - dataGridView1.Rows[row].Cells[descriptionCol.Index].Style.BackColor = System.Drawing.Color.LightGray; - } + dataGridView1.Rows[row].Cells[charToReplaceCol.Index].ReadOnly = true; + dataGridView1.Rows[row].Cells[descriptionCol.Index].ReadOnly = true; + dataGridView1.Rows[row].Cells[charToReplaceCol.Index].Style.BackColor = System.Drawing.Color.LightGray; + dataGridView1.Rows[row].Cells[descriptionCol.Index].Style.BackColor = System.Drawing.Color.LightGray; } } - - private void dataGridView1_UserDeletingRow(object sender, DataGridViewRowCancelEventArgs e) - { - if (e.Row?.Tag is Replacement r && r.Mandatory) - e.Cancel = true; - } - - private void loFiDefaultsBtn_Click(object sender, EventArgs e) - => LoadTable(ReplacementCharacters.LoFiDefault(ntfs: true).Replacements); - - private void defaultsBtn_Click(object sender, EventArgs e) - => LoadTable(ReplacementCharacters.Default(ntfs: true).Replacements); - - private void minDefaultBtn_Click(object sender, EventArgs e) - => LoadTable(ReplacementCharacters.Barebones(ntfs: true).Replacements); - - - private void dataGridView1_CellEndEdit(object sender, DataGridViewCellEventArgs e) - { - if (e.RowIndex < 0) return; - - dataGridView1.Rows[e.RowIndex].ErrorText = string.Empty; - - var charToReplaceStr = dataGridView1.Rows[e.RowIndex].Cells[charToReplaceCol.Index].Value?.ToString(); - var replacement = dataGridView1.Rows[e.RowIndex].Cells[replacementStringCol.Index].Value?.ToString() ?? string.Empty; - var description = dataGridView1.Rows[e.RowIndex].Cells[descriptionCol.Index].Value?.ToString() ?? string.Empty; - - //Validate the whole row. If it passes all validation, add or update the row's tag. - if (string.IsNullOrEmpty(charToReplaceStr) && replacement == string.Empty && description == string.Empty) - { - //Invalid entry, so delete row - var row = dataGridView1.Rows[e.RowIndex]; - if (!row.IsNewRow) - { - BeginInvoke(new MethodInvoker(delegate - { - dataGridView1.Rows.Remove(row); - })); - } - } - else if (string.IsNullOrEmpty(charToReplaceStr)) - { - dataGridView1.Rows[e.RowIndex].ErrorText = $"You must choose a character to replace"; - } - else if (charToReplaceStr.Length > 1) - { - dataGridView1.Rows[e.RowIndex].ErrorText = $"Only 1 {charToReplaceCol.HeaderText} per entry"; - } - else if (dataGridView1.Rows[e.RowIndex].Tag is Replacement repl && !repl.Mandatory && - dataGridView1.Rows - .Cast<DataGridViewRow>() - .Where(r => r.Index != e.RowIndex) - .Select(r => r.Tag) - .OfType<Replacement>() - .Any(r => r.CharacterToReplace == charToReplaceStr[0]) - ) - { - dataGridView1.Rows[e.RowIndex].ErrorText = $"The {charToReplaceStr[0]} character is already being replaced"; - } - else if (ReplacementCharacters.ContainsInvalidFilenameChar(replacement)) - { - dataGridView1.Rows[e.RowIndex].ErrorText = $"Your {replacementStringCol.HeaderText} contains illegal characters"; - } - else - { - //valid entry. Add or update Replacement in row's Tag - var charToReplace = charToReplaceStr[0]; - - if (dataGridView1.Rows[e.RowIndex].Tag is Replacement existing) - existing.Update(charToReplace, replacement, description); - else - dataGridView1.Rows[e.RowIndex].Tag = new Replacement(charToReplace, replacement, description); - } - } - - private void saveBtn_Click(object sender, EventArgs e) - { - var replacements = dataGridView1.Rows - .Cast<DataGridViewRow>() - .Select(r => r.Tag) - .OfType<Replacement>() - .ToList(); - - config.ReplacementCharacters = new ReplacementCharacters { Replacements = replacements }; - DialogResult = DialogResult.OK; - Close(); - } - - private void cancelBtn_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.Cancel; - Close(); - } - - private void dataGridView1_Resize(object sender, EventArgs e) - { - dataGridView1.Columns[^1].Width = dataGridView1.Width - dataGridView1.Columns.Cast<DataGridViewColumn>().Sum(c => c == dataGridView1.Columns[^1] ? 0 : c.Width) - dataGridView1.RowHeadersWidth - 2; - } + } + + private void dataGridView1_UserDeletingRow(object sender, DataGridViewRowCancelEventArgs e) + { + if (e.Row?.Tag is Replacement r && r.Mandatory) + e.Cancel = true; + } + + private void loFiDefaultsBtn_Click(object sender, EventArgs e) + => LoadTable(ReplacementCharacters.LoFiDefault(ntfs: true).Replacements); + + private void defaultsBtn_Click(object sender, EventArgs e) + => LoadTable(ReplacementCharacters.Default(ntfs: true).Replacements); + + private void minDefaultBtn_Click(object sender, EventArgs e) + => LoadTable(ReplacementCharacters.Barebones(ntfs: true).Replacements); + + + private void dataGridView1_CellEndEdit(object sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0) return; + + dataGridView1.Rows[e.RowIndex].ErrorText = string.Empty; + + var charToReplaceStr = dataGridView1.Rows[e.RowIndex].Cells[charToReplaceCol.Index].Value?.ToString(); + var replacement = dataGridView1.Rows[e.RowIndex].Cells[replacementStringCol.Index].Value?.ToString() ?? string.Empty; + var description = dataGridView1.Rows[e.RowIndex].Cells[descriptionCol.Index].Value?.ToString() ?? string.Empty; + + //Validate the whole row. If it passes all validation, add or update the row's tag. + if (string.IsNullOrEmpty(charToReplaceStr) && replacement == string.Empty && description == string.Empty) + { + //Invalid entry, so delete row + var row = dataGridView1.Rows[e.RowIndex]; + if (!row.IsNewRow) + { + BeginInvoke(new MethodInvoker(delegate + { + dataGridView1.Rows.Remove(row); + })); + } + } + else if (string.IsNullOrEmpty(charToReplaceStr)) + { + dataGridView1.Rows[e.RowIndex].ErrorText = $"You must choose a character to replace"; + } + else if (charToReplaceStr.Length > 1) + { + dataGridView1.Rows[e.RowIndex].ErrorText = $"Only 1 {charToReplaceCol.HeaderText} per entry"; + } + else if (dataGridView1.Rows[e.RowIndex].Tag is Replacement repl && !repl.Mandatory && + dataGridView1.Rows + .Cast<DataGridViewRow>() + .Where(r => r.Index != e.RowIndex) + .Select(r => r.Tag) + .OfType<Replacement>() + .Any(r => r.CharacterToReplace == charToReplaceStr[0]) + ) + { + dataGridView1.Rows[e.RowIndex].ErrorText = $"The {charToReplaceStr[0]} character is already being replaced"; + } + else if (ReplacementCharacters.ContainsInvalidFilenameChar(replacement)) + { + dataGridView1.Rows[e.RowIndex].ErrorText = $"Your {replacementStringCol.HeaderText} contains illegal characters"; + } + else + { + //valid entry. Add or update Replacement in row's Tag + var charToReplace = charToReplaceStr[0]; + + if (dataGridView1.Rows[e.RowIndex].Tag is Replacement existing) + existing.Update(charToReplace, replacement, description); + else + dataGridView1.Rows[e.RowIndex].Tag = new Replacement(charToReplace, replacement, description); + } + } + + private void saveBtn_Click(object sender, EventArgs e) + { + var replacements = dataGridView1.Rows + .Cast<DataGridViewRow>() + .Select(r => r.Tag) + .OfType<Replacement>() + .ToList(); + + config?.ReplacementCharacters = new ReplacementCharacters { Replacements = replacements }; + DialogResult = DialogResult.OK; + Close(); + } + + private void cancelBtn_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.Cancel; + Close(); + } + + private void dataGridView1_Resize(object sender, EventArgs e) + { + dataGridView1.Columns[^1].Width = dataGridView1.Width - dataGridView1.Columns.Cast<DataGridViewColumn>().Sum(c => c == dataGridView1.Columns[^1] ? 0 : c.Width) - dataGridView1.RowHeadersWidth - 2; } } diff --git a/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs b/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs index c7a50db2..3f9df279 100644 --- a/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs +++ b/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs @@ -6,156 +6,157 @@ using Dinah.Core; using LibationFileManager; using LibationFileManager.Templates; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class EditTemplateDialog : Form { - public partial class EditTemplateDialog : Form + private void resetTextBox(string? value) => this.templateTb.Text = value; + private Configuration config { get; } = Configuration.Instance; + private ITemplateEditor? templateEditor { get; } + + public EditTemplateDialog() { - private void resetTextBox(string value) => this.templateTb.Text = value; - private Configuration config { get; } = Configuration.Instance; - private ITemplateEditor templateEditor { get; } + InitializeComponent(); + this.SetLibationIcon(); + } - public EditTemplateDialog() + public EditTemplateDialog(ITemplateEditor templateEditor) : this() + { + this.templateEditor = ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor)); + } + + private void EditTemplateDialog_Load(object sender, EventArgs e) + { + if (this.DesignMode) + return; + + if (templateEditor is null) { - InitializeComponent(); - this.SetLibationIcon(); + MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(templateEditor)} is null")); + return; } - public EditTemplateDialog(ITemplateEditor templateEditor) : this() + warningsLbl.Text = ""; + + this.Text = $"Edit {templateEditor.TemplateName}"; + + this.templateLbl.Text = templateEditor.TemplateDescription; + resetTextBox(templateEditor.EditingTemplate.TemplateText); + + // populate list view + foreach (TemplateTags tag in templateEditor.EditingTemplate.TagsRegistered) + listView1.Items.Add(new ListViewItem(new[] { tag.Display, tag.Description }) { Tag = tag.DefaultValue }); + + listView1.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); + } + + private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(templateEditor?.DefaultTemplate); + + private void templateTb_TextChanged(object sender, EventArgs e) + { + if (templateEditor is null) + return; + templateEditor.SetTemplateText(templateTb.Text); + + const char ZERO_WIDTH_SPACE = '\u200B'; + var sing = $"{Path.DirectorySeparatorChar}"; + + // result: can wrap long paths. eg: + // |-- LINE WRAP BOUNDARIES --| + // \books\author with a very <= normal line break on space between words + // long name\narrator narrator + // \title <= line break on the zero-with space we added before slashes + string? slashWrap(string? val) => val?.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); + + warningsLbl.Text + = !templateEditor.EditingTemplate.HasWarnings + ? "" + : "Warning:\r\n" + + templateEditor + .EditingTemplate + .Warnings + .Select(err => $"- {err}") + .Aggregate((a, b) => $"{a}\r\n{b}"); + + var bold = new System.Drawing.Font(richTextBox1.Font, System.Drawing.FontStyle.Bold); + var reg = new System.Drawing.Font(richTextBox1.Font, System.Drawing.FontStyle.Regular); + + richTextBox1.Clear(); + richTextBox1.SelectionFont = reg; + + if (!templateEditor.IsFilePath) { - this.templateEditor = ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor)); + richTextBox1.SelectionFont = bold; + richTextBox1.AppendText(templateEditor.GetName()); + return; } - private void EditTemplateDialog_Load(object sender, EventArgs e) - { - if (this.DesignMode) - return; + var folder = templateEditor.GetFolderName(); + var file = templateEditor.GetFileName(); + var ext = config.DecryptToLossy ? "mp3" : "m4b"; - if (templateEditor is null) - { - MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(templateEditor)} is null")); - return; - } + richTextBox1.AppendText(slashWrap(templateEditor.BaseDirectory.PathWithoutPrefix)); + richTextBox1.AppendText(sing); - warningsLbl.Text = ""; + if (templateEditor.IsFolder) + richTextBox1.SelectionFont = bold; - this.Text = $"Edit {templateEditor.TemplateName}"; + richTextBox1.AppendText(slashWrap(folder)); - this.templateLbl.Text = templateEditor.TemplateDescription; - resetTextBox(templateEditor.EditingTemplate.TemplateText); - - // populate list view - foreach (TemplateTags tag in templateEditor.EditingTemplate.TagsRegistered) - listView1.Items.Add(new ListViewItem(new[] { tag.Display, tag.Description }) { Tag = tag.DefaultValue }); - - listView1.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); - } - - private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(templateEditor.DefaultTemplate); - - private void templateTb_TextChanged(object sender, EventArgs e) - { - templateEditor.SetTemplateText(templateTb.Text); - - const char ZERO_WIDTH_SPACE = '\u200B'; - var sing = $"{Path.DirectorySeparatorChar}"; - - // result: can wrap long paths. eg: - // |-- LINE WRAP BOUNDARIES --| - // \books\author with a very <= normal line break on space between words - // long name\narrator narrator - // \title <= line break on the zero-with space we added before slashes - string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); - - warningsLbl.Text - = !templateEditor.EditingTemplate.HasWarnings - ? "" - : "Warning:\r\n" + - templateEditor - .EditingTemplate - .Warnings - .Select(err => $"- {err}") - .Aggregate((a, b) => $"{a}\r\n{b}"); - - var bold = new System.Drawing.Font(richTextBox1.Font, System.Drawing.FontStyle.Bold); - var reg = new System.Drawing.Font(richTextBox1.Font, System.Drawing.FontStyle.Regular); - - richTextBox1.Clear(); + if (templateEditor.IsFolder) richTextBox1.SelectionFont = reg; - if (!templateEditor.IsFilePath) - { - richTextBox1.SelectionFont = bold; - richTextBox1.AppendText(templateEditor.GetName()); - return; - } + richTextBox1.AppendText(sing); - var folder = templateEditor.GetFolderName(); - var file = templateEditor.GetFileName(); - var ext = config.DecryptToLossy ? "mp3" : "m4b"; + if (templateEditor.IsFilePath && !templateEditor.IsFolder) + richTextBox1.SelectionFont = bold; - richTextBox1.AppendText(slashWrap(templateEditor.BaseDirectory.PathWithoutPrefix)); - richTextBox1.AppendText(sing); + richTextBox1.AppendText(file); - if (templateEditor.IsFolder) - richTextBox1.SelectionFont = bold; + richTextBox1.SelectionFont = reg; + richTextBox1.AppendText($".{ext}"); + } - richTextBox1.AppendText(slashWrap(folder)); - - if (templateEditor.IsFolder) - richTextBox1.SelectionFont = reg; - - richTextBox1.AppendText(sing); - - if (templateEditor.IsFilePath && !templateEditor.IsFolder) - richTextBox1.SelectionFont = bold; - - richTextBox1.AppendText(file); - - richTextBox1.SelectionFont = reg; - richTextBox1.AppendText($".{ext}"); - } - - private void saveBtn_Click(object sender, EventArgs e) + private void saveBtn_Click(object sender, EventArgs e) + { + if (templateEditor?.EditingTemplate.IsValid is true) { - if (!templateEditor.EditingTemplate.IsValid) - { - var errors = templateEditor - .EditingTemplate - .Errors - .Select(err => $"- {err}") - .Aggregate((a, b) => $"{a}\r\n{b}"); - MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - - this.DialogResult = DialogResult.OK; - this.Close(); + var errors = templateEditor + .EditingTemplate + .Errors + .Select(err => $"- {err}") + .Aggregate((a, b) => $"{a}\r\n{b}"); + MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; } - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); - } + this.DialogResult = DialogResult.OK; + this.Close(); + } - private void listView1_DoubleClick(object sender, EventArgs e) - { - var itemText = listView1.SelectedItems[0].Tag as string; + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); + } - if (string.IsNullOrEmpty(itemText)) return; + private void listView1_DoubleClick(object sender, EventArgs e) + { + var itemText = listView1.SelectedItems[0].Tag as string; - var text = templateTb.Text; - var selStart = Math.Min(Math.Max(0, templateTb.SelectionStart), text.Length); + if (string.IsNullOrEmpty(itemText)) return; - templateTb.Text = text.Insert(selStart, itemText); - templateTb.SelectionStart = selStart + itemText.Length; - templateTb.Focus(); - } + var text = templateTb.Text; + var selStart = Math.Min(Math.Max(0, templateTb.SelectionStart), text.Length); - private void llblGoToWiki_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - Go.To.Url(@"ht" + "tps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md"); - e.Link.Visited = true; - } + templateTb.Text = text.Insert(selStart, itemText); + templateTb.SelectionStart = selStart + itemText.Length; + templateTb.Focus(); + } + + private void llblGoToWiki_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + Go.To.Url(@"ht" + "tps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md"); + e.Link?.Visited = true; } } diff --git a/Source/LibationWinForms/Dialogs/FindBetterQualityBooksDialog.cs b/Source/LibationWinForms/Dialogs/FindBetterQualityBooksDialog.cs index 4862fd04..9592ce9d 100644 --- a/Source/LibationWinForms/Dialogs/FindBetterQualityBooksDialog.cs +++ b/Source/LibationWinForms/Dialogs/FindBetterQualityBooksDialog.cs @@ -1,6 +1,5 @@ using ApplicationServices; using LibationUiBase; -using LibationUiBase.ProcessQueue; using System; using System.Data; using System.Linq; @@ -8,7 +7,6 @@ using System.Reflection; using System.Threading.Tasks; using System.Windows.Forms; -#nullable enable namespace LibationWinForms.Dialogs; public partial class FindBetterQualityBooksDialog : Form diff --git a/Source/LibationWinForms/Dialogs/LibationFilesDialog.cs b/Source/LibationWinForms/Dialogs/LibationFilesDialog.cs index c20d02fa..4cf676f2 100644 --- a/Source/LibationWinForms/Dialogs/LibationFilesDialog.cs +++ b/Source/LibationWinForms/Dialogs/LibationFilesDialog.cs @@ -3,58 +3,57 @@ using LibationUiBase; using System; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class LibationFilesDialog : Form, ILibationInstallLocation { - public partial class LibationFilesDialog : Form, ILibationInstallLocation + public string? SelectedDirectory { get; private set; } + + public LibationFilesDialog() => InitializeComponent(); + + private void LibationFilesDialog_Load(object sender, EventArgs e) { - public string SelectedDirectory { get; private set; } + if (this.DesignMode) + return; - public LibationFilesDialog() => InitializeComponent(); + var config = Configuration.Instance; - private void LibationFilesDialog_Load(object sender, EventArgs e) + libationFilesDescLbl.Text = Configuration.GetDescription(nameof(config.LibationFiles)); + + libationFilesSelectControl.SetSearchTitle("Libation Files"); + libationFilesSelectControl.SetDirectoryItems(new() { - if (this.DesignMode) - return; + Configuration.KnownDirectories.UserProfile, + Configuration.KnownDirectories.AppDir, + Configuration.KnownDirectories.MyDocs + }, Configuration.KnownDirectories.UserProfile); - var config = Configuration.Instance; + var selectedDir = System.IO.Directory.Exists(Configuration.Instance.LibationFiles.Location.PathWithoutPrefix) + ? Configuration.Instance.LibationFiles.Location.PathWithoutPrefix + : Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile); - libationFilesDescLbl.Text = Configuration.GetDescription(nameof(config.LibationFiles)); + libationFilesSelectControl.SelectDirectory(selectedDir); + } - libationFilesSelectControl.SetSearchTitle("Libation Files"); - libationFilesSelectControl.SetDirectoryItems(new() - { - Configuration.KnownDirectories.UserProfile, - Configuration.KnownDirectories.AppDir, - Configuration.KnownDirectories.MyDocs - }, Configuration.KnownDirectories.UserProfile); + private void saveBtn_Click(object sender, EventArgs e) + { + var libationDir = libationFilesSelectControl.SelectedDirectory; - var selectedDir = System.IO.Directory.Exists(Configuration.Instance.LibationFiles.Location.PathWithoutPrefix) - ? Configuration.Instance.LibationFiles.Location.PathWithoutPrefix - : Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile); - - libationFilesSelectControl.SelectDirectory(selectedDir); + if (!System.IO.Directory.Exists(libationDir)) + { + MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + libationDir, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; } - private void saveBtn_Click(object sender, EventArgs e) - { - var libationDir = libationFilesSelectControl.SelectedDirectory; + SelectedDirectory = libationDir; - if (!System.IO.Directory.Exists(libationDir)) - { - MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + libationDir, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } + this.DialogResult = DialogResult.OK; + this.Close(); + } - SelectedDirectory = libationDir; - - this.DialogResult = DialogResult.OK; - this.Close(); - } - - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); - } + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); } } diff --git a/Source/LibationWinForms/Dialogs/LiberatedStatusBatchAutoDialog.cs b/Source/LibationWinForms/Dialogs/LiberatedStatusBatchAutoDialog.cs index 925a25ab..d186d4fd 100644 --- a/Source/LibationWinForms/Dialogs/LiberatedStatusBatchAutoDialog.cs +++ b/Source/LibationWinForms/Dialogs/LiberatedStatusBatchAutoDialog.cs @@ -1,25 +1,24 @@ using System; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class LiberatedStatusBatchAutoDialog : Form { - public partial class LiberatedStatusBatchAutoDialog : Form - { - public bool SetDownloaded { get; private set; } - public bool SetNotDownloaded { get; private set; } + public bool SetDownloaded { get; private set; } + public bool SetNotDownloaded { get; private set; } - public LiberatedStatusBatchAutoDialog() - { - InitializeComponent(); - this.SetLibationIcon(); - } + public LiberatedStatusBatchAutoDialog() + { + InitializeComponent(); + this.SetLibationIcon(); + } - private void okBtn_Click(object sender, EventArgs e) - { - SetDownloaded = this.setDownloadedCb.Checked; - SetNotDownloaded = this.setNotDownloadedCb.Checked; + private void okBtn_Click(object sender, EventArgs e) + { + SetDownloaded = this.setDownloadedCb.Checked; + SetNotDownloaded = this.setNotDownloadedCb.Checked; - this.DialogResult = DialogResult.OK; - } - } + this.DialogResult = DialogResult.OK; + } } diff --git a/Source/LibationWinForms/Dialogs/LiberatedStatusBatchManualDialog.cs b/Source/LibationWinForms/Dialogs/LiberatedStatusBatchManualDialog.cs index b4bd3eec..b0c4a5c3 100644 --- a/Source/LibationWinForms/Dialogs/LiberatedStatusBatchManualDialog.cs +++ b/Source/LibationWinForms/Dialogs/LiberatedStatusBatchManualDialog.cs @@ -11,8 +11,8 @@ namespace LibationWinForms.Dialogs public class liberatedComboBoxItem { public LiberatedStatus Status { get; set; } - public string Text { get; set; } - public override string ToString() => Text; + public string? Text { get; set; } + public override string? ToString() => Text; } public LiberatedStatusBatchManualDialog(bool isPdf) : this() @@ -34,7 +34,8 @@ namespace LibationWinForms.Dialogs private void saveBtn_Click(object sender, EventArgs e) { - BookLiberatedStatus = ((liberatedComboBoxItem)this.bookLiberatedCb.SelectedItem).Status; + if (bookLiberatedCb.SelectedItem is liberatedComboBoxItem item) + BookLiberatedStatus = item.Status; this.DialogResult = DialogResult.OK; } } diff --git a/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs index 70994eb7..16eb59bb 100644 --- a/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs +++ b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs @@ -7,62 +7,61 @@ using System.IO; using System.Threading; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class LocateAudiobooksDialog : Form { - public partial class LocateAudiobooksDialog : Form + private readonly CancellationTokenSource tokenSource = new(); + private readonly LocatedAudiobooksViewModel _viewModel; + public LocateAudiobooksDialog() { - private readonly CancellationTokenSource tokenSource = new(); - private readonly LocatedAudiobooksViewModel _viewModel; - public LocateAudiobooksDialog() + InitializeComponent(); + + this.SetLibationIcon(); + this.RestoreSizeAndLocation(Configuration.Instance); + + _viewModel = new LocatedAudiobooksViewModel(new SortBindingList<FoundAudiobook>()); + dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; + dataGridView1.RowsAdded += DataGridView1_RowsAdded; + foundAudiobookBindingSource.DataSource = _viewModel.FoundFiles; + booksFoundLbl.DataBindings.Add(new Binding(nameof(booksFoundLbl.Text), _viewModel, nameof(_viewModel.FoundAsinCount), true, DataSourceUpdateMode.OnPropertyChanged, 0, booksFoundLbl.Text)); + } + + private void DataGridView1_RowsAdded(object? sender, DataGridViewRowsAddedEventArgs e) + { + dataGridView1.FirstDisplayedScrollingRowIndex = e.RowIndex; + } + + private void LocateAudiobooks_FormClosing(object sender, FormClosingEventArgs e) + { + tokenSource.Cancel(); + this.SaveSizeAndLocation(Configuration.Instance); + } + + private async void LocateAudiobooks_Shown(object sender, EventArgs e) + { + var fbd = new FolderBrowserDialog { - InitializeComponent(); + Description = "Select the folder to search for audiobooks", + UseDescriptionForTitle = true, + InitialDirectory = Configuration.Instance.Books?.Path ?? string.Empty + }; - this.SetLibationIcon(); - this.RestoreSizeAndLocation(Configuration.Instance); - - _viewModel = new LocatedAudiobooksViewModel(new SortBindingList<FoundAudiobook>()); - dataGridView1.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - dataGridView1.RowsAdded += DataGridView1_RowsAdded; - foundAudiobookBindingSource.DataSource = _viewModel.FoundFiles; - booksFoundLbl.DataBindings.Add(new Binding(nameof(booksFoundLbl.Text), _viewModel, nameof(_viewModel.FoundAsinCount), true, DataSourceUpdateMode.OnPropertyChanged, 0, booksFoundLbl.Text)); + var result = fbd.ShowDialog(this); + if (result != DialogResult.OK || !Directory.Exists(fbd.SelectedPath)) + { + DialogResult = result; } - - private void DataGridView1_RowsAdded(object sender, DataGridViewRowsAddedEventArgs e) + else { - dataGridView1.FirstDisplayedScrollingRowIndex = e.RowIndex; - } - - private void LocateAudiobooks_FormClosing(object sender, FormClosingEventArgs e) - { - tokenSource.Cancel(); - this.SaveSizeAndLocation(Configuration.Instance); - } - - private async void LocateAudiobooks_Shown(object sender, EventArgs e) - { - var fbd = new FolderBrowserDialog - { - Description = "Select the folder to search for audiobooks", - UseDescriptionForTitle = true, - InitialDirectory = Configuration.Instance.Books - }; - - var result = fbd.ShowDialog(this); - if (result != DialogResult.OK || !Directory.Exists(fbd.SelectedPath)) - { - DialogResult = result; - } - else - { - await _viewModel.FindAndAddBooksAsync(fbd.SelectedPath, tokenSource.Token); - MessageBox.Show(this, $"Libation has found {_viewModel.FoundAsinCount} unique audiobooks and added them to its database. ", $"Found {_viewModel.FoundAsinCount} Audiobooks"); - } - } - - private void dataGridView1_CellDoubleClick(object sender, DataGridViewCellEventArgs e) - { - if (e.RowIndex >= 0 && e.RowIndex < _viewModel.FoundFiles.Count) - Go.To.File(_viewModel.FoundFiles[e.RowIndex].Entry.Path); + await _viewModel.FindAndAddBooksAsync(fbd.SelectedPath, tokenSource.Token); + MessageBox.Show(this, $"Libation has found {_viewModel.FoundAsinCount} unique audiobooks and added them to its database. ", $"Found {_viewModel.FoundAsinCount} Audiobooks"); } } + + private void dataGridView1_CellDoubleClick(object sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex >= 0 && e.RowIndex < _viewModel.FoundFiles.Count) + Go.To.File(_viewModel.FoundFiles[e.RowIndex].Entry.Path); + } } diff --git a/Source/LibationWinForms/Dialogs/Login/LoginExternalDialog.cs b/Source/LibationWinForms/Dialogs/Login/LoginExternalDialog.cs index 377589df..b36c5a0b 100644 --- a/Source/LibationWinForms/Dialogs/Login/LoginExternalDialog.cs +++ b/Source/LibationWinForms/Dialogs/Login/LoginExternalDialog.cs @@ -3,40 +3,39 @@ using System.Windows.Forms; using AudibleUtilities; using Dinah.Core; -namespace LibationWinForms.Dialogs.Login +namespace LibationWinForms.Dialogs.Login; + +public partial class LoginExternalDialog : Form { - public partial class LoginExternalDialog : Form + public string? ResponseUrl { get; private set; } + + public LoginExternalDialog(Account account, string loginUrl) { - public string ResponseUrl { get; private set; } + InitializeComponent(); - public LoginExternalDialog(Account account, string loginUrl) + // do not allow user to change login id here. if they do then jsonpath will fail + this.localeLbl.Text = string.Format(this.localeLbl.Text, account.Locale?.Name); + this.usernameLbl.Text = string.Format(this.usernameLbl.Text, account.AccountId); + + this.loginUrlTb.Text = loginUrl; + } + + private void copyBtn_Click(object sender, EventArgs e) => Clipboard.SetText(this.loginUrlTb.Text); + + private void launchBrowserBtn_Click(object sender, EventArgs e) => Go.To.Url(this.loginUrlTb.Text); + + private void submitBtn_Click(object sender, EventArgs e) + { + ResponseUrl = this.responseUrlTb.Text?.Trim(); + + Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { ResponseUrl }); + if (!Uri.TryCreate(ResponseUrl, UriKind.Absolute, out var result)) { - InitializeComponent(); - - // do not allow user to change login id here. if they do then jsonpath will fail - this.localeLbl.Text = string.Format(this.localeLbl.Text, account.Locale.Name); - this.usernameLbl.Text = string.Format(this.usernameLbl.Text, account.AccountId); - - this.loginUrlTb.Text = loginUrl; + MessageBox.Show("Invalid response URL"); + return; } - private void copyBtn_Click(object sender, EventArgs e) => Clipboard.SetText(this.loginUrlTb.Text); - - private void launchBrowserBtn_Click(object sender, EventArgs e) => Go.To.Url(this.loginUrlTb.Text); - - private void submitBtn_Click(object sender, EventArgs e) - { - ResponseUrl = this.responseUrlTb.Text?.Trim(); - - Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { ResponseUrl }); - if (!Uri.TryCreate(ResponseUrl, UriKind.Absolute, out var result)) - { - MessageBox.Show("Invalid response URL"); - return; - } - - DialogResult = DialogResult.OK; - // Close() not needed for AcceptButton - } + DialogResult = DialogResult.OK; + // Close() not needed for AcceptButton } } diff --git a/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs index c5b45c7c..e6c6b4e8 100644 --- a/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs +++ b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs @@ -5,82 +5,81 @@ using Microsoft.Web.WebView2.WinForms; using System; using System.Windows.Forms; -namespace LibationWinForms.Login +namespace LibationWinForms.Login; + +public partial class WebLoginDialog : Form { - public partial class WebLoginDialog : Form + public string? ResponseUrl { get; private set; } + private readonly string? accountID; + private readonly WebView2 webView; + public WebLoginDialog() { - public string ResponseUrl { get; private set; } - private readonly string accountID; - private readonly WebView2 webView; - public WebLoginDialog() + InitializeComponent(); + webView = new WebView2(); + + webView.Dock = DockStyle.Fill; + Controls.Add(webView); + + webView.NavigationStarting += WebView_NavigationStarting; + this.SetLibationIcon(); + } + + public WebLoginDialog(string accountID, ChoiceIn choiceIn) : this() + { + this.accountID = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountID, nameof(accountID)); + ArgumentValidator.EnsureNotNullOrWhiteSpace(choiceIn?.LoginUrl, nameof(choiceIn)); + this.Load += async (_, _) => { - InitializeComponent(); - webView = new WebView2(); + //enable private browsing + var env = await CoreWebView2Environment.CreateAsync(); + var options = env.CreateCoreWebView2ControllerOptions(); + options.IsInPrivateModeEnabled = true; + await webView.EnsureCoreWebView2Async(env, options); - webView.Dock = DockStyle.Fill; - Controls.Add(webView); + webView.CoreWebView2.Settings.UserAgent = Resources.User_Agent; - webView.NavigationStarting += WebView_NavigationStarting; - this.SetLibationIcon(); - } - - public WebLoginDialog(string accountID, ChoiceIn choiceIn) : this() - { - this.accountID = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountID, nameof(accountID)); - ArgumentValidator.EnsureNotNullOrWhiteSpace(choiceIn?.LoginUrl, nameof(choiceIn)); - this.Load += async (_, _) => + //Load init cookies + foreach (System.Net.Cookie cookie in choiceIn.SignInCookies ?? []) { - //enable private browsing - var env = await CoreWebView2Environment.CreateAsync(); - var options = env.CreateCoreWebView2ControllerOptions(); - options.IsInPrivateModeEnabled = true; - await webView.EnsureCoreWebView2Async(env, options); - - webView.CoreWebView2.Settings.UserAgent = Resources.User_Agent; - - //Load init cookies - foreach (System.Net.Cookie cookie in choiceIn.SignInCookies ?? []) + try { - try - { - webView.CoreWebView2.CookieManager.AddOrUpdateCookie(webView.CoreWebView2.CookieManager.CreateCookieWithSystemNetCookie(cookie)); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"Failed to set cookie {cookie.Name} for domain {cookie.Domain}"); - } + webView.CoreWebView2.CookieManager.AddOrUpdateCookie(webView.CoreWebView2.CookieManager.CreateCookieWithSystemNetCookie(cookie)); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, $"Failed to set cookie {cookie.Name} for domain {cookie.Domain}"); } - - webView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded; - Invoke(() => webView.Source = new Uri(choiceIn.LoginUrl)); - }; - } - - private void WebView_NavigationStarting(object sender, Microsoft.Web.WebView2.Core.CoreWebView2NavigationStartingEventArgs e) - { - if (e.Uri.Contains("/ap/maplanding") is true) - { - ResponseUrl = e.Uri; - DialogResult = DialogResult.OK; - Close(); } - } - private async void CoreWebView2_DOMContentLoaded(object sender, CoreWebView2DOMContentLoadedEventArgs e) - { - await webView.ExecuteScriptAsync(getScript(accountID)); - } - - private static string getScript(string accountID) => $$""" - (function() { - var email = document.querySelector("input[id='ap_email_login']"); - if (email !== null) - email.value = '{{accountID}}'; - - var pass = document.querySelector("input[name='password']"); - if (pass !== null) - pass.focus(); - })() - """; + webView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded; + Invoke(() => webView.Source = new Uri(choiceIn.LoginUrl)); + }; } + + private void WebView_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e) + { + if (e.Uri.Contains("/ap/maplanding") is true) + { + ResponseUrl = e.Uri; + DialogResult = DialogResult.OK; + Close(); + } + } + + private async void CoreWebView2_DOMContentLoaded(object? sender, CoreWebView2DOMContentLoadedEventArgs e) + { + await webView.ExecuteScriptAsync(getScript(accountID)); + } + + private static string getScript(string? accountID) => $$""" + (function() { + var email = document.querySelector("input[id='ap_email_login']"); + if (email !== null) + email.value = '{{accountID}}'; + + var pass = document.querySelector("input[name='password']"); + if (pass !== null) + pass.focus(); + })() + """; } diff --git a/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs b/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs index a481a4db..2c07e51d 100644 --- a/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs +++ b/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs @@ -1,23 +1,18 @@ -using System; -using System.Threading.Tasks; -using System.Windows.Forms; +using System.Threading.Tasks; using AudibleApi; -using AudibleUtilities; -using LibationWinForms.Dialogs.Login; -namespace LibationWinForms.Login +namespace LibationWinForms.Login; + +public class WinformLoginCallback : ILoginCallback { - public class WinformLoginCallback : ILoginCallback - { - public string DeviceName { get; } = "Libation"; + public string DeviceName { get; } = "Libation"; - public Task<string> Get2faCodeAsync(string prompt) => throw new System.NotSupportedException(); - public Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage) - => throw new System.NotSupportedException(); - public Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig) - => throw new System.NotSupportedException(); - public Task<(string email, string password)> GetLoginAsync() - => throw new System.NotSupportedException(); - public Task ShowApprovalNeededAsync() => throw new System.NotSupportedException(); - } + public Task<string> Get2faCodeAsync(string prompt) => throw new System.NotSupportedException(); + public Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage) + => throw new System.NotSupportedException(); + public Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig) + => throw new System.NotSupportedException(); + public Task<(string email, string password)> GetLoginAsync() + => throw new System.NotSupportedException(); + public Task ShowApprovalNeededAsync() => throw new System.NotSupportedException(); } \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs index 80842dbb..8483e2cd 100644 --- a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs +++ b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs @@ -5,53 +5,52 @@ using AudibleApi; using AudibleUtilities; using LibationWinForms.Dialogs.Login; -namespace LibationWinForms.Login +namespace LibationWinForms.Login; + +public class WinformLoginChoiceEager : ILoginChoiceEager { - public class WinformLoginChoiceEager : ILoginChoiceEager + public ILoginCallback LoginCallback { get; } = new WinformLoginCallback(); + + private Account _account { get; } + private Control Owner { get; } + public WinformLoginChoiceEager(Account account, Control owner) { - public ILoginCallback LoginCallback { get; } = new WinformLoginCallback(); - - private Account _account { get; } - private Control Owner { get; } - public WinformLoginChoiceEager(Account account, Control owner) - { - _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account)); - Owner = Dinah.Core.ArgumentValidator.EnsureNotNull(owner, nameof(owner)); - } - - public Task<ChoiceOut> StartAsync(ChoiceIn choiceIn) - => Owner.Invoke(() => StartAsyncInternal(choiceIn)); - - private Task<ChoiceOut> StartAsyncInternal(ChoiceIn choiceIn) - { - if (Environment.OSVersion.Version.Major >= 10) - { - try - { - using var weblogin = new WebLoginDialog(_account.AccountId, choiceIn); - if (ShowDialog(weblogin)) - return Task.FromResult(ChoiceOut.External(weblogin.ResponseUrl)); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"Failed to run {nameof(WebLoginDialog)}"); - } - } - - using var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl); - return Task.FromResult( - ShowDialog(externalDialog) - ? ChoiceOut.External(externalDialog.ResponseUrl) - : null); - } - - /// <returns>True if ShowDialog's DialogResult == OK</returns> - private bool ShowDialog(Form dialog) - => Owner.Invoke(() => - { - var result = dialog.ShowDialog(Owner); - Serilog.Log.Logger.Debug("{@DebugInfo}", new { DialogResult = result }); - return result == DialogResult.OK; - }); + _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account)); + Owner = Dinah.Core.ArgumentValidator.EnsureNotNull(owner, nameof(owner)); } + + public Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn) + => Owner.Invoke(() => StartAsyncInternal(choiceIn)); + + private Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn) + { + if (Environment.OSVersion.Version.Major >= 10) + { + try + { + using var weblogin = new WebLoginDialog(_account.AccountId, choiceIn); + if (ShowDialog(weblogin)) + return Task.FromResult((ChoiceOut?)ChoiceOut.External(weblogin.ResponseUrl)); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, $"Failed to run {nameof(WebLoginDialog)}"); + } + } + + using var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl); + return Task.FromResult( + ShowDialog(externalDialog) + ? ChoiceOut.External(externalDialog.ResponseUrl) + : null); + } + + /// <returns>True if ShowDialog's DialogResult == OK</returns> + private bool ShowDialog(Form dialog) + => Owner.Invoke(() => + { + var result = dialog.ShowDialog(Owner); + Serilog.Log.Logger.Debug("{@DebugInfo}", new { DialogResult = result }); + return result == DialogResult.OK; + }); } diff --git a/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs b/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs index 896da325..96b2aaf5 100644 --- a/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs +++ b/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs @@ -1,78 +1,76 @@ using Dinah.Core; using System; -using System.Linq; using System.Drawing; using System.Windows.Forms; using FileManager; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class MessageBoxAlertAdminDialog : Form { - public partial class MessageBoxAlertAdminDialog : Form + public MessageBoxAlertAdminDialog() => InitializeComponent(); + + /// <summary> + /// Displays a message box with specified text and caption. + /// </summary> + /// <param name="text">The text to display in the message box.</param> + /// <param name="caption">The text to display in the title bar of the message box.</param> + /// <param name="exception">Exception to display</param> + public MessageBoxAlertAdminDialog(string text, string caption, Exception exception) : this() { - public MessageBoxAlertAdminDialog() => InitializeComponent(); + this.descriptionLbl.Text = text; + this.Text = caption; + this.exceptionTb.Text = $"{exception.Message}\r\n\r\n{exception.StackTrace}"; + } - /// <summary> - /// Displays a message box with specified text and caption. - /// </summary> - /// <param name="text">The text to display in the message box.</param> - /// <param name="caption">The text to display in the title bar of the message box.</param> - /// <param name="exception">Exception to display</param> - public MessageBoxAlertAdminDialog(string text, string caption, Exception exception) : this() + private void MessageBoxAlertAdminDialog_Load(object sender, EventArgs e) + { + if (this.DesignMode) + return; + + System.Media.SystemSounds.Hand.Play(); + //This is a different (and newer) icon from SystemIcons.Error + pictureBox1.Image = SystemIcons.GetStockIcon(StockIconId.Error).ToBitmap(); + } + + private void githubLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + var url = "https://github.com/rmcrackan/Libation/issues"; + try { - this.descriptionLbl.Text = text; - this.Text = caption; - this.exceptionTb.Text = $"{exception.Message}\r\n\r\n{exception.StackTrace}"; + Go.To.Url(url); } - - private void MessageBoxAlertAdminDialog_Load(object sender, EventArgs e) + catch { - if (this.DesignMode) - return; - - System.Media.SystemSounds.Hand.Play(); - //This is a different (and newer) icon from SystemIcons.Error - pictureBox1.Image = SystemIcons.GetStockIcon(StockIconId.Error).ToBitmap(); - } - - private void githubLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - var url = "https://github.com/rmcrackan/Libation/issues"; - try - { - Go.To.Url(url); - } - catch - { - MessageBox.Show(this, $"Error opening url\r\n{url}", "Error opening url", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - private void logsLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - try - { - Go.To.File(LibationFileManager.LogFileFilter.LogFilePath); - } - catch - { - LongPath dir = ""; - try - { - dir = LibationFileManager.Configuration.Instance.LibationFiles.Location; - Go.To.Folder(dir.ShortPathName); - } - catch - { - MessageBox.Show(this, $"Error opening folder\r\n{dir}", "Error opening folder", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - } - - - private void okBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.OK; - this.Close(); + MessageBox.Show(this, $"Error opening url\r\n{url}", "Error opening url", MessageBoxButtons.OK, MessageBoxIcon.Error); } } + + private void logsLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + try + { + Go.To.File(LibationFileManager.LogFileFilter.LogFilePath); + } + catch + { + LongPath dir = ""; + try + { + dir = LibationFileManager.Configuration.Instance.LibationFiles.Location; + Go.To.Folder(dir.ShortPathName); + } + catch + { + MessageBox.Show(this, $"Error opening folder\r\n{dir}", "Error opening folder", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + + private void okBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.OK; + this.Close(); + } } diff --git a/Source/LibationWinForms/Dialogs/ScanAccountsDialog.cs b/Source/LibationWinForms/Dialogs/ScanAccountsDialog.cs index fc42d9b6..ec0a79f9 100644 --- a/Source/LibationWinForms/Dialogs/ScanAccountsDialog.cs +++ b/Source/LibationWinForms/Dialogs/ScanAccountsDialog.cs @@ -1,68 +1,69 @@ using System; -using System.Linq; using System.Collections.Generic; using System.Windows.Forms; using AudibleUtilities; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class ScanAccountsDialog : Form { - public partial class ScanAccountsDialog : Form + public List<Account> CheckedAccounts { get; } = new List<Account>(); + + public ScanAccountsDialog() { - public List<Account> CheckedAccounts { get; } = new List<Account>(); + InitializeComponent(); + this.SetLibationIcon(); + } - public ScanAccountsDialog() - { - InitializeComponent(); - this.SetLibationIcon(); - } + private class listItem + { + public Account? Account { get; set; } + public string? Text { get; set; } + public override string? ToString() => Text; + } + private void ScanAccountsDialog_Load(object sender, EventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings.Accounts; - private class listItem + foreach (var account in accounts) { - public Account Account { get; set; } - public string Text { get; set; } - public override string ToString() => Text; - } - private void ScanAccountsDialog_Load(object sender, EventArgs e) - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var accounts = persister.AccountsSettings.Accounts; - - foreach (var account in accounts) + var item = new listItem { - var item = new listItem - { - Account = account, - Text = $"{account.AccountName} ({account.AccountId} - {account.Locale.Name})" - }; - this.accountsClb.Items.Add(item, account.LibraryScan); - } - } - - private void editBtn_Click(object sender, EventArgs e) - { - if (new AccountsDialog().ShowDialog() == DialogResult.OK) - { - // clear grid - this.accountsClb.Items.Clear(); - - // reload grid and default checkboxes - ScanAccountsDialog_Load(sender, e); - } - } - - private void importBtn_Click(object sender, EventArgs e) - { - foreach (listItem item in accountsClb.CheckedItems) - CheckedAccounts.Add(item.Account); - - this.DialogResult = DialogResult.OK; - this.Close(); - } - - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); + Account = account, + Text = $"{account.AccountName} ({account.AccountId} - {account.Locale?.Name })" + }; + this.accountsClb.Items.Add(item, account.LibraryScan); } } + + private void editBtn_Click(object sender, EventArgs e) + { + if (new AccountsDialog().ShowDialog() == DialogResult.OK) + { + // clear grid + this.accountsClb.Items.Clear(); + + // reload grid and default checkboxes + ScanAccountsDialog_Load(sender, e); + } + } + + private void importBtn_Click(object sender, EventArgs e) + { + foreach (listItem item in accountsClb.CheckedItems) + { + if (item.Account != null) + CheckedAccounts.Add(item.Account); + } + + this.DialogResult = DialogResult.OK; + this.Close(); + } + + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); + } } diff --git a/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs b/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs index 82479014..fe3226fe 100644 --- a/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs +++ b/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs @@ -3,34 +3,33 @@ using System; using System.Linq; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class SearchSyntaxDialog : Form { - public partial class SearchSyntaxDialog : Form + public event EventHandler<string>? TagDoubleClicked; + public SearchSyntaxDialog() { - public event EventHandler<string> TagDoubleClicked; - public SearchSyntaxDialog() - { - InitializeComponent(); + InitializeComponent(); - lboxNumberFields.Items.AddRange(SearchEngine.FieldIndexRules.NumberFieldNames.ToArray()); - lboxStringFields.Items.AddRange(SearchEngine.FieldIndexRules.StringFieldNames.ToArray()); - lboxBoolFields.Items.AddRange(SearchEngine.FieldIndexRules.BoolFieldNames.ToArray()); - lboxIdFields.Items.AddRange(SearchEngine.FieldIndexRules.IdFieldNames.ToArray()); - this.SetLibationIcon(); - this.RestoreSizeAndLocation(LibationFileManager.Configuration.Instance); - } - protected override void OnFormClosing(FormClosingEventArgs e) - { - base.OnFormClosing(e); - this.SaveSizeAndLocation(LibationFileManager.Configuration.Instance); - } + lboxNumberFields.Items.AddRange(SearchEngine.FieldIndexRules.NumberFieldNames.ToArray()); + lboxStringFields.Items.AddRange(SearchEngine.FieldIndexRules.StringFieldNames.ToArray()); + lboxBoolFields.Items.AddRange(SearchEngine.FieldIndexRules.BoolFieldNames.ToArray()); + lboxIdFields.Items.AddRange(SearchEngine.FieldIndexRules.IdFieldNames.ToArray()); + this.SetLibationIcon(); + this.RestoreSizeAndLocation(LibationFileManager.Configuration.Instance); + } + protected override void OnFormClosing(FormClosingEventArgs e) + { + base.OnFormClosing(e); + this.SaveSizeAndLocation(LibationFileManager.Configuration.Instance); + } - private void lboxFields_DoubleClick(object sender, EventArgs e) + private void lboxFields_DoubleClick(object sender, EventArgs e) + { + if (sender is ListBox { SelectedItem: string tagName }) { - if (sender is ListBox { SelectedItem: string tagName }) - { - TagDoubleClicked?.Invoke(this, tagName); - } + TagDoubleClicked?.Invoke(this, tagName); } } } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs index 3bbbb3cc..492ccca7 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs @@ -1,237 +1,236 @@ -using System; +using AudibleUtilities; using LibationFileManager; -using System.Linq; -using LibationUiBase; using LibationFileManager.Templates; -using AudibleUtilities; +using LibationUiBase; +using System; +using System.Linq; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +partial class SettingsDialog { - partial class SettingsDialog + private void Load_AudioSettings(Configuration config) { - private void Load_AudioSettings(Configuration config) + this.fileDownloadQualityLbl.Text = desc(nameof(config.FileDownloadQuality)); + this.allowLibationFixupCbox.Text = desc(nameof(config.AllowLibationFixup)); + this.createCueSheetCbox.Text = desc(nameof(config.CreateCueSheet)); + this.downloadCoverArtCbox.Text = desc(nameof(config.DownloadCoverArt)); + this.retainAaxFileCbox.Text = desc(nameof(config.RetainAaxFile)); + this.combineNestedChapterTitlesCbox.Text = desc(nameof(config.CombineNestedChapterTitles)); + this.splitFilesByChapterCbox.Text = desc(nameof(config.SplitFilesByChapter)); + this.minFileDurationLbl.Text = desc(nameof(config.MinimumFileDuration)); + this.mergeOpeningEndCreditsCbox.Text = desc(nameof(config.MergeOpeningAndEndCredits)); + this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio)); + this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged)); + this.moveMoovAtomCbox.Text = desc(nameof(config.MoveMoovToBeginning)); + this.useWidevineCbox.Text = desc(nameof(config.UseWidevine)); + this.request_xHE_AAC_Cbox.Text = desc(nameof(config.Request_xHE_AAC)); + + toolTip.SetToolTip(combineNestedChapterTitlesCbox, Configuration.GetHelpText(nameof(config.CombineNestedChapterTitles))); + toolTip.SetToolTip(allowLibationFixupCbox, Configuration.GetHelpText(nameof(config.AllowLibationFixup))); + toolTip.SetToolTip(moveMoovAtomCbox, Configuration.GetHelpText(nameof(config.MoveMoovToBeginning))); + toolTip.SetToolTip(lameDownsampleMonoCbox, Configuration.GetHelpText(nameof(config.LameDownsampleMono))); + toolTip.SetToolTip(convertLosslessRb, Configuration.GetHelpText(nameof(config.DecryptToLossy))); + toolTip.SetToolTip(convertLossyRb, Configuration.GetHelpText(nameof(config.DecryptToLossy))); + toolTip.SetToolTip(mergeOpeningEndCreditsCbox, Configuration.GetHelpText(nameof(config.MergeOpeningAndEndCredits))); + toolTip.SetToolTip(retainAaxFileCbox, Configuration.GetHelpText(nameof(config.RetainAaxFile))); + toolTip.SetToolTip(stripAudibleBrandingCbox, Configuration.GetHelpText(nameof(config.StripAudibleBrandAudio))); + toolTip.SetToolTip(useWidevineCbox, Configuration.GetHelpText(nameof(config.UseWidevine))); + toolTip.SetToolTip(request_xHE_AAC_Cbox, Configuration.GetHelpText(nameof(config.Request_xHE_AAC))); + toolTip.SetToolTip(minFileDurationLbl, Configuration.GetHelpText(nameof(config.SpatialAudioCodec))); + toolTip.SetToolTip(minFileDurationNud, Configuration.GetHelpText(nameof(config.SpatialAudioCodec))); + + fileDownloadQualityCb.Items.AddRange( + [ + new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Normal), + new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.High), + ]); + + clipsBookmarksFormatCb.Items.AddRange( + [ + Configuration.ClipBookmarkFormat.CSV, + Configuration.ClipBookmarkFormat.Xlsx, + Configuration.ClipBookmarkFormat.Json + ]); + + maxSampleRateCb.Items.AddRange( + Enum.GetValues<AAXClean.SampleRate>() + .Where(r => r >= AAXClean.SampleRate.Hz_8000 && r <= AAXClean.SampleRate.Hz_48000) + .Select(v => new EnumDisplay<AAXClean.SampleRate>(v, $"{(int)v} Hz")) + .ToArray()); + + encoderQualityCb.Items.AddRange( + [ + new EnumDisplay<NAudio.Lame.EncoderQuality>(NAudio.Lame.EncoderQuality.High), + new EnumDisplay<NAudio.Lame.EncoderQuality>(NAudio.Lame.EncoderQuality.Standard), + new EnumDisplay<NAudio.Lame.EncoderQuality>(NAudio.Lame.EncoderQuality.Fast) + ]); + + allowLibationFixupCbox.Checked = config.AllowLibationFixup; + createCueSheetCbox.Checked = config.CreateCueSheet; + downloadCoverArtCbox.Checked = config.DownloadCoverArt; + downloadClipsBookmarksCbox.Checked = config.DownloadClipsBookmarks; + fileDownloadQualityCb.SelectedItem = config.FileDownloadQuality; + useWidevineCbox.Checked = config.UseWidevine; + request_xHE_AAC_Cbox.Checked = config.Request_xHE_AAC; + + clipsBookmarksFormatCb.SelectedItem = config.ClipsBookmarksFileFormat; + retainAaxFileCbox.Checked = config.RetainAaxFile; + combineNestedChapterTitlesCbox.Checked = config.CombineNestedChapterTitles; + splitFilesByChapterCbox.Checked = config.SplitFilesByChapter; + minFileDurationNud.Value = config.MinimumFileDuration; + mergeOpeningEndCreditsCbox.Checked = config.MergeOpeningAndEndCredits; + stripUnabridgedCbox.Checked = config.StripUnabridged; + stripAudibleBrandingCbox.Checked = config.StripAudibleBrandAudio; + convertLosslessRb.Checked = !config.DecryptToLossy; + convertLossyRb.Checked = config.DecryptToLossy; + moveMoovAtomCbox.Checked = config.MoveMoovToBeginning; + + lameTargetBitrateRb.Checked = config.LameTargetBitrate; + lameTargetQualityRb.Checked = !config.LameTargetBitrate; + + maxSampleRateCb.SelectedItem = config.MaxSampleRate; + + encoderQualityCb.SelectedItem = config.LameEncoderQuality; + lameDownsampleMonoCbox.Checked = config.LameDownsampleMono; + lameBitrateTb.Value = config.LameBitrate; + lameConstantBitrateCbox.Checked = config.LameConstantBitrate; + LameMatchSourceBRCbox.Checked = config.LameMatchSourceBR; + lameVBRQualityTb.Value = config.LameVBRQuality; + + chapterTitleTemplateGb.Text = desc(nameof(config.ChapterTitleTemplate)); + chapterTitleTemplateTb.Text = config.ChapterTitleTemplate; + + lameTargetRb_CheckedChanged(this, EventArgs.Empty); + LameMatchSourceBRCbox_CheckedChanged(this, EventArgs.Empty); + convertFormatRb_CheckedChanged(this, EventArgs.Empty); + allowLibationFixupCbox_CheckedChanged(this, EventArgs.Empty); + splitFilesByChapterCbox_CheckedChanged(this, EventArgs.Empty); + downloadClipsBookmarksCbox_CheckedChanged(this, EventArgs.Empty); + } + + private void Save_AudioSettings(Configuration config) + { + config.AllowLibationFixup = allowLibationFixupCbox.Checked; + config.CreateCueSheet = createCueSheetCbox.Checked; + config.DownloadCoverArt = downloadCoverArtCbox.Checked; + config.DownloadClipsBookmarks = downloadClipsBookmarksCbox.Checked; + config.FileDownloadQuality = (fileDownloadQualityCb.SelectedItem as EnumDisplay<Configuration.DownloadQuality>)?.Value ?? config.FileDownloadQuality; + config.UseWidevine = useWidevineCbox.Checked; + config.Request_xHE_AAC = request_xHE_AAC_Cbox.Checked; + config.ClipsBookmarksFileFormat = (clipsBookmarksFormatCb.SelectedItem as EnumDisplay<Configuration.ClipBookmarkFormat>)?.Value ?? config.ClipsBookmarksFileFormat; + config.RetainAaxFile = retainAaxFileCbox.Checked; + config.CombineNestedChapterTitles = combineNestedChapterTitlesCbox.Checked; + config.SplitFilesByChapter = splitFilesByChapterCbox.Checked; + config.MinimumFileDuration = (int)minFileDurationNud.Value; + config.MergeOpeningAndEndCredits = mergeOpeningEndCreditsCbox.Checked; + config.StripUnabridged = stripUnabridgedCbox.Checked; + config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked; + config.DecryptToLossy = convertLossyRb.Checked; + config.MoveMoovToBeginning = moveMoovAtomCbox.Checked; + config.LameTargetBitrate = lameTargetBitrateRb.Checked; + config.MaxSampleRate = (maxSampleRateCb.SelectedItem as EnumDisplay<AAXClean.SampleRate>)?.Value ?? config.MaxSampleRate; + config.LameEncoderQuality = (encoderQualityCb.SelectedItem as EnumDisplay<NAudio.Lame.EncoderQuality>)?.Value ?? config.LameEncoderQuality; + config.LameDownsampleMono = lameDownsampleMonoCbox.Checked; + config.LameBitrate = lameBitrateTb.Value; + config.LameConstantBitrate = lameConstantBitrateCbox.Checked; + config.LameMatchSourceBR = LameMatchSourceBRCbox.Checked; + config.LameVBRQuality = lameVBRQualityTb.Value; + + config.ChapterTitleTemplate = chapterTitleTemplateTb.Text; + } + + private void downloadClipsBookmarksCbox_CheckedChanged(object sender, EventArgs e) + { + clipsBookmarksFormatCb.Enabled = downloadClipsBookmarksCbox.Checked; + } + + private void lameTargetRb_CheckedChanged(object sender, EventArgs e) + { + lameBitrateGb.Enabled = lameTargetBitrateRb.Checked; + lameQualityGb.Enabled = !lameTargetBitrateRb.Checked; + } + + private void LameMatchSourceBRCbox_CheckedChanged(object sender, EventArgs e) + { + lameBitrateTb.Enabled = !LameMatchSourceBRCbox.Checked; + } + + private void splitFilesByChapterCbox_CheckedChanged(object sender, EventArgs e) + { + chapterTitleTemplateGb.Enabled = minFileDurationNud.Enabled = minFileDurationLbl.Enabled = splitFilesByChapterCbox.Checked; + } + + private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.ChapterTitleTemplate>.CreateNameEditor(chapterTitleTemplateTb.Text), chapterTitleTemplateTb); + + private void convertFormatRb_CheckedChanged(object sender, EventArgs e) + { + moveMoovAtomCbox.Enabled = convertLosslessRb.Checked; + lameOptionsGb.Enabled = !convertLosslessRb.Checked; + + lameTargetRb_CheckedChanged(sender, e); + LameMatchSourceBRCbox_CheckedChanged(sender, e); + } + private void allowLibationFixupCbox_CheckedChanged(object sender, EventArgs e) + { + audiobookFixupsGb.Enabled = allowLibationFixupCbox.Checked; + convertLosslessRb.Enabled = allowLibationFixupCbox.Checked; + convertLossyRb.Enabled = allowLibationFixupCbox.Checked; + splitFilesByChapterCbox.Enabled = allowLibationFixupCbox.Checked; + stripUnabridgedCbox.Enabled = allowLibationFixupCbox.Checked; + stripAudibleBrandingCbox.Enabled = allowLibationFixupCbox.Checked; + + if (!allowLibationFixupCbox.Checked) { - this.fileDownloadQualityLbl.Text = desc(nameof(config.FileDownloadQuality)); - this.allowLibationFixupCbox.Text = desc(nameof(config.AllowLibationFixup)); - this.createCueSheetCbox.Text = desc(nameof(config.CreateCueSheet)); - this.downloadCoverArtCbox.Text = desc(nameof(config.DownloadCoverArt)); - this.retainAaxFileCbox.Text = desc(nameof(config.RetainAaxFile)); - this.combineNestedChapterTitlesCbox.Text = desc(nameof(config.CombineNestedChapterTitles)); - this.splitFilesByChapterCbox.Text = desc(nameof(config.SplitFilesByChapter)); - this.minFileDurationLbl.Text = desc(nameof(config.MinimumFileDuration)); - this.mergeOpeningEndCreditsCbox.Text = desc(nameof(config.MergeOpeningAndEndCredits)); - this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio)); - this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged)); - this.moveMoovAtomCbox.Text = desc(nameof(config.MoveMoovToBeginning)); - this.useWidevineCbox.Text = desc(nameof(config.UseWidevine)); - this.request_xHE_AAC_Cbox.Text = desc(nameof(config.Request_xHE_AAC)); - - toolTip.SetToolTip(combineNestedChapterTitlesCbox, Configuration.GetHelpText(nameof(config.CombineNestedChapterTitles))); - toolTip.SetToolTip(allowLibationFixupCbox, Configuration.GetHelpText(nameof(config.AllowLibationFixup))); - toolTip.SetToolTip(moveMoovAtomCbox, Configuration.GetHelpText(nameof(config.MoveMoovToBeginning))); - toolTip.SetToolTip(lameDownsampleMonoCbox, Configuration.GetHelpText(nameof(config.LameDownsampleMono))); - toolTip.SetToolTip(convertLosslessRb, Configuration.GetHelpText(nameof(config.DecryptToLossy))); - toolTip.SetToolTip(convertLossyRb, Configuration.GetHelpText(nameof(config.DecryptToLossy))); - toolTip.SetToolTip(mergeOpeningEndCreditsCbox, Configuration.GetHelpText(nameof(config.MergeOpeningAndEndCredits))); - toolTip.SetToolTip(retainAaxFileCbox, Configuration.GetHelpText(nameof(config.RetainAaxFile))); - toolTip.SetToolTip(stripAudibleBrandingCbox, Configuration.GetHelpText(nameof(config.StripAudibleBrandAudio))); - toolTip.SetToolTip(useWidevineCbox, Configuration.GetHelpText(nameof(config.UseWidevine))); - toolTip.SetToolTip(request_xHE_AAC_Cbox, Configuration.GetHelpText(nameof(config.Request_xHE_AAC))); - toolTip.SetToolTip(minFileDurationLbl, Configuration.GetHelpText(nameof(config.SpatialAudioCodec))); - toolTip.SetToolTip(minFileDurationNud, Configuration.GetHelpText(nameof(config.SpatialAudioCodec))); - - fileDownloadQualityCb.Items.AddRange( - [ - new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Normal), - new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.High), - ]); - - clipsBookmarksFormatCb.Items.AddRange( - [ - Configuration.ClipBookmarkFormat.CSV, - Configuration.ClipBookmarkFormat.Xlsx, - Configuration.ClipBookmarkFormat.Json - ]); - - maxSampleRateCb.Items.AddRange( - Enum.GetValues<AAXClean.SampleRate>() - .Where(r => r >= AAXClean.SampleRate.Hz_8000 && r <= AAXClean.SampleRate.Hz_48000) - .Select(v => new EnumDisplay<AAXClean.SampleRate>(v, $"{(int)v} Hz")) - .ToArray()); - - encoderQualityCb.Items.AddRange( - [ - NAudio.Lame.EncoderQuality.High, - NAudio.Lame.EncoderQuality.Standard, - NAudio.Lame.EncoderQuality.Fast, - ]); - - allowLibationFixupCbox.Checked = config.AllowLibationFixup; - createCueSheetCbox.Checked = config.CreateCueSheet; - downloadCoverArtCbox.Checked = config.DownloadCoverArt; - downloadClipsBookmarksCbox.Checked = config.DownloadClipsBookmarks; - fileDownloadQualityCb.SelectedItem = config.FileDownloadQuality; - useWidevineCbox.Checked = config.UseWidevine; - request_xHE_AAC_Cbox.Checked = config.Request_xHE_AAC; - - clipsBookmarksFormatCb.SelectedItem = config.ClipsBookmarksFileFormat; - retainAaxFileCbox.Checked = config.RetainAaxFile; - combineNestedChapterTitlesCbox.Checked = config.CombineNestedChapterTitles; - splitFilesByChapterCbox.Checked = config.SplitFilesByChapter; - minFileDurationNud.Value = config.MinimumFileDuration; - mergeOpeningEndCreditsCbox.Checked = config.MergeOpeningAndEndCredits; - stripUnabridgedCbox.Checked = config.StripUnabridged; - stripAudibleBrandingCbox.Checked = config.StripAudibleBrandAudio; - convertLosslessRb.Checked = !config.DecryptToLossy; - convertLossyRb.Checked = config.DecryptToLossy; - moveMoovAtomCbox.Checked = config.MoveMoovToBeginning; - - lameTargetBitrateRb.Checked = config.LameTargetBitrate; - lameTargetQualityRb.Checked = !config.LameTargetBitrate; - - maxSampleRateCb.SelectedItem = config.MaxSampleRate; - - encoderQualityCb.SelectedItem = config.LameEncoderQuality; - lameDownsampleMonoCbox.Checked = config.LameDownsampleMono; - lameBitrateTb.Value = config.LameBitrate; - lameConstantBitrateCbox.Checked = config.LameConstantBitrate; - LameMatchSourceBRCbox.Checked = config.LameMatchSourceBR; - lameVBRQualityTb.Value = config.LameVBRQuality; - - chapterTitleTemplateGb.Text = desc(nameof(config.ChapterTitleTemplate)); - chapterTitleTemplateTb.Text = config.ChapterTitleTemplate; - - lameTargetRb_CheckedChanged(this, EventArgs.Empty); - LameMatchSourceBRCbox_CheckedChanged(this, EventArgs.Empty); - convertFormatRb_CheckedChanged(this, EventArgs.Empty); - allowLibationFixupCbox_CheckedChanged(this, EventArgs.Empty); - splitFilesByChapterCbox_CheckedChanged(this, EventArgs.Empty); - downloadClipsBookmarksCbox_CheckedChanged(this, EventArgs.Empty); - } - - private void Save_AudioSettings(Configuration config) - { - config.AllowLibationFixup = allowLibationFixupCbox.Checked; - config.CreateCueSheet = createCueSheetCbox.Checked; - config.DownloadCoverArt = downloadCoverArtCbox.Checked; - config.DownloadClipsBookmarks = downloadClipsBookmarksCbox.Checked; - config.FileDownloadQuality = ((EnumDisplay<Configuration.DownloadQuality>)fileDownloadQualityCb.SelectedItem).Value; - config.UseWidevine = useWidevineCbox.Checked; - config.Request_xHE_AAC = request_xHE_AAC_Cbox.Checked; - config.ClipsBookmarksFileFormat = (Configuration.ClipBookmarkFormat)clipsBookmarksFormatCb.SelectedItem; - config.RetainAaxFile = retainAaxFileCbox.Checked; - config.CombineNestedChapterTitles = combineNestedChapterTitlesCbox.Checked; - config.SplitFilesByChapter = splitFilesByChapterCbox.Checked; - config.MinimumFileDuration = (int)minFileDurationNud.Value; - config.MergeOpeningAndEndCredits = mergeOpeningEndCreditsCbox.Checked; - config.StripUnabridged = stripUnabridgedCbox.Checked; - config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked; - config.DecryptToLossy = convertLossyRb.Checked; - config.MoveMoovToBeginning = moveMoovAtomCbox.Checked; - config.LameTargetBitrate = lameTargetBitrateRb.Checked; - config.MaxSampleRate = ((EnumDisplay<AAXClean.SampleRate>)maxSampleRateCb.SelectedItem).Value; - config.LameEncoderQuality = (NAudio.Lame.EncoderQuality)encoderQualityCb.SelectedItem; - config.LameDownsampleMono = lameDownsampleMonoCbox.Checked; - config.LameBitrate = lameBitrateTb.Value; - config.LameConstantBitrate = lameConstantBitrateCbox.Checked; - config.LameMatchSourceBR = LameMatchSourceBRCbox.Checked; - config.LameVBRQuality = lameVBRQualityTb.Value; - - config.ChapterTitleTemplate = chapterTitleTemplateTb.Text; - } - - private void downloadClipsBookmarksCbox_CheckedChanged(object sender, EventArgs e) - { - clipsBookmarksFormatCb.Enabled = downloadClipsBookmarksCbox.Checked; - } - - private void lameTargetRb_CheckedChanged(object sender, EventArgs e) - { - lameBitrateGb.Enabled = lameTargetBitrateRb.Checked; - lameQualityGb.Enabled = !lameTargetBitrateRb.Checked; - } - - private void LameMatchSourceBRCbox_CheckedChanged(object sender, EventArgs e) - { - lameBitrateTb.Enabled = !LameMatchSourceBRCbox.Checked; - } - - private void splitFilesByChapterCbox_CheckedChanged(object sender, EventArgs e) - { - chapterTitleTemplateGb.Enabled = minFileDurationNud.Enabled = minFileDurationLbl.Enabled = splitFilesByChapterCbox.Checked; - } - - private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) - => editTemplate(TemplateEditor<Templates.ChapterTitleTemplate>.CreateNameEditor(chapterTitleTemplateTb.Text), chapterTitleTemplateTb); - - private void convertFormatRb_CheckedChanged(object sender, EventArgs e) - { - moveMoovAtomCbox.Enabled = convertLosslessRb.Checked; - lameOptionsGb.Enabled = !convertLosslessRb.Checked; - - lameTargetRb_CheckedChanged(sender, e); - LameMatchSourceBRCbox_CheckedChanged(sender, e); - } - private void allowLibationFixupCbox_CheckedChanged(object sender, EventArgs e) - { - audiobookFixupsGb.Enabled = allowLibationFixupCbox.Checked; - convertLosslessRb.Enabled = allowLibationFixupCbox.Checked; - convertLossyRb.Enabled = allowLibationFixupCbox.Checked; - splitFilesByChapterCbox.Enabled = allowLibationFixupCbox.Checked; - stripUnabridgedCbox.Enabled = allowLibationFixupCbox.Checked; - stripAudibleBrandingCbox.Enabled = allowLibationFixupCbox.Checked; - - if (!allowLibationFixupCbox.Checked) - { - convertLosslessRb.Checked = true; - splitFilesByChapterCbox.Checked = false; - stripUnabridgedCbox.Checked = false; - stripAudibleBrandingCbox.Checked = false; - } - } - - private void useWidevineCbox_CheckedChanged(object sender, EventArgs e) - { - if (useWidevineCbox.Checked) - { - using var accounts = AudibleApiStorage.GetAccountsSettingsPersister(); - - if (!accounts.AccountsSettings.Accounts.All(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) - { - var choice = MessageBox.Show(this, - "In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" + - "Do you want Libation to clear your current account settings and prompt you to login before the next download?", - "Widevine Content Unavailable", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question, - MessageBoxDefaultButton.Button2); - - if (choice == DialogResult.Yes) - { - foreach (var account in accounts.AccountsSettings.Accounts.ToArray()) - { - if (account.IdentityTokens.DeviceType != AudibleApi.Resources.DeviceType) - { - accounts.AccountsSettings.Delete(account); - var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name); - acc.AccountName = account.AccountName; - } - } - - return; - } - - useWidevineCbox.Checked = false; - return; - } - } - else - { - request_xHE_AAC_Cbox.Checked = false; - } - - request_xHE_AAC_Cbox.Enabled = useWidevineCbox.Checked; + convertLosslessRb.Checked = true; + splitFilesByChapterCbox.Checked = false; + stripUnabridgedCbox.Checked = false; + stripAudibleBrandingCbox.Checked = false; } } + + private void useWidevineCbox_CheckedChanged(object sender, EventArgs e) + { + if (useWidevineCbox.Checked) + { + using var accounts = AudibleApiStorage.GetAccountsSettingsPersister(); + + if (!accounts.AccountsSettings.Accounts.All(a => a.IdentityTokens?.DeviceType == AudibleApi.Resources.DeviceType)) + { + var choice = MessageBox.Show(this, + "In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" + + "Do you want Libation to clear your current account settings and prompt you to login before the next download?", + "Widevine Content Unavailable", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question, + MessageBoxDefaultButton.Button2); + + if (choice == DialogResult.Yes) + { + foreach (var account in accounts.AccountsSettings.Accounts.ToArray()) + { + if (account.Locale is not null && account.IdentityTokens?.DeviceType != AudibleApi.Resources.DeviceType) + { + accounts.AccountsSettings.Delete(account); + var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name); + acc.AccountName = account.AccountName; + } + } + + return; + } + + useWidevineCbox.Checked = false; + return; + } + } + else + { + request_xHE_AAC_Cbox.Checked = false; + } + + request_xHE_AAC_Cbox.Enabled = useWidevineCbox.Checked; + } } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs index 9c88ce35..222d254b 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs @@ -1,87 +1,86 @@ using System; -using System.Linq; +using System.IO; using Dinah.Core; using LibationFileManager; using LibationFileManager.Templates; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class SettingsDialog { - public partial class SettingsDialog + private void folderTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(config.Books?.Path ?? Path.GetTempPath(), folderTemplateTb.Text), folderTemplateTb); + private void fileTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(config.Books?.Path ?? Path.GetTempPath(), fileTemplateTb.Text), fileTemplateTb); + private void chapterFileTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(config.Books?.Path ?? Path.GetTempPath(), chapterFileTemplateTb.Text), chapterFileTemplateTb); + + private void editCharreplacementBtn_Click(object sender, EventArgs e) { - private void folderTemplateBtn_Click(object sender, EventArgs e) - => editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(config.Books, folderTemplateTb.Text), folderTemplateTb); - private void fileTemplateBtn_Click(object sender, EventArgs e) - => editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(config.Books, fileTemplateTb.Text), fileTemplateTb); - private void chapterFileTemplateBtn_Click(object sender, EventArgs e) - => editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(config.Books, chapterFileTemplateTb.Text), chapterFileTemplateTb); + var form = new EditReplacementChars(config); + form.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + form.ShowDialog(this); + } - private void editCharreplacementBtn_Click(object sender, EventArgs e) + private void Load_DownloadDecrypt(Configuration config) + { + inProgressDescLbl.Text = desc(nameof(config.InProgress)); + editCharreplacementBtn.Text = desc(nameof(config.ReplacementCharacters)); + + badBookGb.Text = desc(nameof(config.BadBook)); + badBookAskRb.Text = Configuration.BadBookAction.Ask.GetDescription(); + badBookAbortRb.Text = Configuration.BadBookAction.Abort.GetDescription(); + badBookRetryRb.Text = Configuration.BadBookAction.Retry.GetDescription(); + badBookIgnoreRb.Text = Configuration.BadBookAction.Ignore.GetDescription(); + useCoverAsFolderIconCb.Text = desc(nameof(config.UseCoverAsFolderIcon)); + saveMetadataToFileCbox.Text = desc(nameof(config.SaveMetadataToFile)); + + inProgressSelectControl.SetDirectoryItems(new() { - var form = new EditReplacementChars(config); - form.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; - form.ShowDialog(this); - } + Configuration.KnownDirectories.WinTemp, + Configuration.KnownDirectories.ApplicationData, + Configuration.KnownDirectories.UserProfile, + Configuration.KnownDirectories.AppDir, + Configuration.KnownDirectories.MyDocs, + Configuration.KnownDirectories.LibationFiles + }, Configuration.KnownDirectories.WinTemp); + inProgressSelectControl.SelectDirectory(config.InProgress); - private void Load_DownloadDecrypt(Configuration config) + var rb = config.BadBook switch { - inProgressDescLbl.Text = desc(nameof(config.InProgress)); - editCharreplacementBtn.Text = desc(nameof(config.ReplacementCharacters)); + Configuration.BadBookAction.Ask => this.badBookAskRb, + Configuration.BadBookAction.Abort => this.badBookAbortRb, + Configuration.BadBookAction.Retry => this.badBookRetryRb, + Configuration.BadBookAction.Ignore => this.badBookIgnoreRb, + _ => this.badBookAskRb + }; + rb.Checked = true; - badBookGb.Text = desc(nameof(config.BadBook)); - badBookAskRb.Text = Configuration.BadBookAction.Ask.GetDescription(); - badBookAbortRb.Text = Configuration.BadBookAction.Abort.GetDescription(); - badBookRetryRb.Text = Configuration.BadBookAction.Retry.GetDescription(); - badBookIgnoreRb.Text = Configuration.BadBookAction.Ignore.GetDescription(); - useCoverAsFolderIconCb.Text = desc(nameof(config.UseCoverAsFolderIcon)); - saveMetadataToFileCbox.Text = desc(nameof(config.SaveMetadataToFile)); + folderTemplateLbl.Text = desc(nameof(config.FolderTemplate)); + fileTemplateLbl.Text = desc(nameof(config.FileTemplate)); + chapterFileTemplateLbl.Text = desc(nameof(config.ChapterFileTemplate)); + folderTemplateTb.Text = config.FolderTemplate; + fileTemplateTb.Text = config.FileTemplate; + chapterFileTemplateTb.Text = config.ChapterFileTemplate; + useCoverAsFolderIconCb.Checked = config.UseCoverAsFolderIcon; + saveMetadataToFileCbox.Checked = config.SaveMetadataToFile; + } - inProgressSelectControl.SetDirectoryItems(new() - { - Configuration.KnownDirectories.WinTemp, - Configuration.KnownDirectories.ApplicationData, - Configuration.KnownDirectories.UserProfile, - Configuration.KnownDirectories.AppDir, - Configuration.KnownDirectories.MyDocs, - Configuration.KnownDirectories.LibationFiles - }, Configuration.KnownDirectories.WinTemp); - inProgressSelectControl.SelectDirectory(config.InProgress); + private void Save_DownloadDecrypt(Configuration config) + { + config.InProgress = inProgressSelectControl.SelectedDirectory; - var rb = config.BadBook switch - { - Configuration.BadBookAction.Ask => this.badBookAskRb, - Configuration.BadBookAction.Abort => this.badBookAbortRb, - Configuration.BadBookAction.Retry => this.badBookRetryRb, - Configuration.BadBookAction.Ignore => this.badBookIgnoreRb, - _ => this.badBookAskRb - }; - rb.Checked = true; + config.BadBook + = badBookAskRb.Checked ? Configuration.BadBookAction.Ask + : badBookAbortRb.Checked ? Configuration.BadBookAction.Abort + : badBookRetryRb.Checked ? Configuration.BadBookAction.Retry + : badBookIgnoreRb.Checked ? Configuration.BadBookAction.Ignore + : Configuration.BadBookAction.Ask; - folderTemplateLbl.Text = desc(nameof(config.FolderTemplate)); - fileTemplateLbl.Text = desc(nameof(config.FileTemplate)); - chapterFileTemplateLbl.Text = desc(nameof(config.ChapterFileTemplate)); - folderTemplateTb.Text = config.FolderTemplate; - fileTemplateTb.Text = config.FileTemplate; - chapterFileTemplateTb.Text = config.ChapterFileTemplate; - useCoverAsFolderIconCb.Checked = config.UseCoverAsFolderIcon; - saveMetadataToFileCbox.Checked = config.SaveMetadataToFile; - } - - private void Save_DownloadDecrypt(Configuration config) - { - config.InProgress = inProgressSelectControl.SelectedDirectory; - - config.BadBook - = badBookAskRb.Checked ? Configuration.BadBookAction.Ask - : badBookAbortRb.Checked ? Configuration.BadBookAction.Abort - : badBookRetryRb.Checked ? Configuration.BadBookAction.Retry - : badBookIgnoreRb.Checked ? Configuration.BadBookAction.Ignore - : Configuration.BadBookAction.Ask; - - config.FolderTemplate = folderTemplateTb.Text; - config.FileTemplate = fileTemplateTb.Text; - config.ChapterFileTemplate = chapterFileTemplateTb.Text; - config.UseCoverAsFolderIcon = useCoverAsFolderIconCb.Checked; - config.SaveMetadataToFile = saveMetadataToFileCbox.Checked; - } + config.FolderTemplate = folderTemplateTb.Text; + config.FileTemplate = fileTemplateTb.Text; + config.ChapterFileTemplate = chapterFileTemplateTb.Text; + config.UseCoverAsFolderIcon = useCoverAsFolderIconCb.Checked; + config.SaveMetadataToFile = saveMetadataToFileCbox.Checked; } } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.ImportLibrary.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.ImportLibrary.cs index 8376d890..326ab6cb 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.ImportLibrary.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.ImportLibrary.cs @@ -1,36 +1,32 @@ using LibationFileManager; -using LibationUiBase; -using System; -using System.Linq; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class SettingsDialog { - public partial class SettingsDialog + private void Load_ImportLibrary(Configuration config) { - private void Load_ImportLibrary(Configuration config) - { - this.autoScanCb.Text = desc(nameof(config.AutoScan)); - this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats)); - this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes)); - this.importPlusTitlesCb.Text = desc(nameof(config.ImportPlusTitles)); - this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes)); - this.autoDownloadEpisodesCb.Text = desc(nameof(config.AutoDownloadEpisodes)); + this.autoScanCb.Text = desc(nameof(config.AutoScan)); + this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats)); + this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes)); + this.importPlusTitlesCb.Text = desc(nameof(config.ImportPlusTitles)); + this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes)); + this.autoDownloadEpisodesCb.Text = desc(nameof(config.AutoDownloadEpisodes)); - autoScanCb.Checked = config.AutoScan; - showImportedStatsCb.Checked = config.ShowImportedStats; - importEpisodesCb.Checked = config.ImportEpisodes; - importPlusTitlesCb.Checked = config.ImportPlusTitles; - downloadEpisodesCb.Checked = config.DownloadEpisodes; - autoDownloadEpisodesCb.Checked = config.AutoDownloadEpisodes; - } - private void Save_ImportLibrary(Configuration config) - { - config.AutoScan = autoScanCb.Checked; - config.ShowImportedStats = showImportedStatsCb.Checked; - config.ImportEpisodes = importEpisodesCb.Checked; - config.ImportPlusTitles = importPlusTitlesCb.Checked; - config.DownloadEpisodes = downloadEpisodesCb.Checked; - config.AutoDownloadEpisodes = autoDownloadEpisodesCb.Checked; - } + autoScanCb.Checked = config.AutoScan; + showImportedStatsCb.Checked = config.ShowImportedStats; + importEpisodesCb.Checked = config.ImportEpisodes; + importPlusTitlesCb.Checked = config.ImportPlusTitles; + downloadEpisodesCb.Checked = config.DownloadEpisodes; + autoDownloadEpisodesCb.Checked = config.AutoDownloadEpisodes; + } + private void Save_ImportLibrary(Configuration config) + { + config.AutoScan = autoScanCb.Checked; + config.ShowImportedStats = showImportedStatsCb.Checked; + config.ImportEpisodes = importEpisodesCb.Checked; + config.ImportPlusTitles = importPlusTitlesCb.Checked; + config.DownloadEpisodes = downloadEpisodesCb.Checked; + config.AutoDownloadEpisodes = autoDownloadEpisodesCb.Checked; } } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs index 7c74cdfb..24c023b7 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs @@ -7,138 +7,136 @@ using System.IO; using System.Linq; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class SettingsDialog { - public partial class SettingsDialog + private void logsBtn_Click(object sender, EventArgs e) + { + if (File.Exists(LogFileFilter.LogFilePath)) + Go.To.File(LogFileFilter.LogFilePath); + else + Go.To.Folder(Configuration.Instance.LibationFiles.Location.ShortPathName); + } + private Configuration.Theme themeVariant; + private Configuration.Theme initialThemeVariant; + + private void Load_Important(Configuration config) { - private void logsBtn_Click(object sender, EventArgs e) { - if (File.Exists(LogFileFilter.LogFilePath)) - Go.To.File(LogFileFilter.LogFilePath); - else - Go.To.Folder(Configuration.Instance.LibationFiles.Location.ShortPathName); + loggingLevelCb.Items.Clear(); + foreach (var level in Enum<Serilog.Events.LogEventLevel>.GetValues()) + loggingLevelCb.Items.Add(level); + loggingLevelCb.SelectedItem = config.LogLevel; } - private Configuration.Theme themeVariant; - private Configuration.Theme initialThemeVariant; - private void Load_Important(Configuration config) - { + booksLocationDescLbl.Text = desc(nameof(config.Books)); + saveEpisodesToSeriesFolderCbox.Text = desc(nameof(config.SavePodcastsToParentFolder)); + overwriteExistingCbox.Text = desc(nameof(config.OverwriteExisting)); + creationTimeLbl.Text = desc(nameof(config.CreationTime)); + lastWriteTimeLbl.Text = desc(nameof(config.LastWriteTime)); + gridScaleFactorLbl.Text = desc(nameof(config.GridScaleFactor)); + gridFontScaleFactorLbl.Text = desc(nameof(config.GridFontScaleFactor)); + + var dateTimeSources = Enum.GetValues<Configuration.DateTimeSource>().Select(v => new EnumDisplay<Configuration.DateTimeSource>(v)).ToArray(); + creationTimeCb.Items.AddRange(dateTimeSources); + lastWriteTimeCb.Items.AddRange(dateTimeSources); + + creationTimeCb.SelectedItem = dateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? dateTimeSources[0]; + lastWriteTimeCb.SelectedItem = dateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? dateTimeSources[0]; + + themeVariant = initialThemeVariant = config.ThemeVariant; + var themes = Enum.GetValues<Configuration.Theme>().Select(v => new EnumDisplay<Configuration.Theme>(v)).ToArray(); + themeCb.Items.AddRange(themes); + themeCb.SelectedItem = themes.SingleOrDefault(v => v.Value == themeVariant) ?? themes[0]; + + booksSelectControl.SetSearchTitle("books location"); + booksSelectControl.SetDirectoryItems( + new() { - loggingLevelCb.Items.Clear(); - foreach (var level in Enum<Serilog.Events.LogEventLevel>.GetValues()) - loggingLevelCb.Items.Add(level); - loggingLevelCb.SelectedItem = config.LogLevel; - } - - booksLocationDescLbl.Text = desc(nameof(config.Books)); - saveEpisodesToSeriesFolderCbox.Text = desc(nameof(config.SavePodcastsToParentFolder)); - overwriteExistingCbox.Text = desc(nameof(config.OverwriteExisting)); - creationTimeLbl.Text = desc(nameof(config.CreationTime)); - lastWriteTimeLbl.Text = desc(nameof(config.LastWriteTime)); - gridScaleFactorLbl.Text = desc(nameof(config.GridScaleFactor)); - gridFontScaleFactorLbl.Text = desc(nameof(config.GridFontScaleFactor)); - - var dateTimeSources = Enum.GetValues<Configuration.DateTimeSource>().Select(v => new EnumDisplay<Configuration.DateTimeSource>(v)).ToArray(); - creationTimeCb.Items.AddRange(dateTimeSources); - lastWriteTimeCb.Items.AddRange(dateTimeSources); - - creationTimeCb.SelectedItem = dateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? dateTimeSources[0]; - lastWriteTimeCb.SelectedItem = dateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? dateTimeSources[0]; - - themeVariant = initialThemeVariant = config.ThemeVariant; - var themes = Enum.GetValues<Configuration.Theme>().Select(v => new EnumDisplay<Configuration.Theme>(v)).ToArray(); - themeCb.Items.AddRange(themes); - themeCb.SelectedItem = themes.SingleOrDefault(v => v.Value == themeVariant) ?? themes[0]; - - booksSelectControl.SetSearchTitle("books location"); - booksSelectControl.SetDirectoryItems( - new() - { - Configuration.KnownDirectories.UserProfile, - Configuration.KnownDirectories.AppDir, - Configuration.KnownDirectories.MyDocs, - Configuration.KnownDirectories.MyMusic, - }, Configuration.KnownDirectories.UserProfile, - "Books"); - booksSelectControl.SelectDirectory(config.Books?.PathWithoutPrefix ?? ""); + Configuration.KnownDirectories.AppDir, + Configuration.KnownDirectories.MyDocs, + Configuration.KnownDirectories.MyMusic, + }, + Configuration.KnownDirectories.UserProfile, + "Books"); + booksSelectControl.SelectDirectory(config.Books?.PathWithoutPrefix ?? ""); - saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder; - overwriteExistingCbox.Checked = config.OverwriteExisting; - gridScaleFactorTbar.Value = scaleFactorToLinearRange(config.GridScaleFactor); - gridFontScaleFactorTbar.Value = scaleFactorToLinearRange(config.GridFontScaleFactor); - } + saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder; + overwriteExistingCbox.Checked = config.OverwriteExisting; + gridScaleFactorTbar.Value = scaleFactorToLinearRange(config.GridScaleFactor); + gridFontScaleFactorTbar.Value = scaleFactorToLinearRange(config.GridFontScaleFactor); + } - private bool Save_Important(Configuration config) + private bool Save_Important(Configuration config) + { + var newBooks = booksSelectControl.SelectedDirectory; + + #region validation + static void validationError(string text, string caption) + => MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error); + if (string.IsNullOrWhiteSpace(newBooks)) { - var newBooks = booksSelectControl.SelectedDirectory; - - #region validation - static void validationError(string text, string caption) - => MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error); - if (string.IsNullOrWhiteSpace(newBooks)) + validationError("Cannot set Books Location to blank", "Location is blank"); + return false; + } + LongPath lonNewBooks = newBooks; + if (!Directory.Exists(lonNewBooks)) + { + try { - validationError("Cannot set Books Location to blank", "Location is blank"); + Directory.CreateDirectory(lonNewBooks); + } + catch (Exception ex) + { + validationError($"Error creating Books Location:\r\n{ex.Message}", "Error creating directory"); return false; } - LongPath lonNewBooks = newBooks; - if (!Directory.Exists(lonNewBooks)) - { - try - { - Directory.CreateDirectory(lonNewBooks); - } - catch (Exception ex) - { - validationError($"Error creating Books Location:\r\n{ex.Message}", "Error creating directory"); - return false; - } - } - #endregion + } + #endregion - config.Books = newBooks; + config.Books = newBooks; - { - var logLevelOld = config.LogLevel; - var logLevelNew = (loggingLevelCb.SelectedItem as Serilog.Events.LogEventLevel?) ?? Serilog.Events.LogEventLevel.Information; + { + var logLevelOld = config.LogLevel; + var logLevelNew = (loggingLevelCb.SelectedItem as Serilog.Events.LogEventLevel?) ?? Serilog.Events.LogEventLevel.Information; - config.LogLevel = logLevelNew; + config.LogLevel = logLevelNew; - // only warn if changed during this time. don't want to warn every time user happens to change settings while level is verbose - if (logLevelOld != logLevelNew) - MessageBoxLib.VerboseLoggingWarning_ShowIfTrue(); - } - - config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked; - config.OverwriteExisting = overwriteExistingCbox.Checked; - - config.CreationTime = (creationTimeCb.SelectedItem as EnumDisplay<Configuration.DateTimeSource>)?.Value ?? Configuration.DateTimeSource.File; - config.LastWriteTime = (lastWriteTimeCb.SelectedItem as EnumDisplay<Configuration.DateTimeSource>)?.Value ?? Configuration.DateTimeSource.File; - config.ThemeVariant = (themeCb.SelectedItem as EnumDisplay<Configuration.Theme>)?.Value ?? Configuration.Theme.System; - return true; + // only warn if changed during this time. don't want to warn every time user happens to change settings while level is verbose + if (logLevelOld != logLevelNew) + MessageBoxLib.VerboseLoggingWarning_ShowIfTrue(); } - private static int scaleFactorToLinearRange(float scaleFactor) - => (int)float.Round(100 * MathF.Log2(scaleFactor)); - private static float linearRangeToScaleFactor(int value) - => MathF.Pow(2, value / 100f); + config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked; + config.OverwriteExisting = overwriteExistingCbox.Checked; - private void applyDisplaySettingsBtn_Click(object sender, EventArgs e) - { - config.GridFontScaleFactor = linearRangeToScaleFactor(gridFontScaleFactorTbar.Value); - config.GridScaleFactor = linearRangeToScaleFactor(gridScaleFactorTbar.Value); - } + config.CreationTime = (creationTimeCb.SelectedItem as EnumDisplay<Configuration.DateTimeSource>)?.Value ?? Configuration.DateTimeSource.File; + config.LastWriteTime = (lastWriteTimeCb.SelectedItem as EnumDisplay<Configuration.DateTimeSource>)?.Value ?? Configuration.DateTimeSource.File; + config.ThemeVariant = (themeCb.SelectedItem as EnumDisplay<Configuration.Theme>)?.Value ?? Configuration.Theme.System; + return true; + } - private void themeCb_SelectedIndexChanged(object? sender, EventArgs e) + private static int scaleFactorToLinearRange(float scaleFactor) + => (int)float.Round(100 * MathF.Log2(scaleFactor)); + private static float linearRangeToScaleFactor(int value) + => MathF.Pow(2, value / 100f); + + private void applyDisplaySettingsBtn_Click(object sender, EventArgs e) + { + config.GridFontScaleFactor = linearRangeToScaleFactor(gridFontScaleFactorTbar.Value); + config.GridScaleFactor = linearRangeToScaleFactor(gridScaleFactorTbar.Value); + } + + private void themeCb_SelectedIndexChanged(object? sender, EventArgs e) + { + var selected = themeCb.SelectedItem as EnumDisplay<Configuration.Theme>; + if (selected != null) { - var selected = themeCb.SelectedItem as EnumDisplay<Configuration.Theme>; - if (selected != null) - { - themeVariant = selected.Value; - themeLbl.Visible = themeVariant != initialThemeVariant; - } + themeVariant = selected.Value; + themeLbl.Visible = themeVariant != initialThemeVariant; } } } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.cs index 2c028bf5..082c6538 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.cs @@ -1,61 +1,59 @@ using System; -using System.Linq; using System.Windows.Forms; using LibationFileManager; using LibationFileManager.Templates; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class SettingsDialog : Form { - public partial class SettingsDialog : Form + private Configuration config { get; } = Configuration.Instance; + private Func<string, string> desc { get; } = Configuration.GetDescription; + private readonly ToolTip toolTip = new ToolTip { - private Configuration config { get; } = Configuration.Instance; - private Func<string, string> desc { get; } = Configuration.GetDescription; - private readonly ToolTip toolTip = new ToolTip - { - InitialDelay = 300, - AutoPopDelay = 10000, - ReshowDelay = 0 - }; + InitialDelay = 300, + AutoPopDelay = 10000, + ReshowDelay = 0 + }; - public SettingsDialog() - { - InitializeComponent(); - this.SetLibationIcon(); - } + public SettingsDialog() + { + InitializeComponent(); + this.SetLibationIcon(); + } - private void SettingsDialog_Load(object sender, EventArgs e) - { - if (this.DesignMode) - return; + private void SettingsDialog_Load(object sender, EventArgs e) + { + if (this.DesignMode) + return; - Load_Important(config); - Load_ImportLibrary(config); - Load_DownloadDecrypt(config); - Load_AudioSettings(config); - } + Load_Important(config); + Load_ImportLibrary(config); + Load_DownloadDecrypt(config); + Load_AudioSettings(config); + } - private static void editTemplate(ITemplateEditor template, TextBox textBox) - { - var form = new EditTemplateDialog(template); - if (form.ShowDialog() == DialogResult.OK) - textBox.Text = template.EditingTemplate.TemplateText; - } + private static void editTemplate(ITemplateEditor template, TextBox textBox) + { + var form = new EditTemplateDialog(template); + if (form.ShowDialog() == DialogResult.OK) + textBox.Text = template.EditingTemplate.TemplateText; + } - private void saveBtn_Click(object sender, EventArgs e) - { - if (!Save_Important(config)) return; - Save_ImportLibrary(config); - Save_DownloadDecrypt(config); - Save_AudioSettings(config); + private void saveBtn_Click(object sender, EventArgs e) + { + if (!Save_Important(config)) return; + Save_ImportLibrary(config); + Save_DownloadDecrypt(config); + Save_AudioSettings(config); - this.DialogResult = DialogResult.OK; - this.Close(); - } + this.DialogResult = DialogResult.OK; + this.Close(); + } - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); - } + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); } } diff --git a/Source/LibationWinForms/Dialogs/SetupDialog.cs b/Source/LibationWinForms/Dialogs/SetupDialog.cs index 2f01c342..8e91e205 100644 --- a/Source/LibationWinForms/Dialogs/SetupDialog.cs +++ b/Source/LibationWinForms/Dialogs/SetupDialog.cs @@ -1,31 +1,29 @@ using LibationUiBase; using System; -using System.Linq; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class SetupDialog : Form, ILibationSetup { - public partial class SetupDialog : Form, ILibationSetup + public bool IsNewUser { get; private set; } + public bool IsReturningUser { get; private set; } + + public SetupDialog() => InitializeComponent(); + + private void newUserBtn_Click(object sender, EventArgs e) { - public bool IsNewUser { get; private set; } - public bool IsReturningUser { get; private set; } + IsNewUser = true; - public SetupDialog() => InitializeComponent(); + this.DialogResult = DialogResult.OK; + Close(); + } - private void newUserBtn_Click(object sender, EventArgs e) - { - IsNewUser = true; + private void returningUserBtn_Click(object sender, EventArgs e) + { + IsReturningUser = true; - this.DialogResult = DialogResult.OK; - Close(); - } - - private void returningUserBtn_Click(object sender, EventArgs e) - { - IsReturningUser = true; - - this.DialogResult = DialogResult.OK; - Close(); - } + this.DialogResult = DialogResult.OK; + Close(); } } diff --git a/Source/LibationWinForms/Dialogs/TagsBatchDialog.cs b/Source/LibationWinForms/Dialogs/TagsBatchDialog.cs index bd74c532..aa99e45b 100644 --- a/Source/LibationWinForms/Dialogs/TagsBatchDialog.cs +++ b/Source/LibationWinForms/Dialogs/TagsBatchDialog.cs @@ -1,35 +1,27 @@ using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Drawing; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class TagsBatchDialog : Form { - public partial class TagsBatchDialog : Form + public string? NewTags { get; private set; } + + public TagsBatchDialog() { - public string NewTags { get; private set; } + InitializeComponent(); + this.SetLibationIcon(); + } - public TagsBatchDialog() - { - InitializeComponent(); - this.SetLibationIcon(); - } + private void saveBtn_Click(object sender, EventArgs e) + { + NewTags = this.newTagsTb.Text; + this.DialogResult = DialogResult.OK; + } - private void saveBtn_Click(object sender, EventArgs e) - { - NewTags = this.newTagsTb.Text; - this.DialogResult = DialogResult.OK; - } - - private void cancelBtn_Click(object sender, EventArgs e) - { - this.DialogResult = DialogResult.Cancel; - this.Close(); - } + private void cancelBtn_Click(object sender, EventArgs e) + { + this.DialogResult = DialogResult.Cancel; + this.Close(); } } diff --git a/Source/LibationWinForms/Dialogs/TrashBinDialog.cs b/Source/LibationWinForms/Dialogs/TrashBinDialog.cs index 28826149..523eaa4a 100644 --- a/Source/LibationWinForms/Dialogs/TrashBinDialog.cs +++ b/Source/LibationWinForms/Dialogs/TrashBinDialog.cs @@ -9,154 +9,152 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class TrashBinDialog : Form { - public partial class TrashBinDialog : Form + private string lastGoodFilter = ""; + private TempSearchEngine SearchEngine { get; } = new TempSearchEngine(); + public TrashBinDialog() { - private string lastGoodFilter = ""; - private TempSearchEngine SearchEngine { get; } = new TempSearchEngine(); - public TrashBinDialog() + InitializeComponent(); + + this.SetLibationIcon(); + this.RestoreSizeAndLocation(Configuration.Instance); + this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); + + deletedCheckedLbl.Text = ""; + plusBookcSheckedLbl.Text = ""; + productsGrid1.SearchEngine = SearchEngine; + productsGrid1.RemovableCountChanged += (_, _) => UpdateCounts(); + productsGrid1.VisibleCountChanged += (_, _) => UpdateCounts(); + Load += TrashBinDialog_Load; + } + + private IEnumerable<LibraryBook> GetCheckedBooks() => productsGrid1.GetVisibleGridEntries().Where(i => i.Remove is true).Select(i => i.LibraryBook); + + private async void TrashBinDialog_Load(object? sender, EventArgs e) + { + productsGrid1.RemoveColumnVisible = true; + await InitAsync(); + } + + private void UpdateCounts() + { + var visible = productsGrid1.GetVisibleGridEntries().ToArray(); + var plusVisibleCount = visible.Count(e => e.LibraryBook.IsAudiblePlus); + + var checkedCount = visible.Count(e => e.Remove is true); + var plusCheckedCount = visible.Count(e => e.LibraryBook.IsAudiblePlus && e.Remove is true); + + deletedCheckedLbl.Text = $"Checked: {checkedCount} of {visible.Length}"; + plusBookcSheckedLbl.Text = $"Checked: {plusCheckedCount} of {plusVisibleCount}"; + + everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged; + everythingCb.CheckState = checkedCount == 0 || visible.Length == 0 ? CheckState.Unchecked + : checkedCount == visible.Length ? CheckState.Checked + : CheckState.Indeterminate; + everythingCb.CheckStateChanged += everythingCb_CheckStateChanged; + + audiblePlusCb.CheckStateChanged -= audiblePlusCb_CheckStateChanged; + audiblePlusCb.CheckState = plusCheckedCount == 0 || plusVisibleCount == 0 ? CheckState.Unchecked + : plusCheckedCount == plusVisibleCount ? CheckState.Checked + : CheckState.Indeterminate; + audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged; + } + + private async Task InitAsync() + { + var deletedBooks = DbContexts.GetDeletedLibraryBooks(); + SearchEngine.ReindexSearchEngine(deletedBooks); + await productsGrid1.BindToGridAsync(deletedBooks); + } + + private void Reload() + { + var deletedBooks = DbContexts.GetDeletedLibraryBooks(); + SearchEngine.ReindexSearchEngine(deletedBooks); + productsGrid1.UpdateGrid(deletedBooks); + } + + private async void permanentlyDeleteBtn_Click(object sender, EventArgs e) + { + setControlsEnabled(false); + + var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync(); + if (qtyChanges > 0) + Reload(); + + setControlsEnabled(true); + } + + private async void restoreBtn_Click(object sender, EventArgs e) + { + setControlsEnabled(false); + + var qtyChanges = await GetCheckedBooks().RestoreBooksAsync(); + if (qtyChanges > 0) + Reload(); + + setControlsEnabled(true); + } + + private void setControlsEnabled(bool enabled) + => Invoke(() => productsGrid1.Enabled = restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = everythingCb.Enabled = enabled); + + private void textBox1_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Enter) + searchBtn_Click(sender, e); + } + + private void searchBtn_Click(object sender, EventArgs e) + { + try { - InitializeComponent(); - - this.SetLibationIcon(); - this.RestoreSizeAndLocation(Configuration.Instance); - this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); - - deletedCheckedLbl.Text = ""; - plusBookcSheckedLbl.Text = ""; - productsGrid1.SearchEngine = SearchEngine; - productsGrid1.RemovableCountChanged += (_, _) => UpdateCounts(); - productsGrid1.VisibleCountChanged += (_, _) => UpdateCounts(); - Load += TrashBinDialog_Load; + productsGrid1.Filter(textBox1.Text); + lastGoodFilter = textBox1.Text; } - - private IEnumerable<LibraryBook> GetCheckedBooks() => productsGrid1.GetVisibleGridEntries().Where(i => i.Remove is true).Select(i => i.LibraryBook); - - private async void TrashBinDialog_Load(object? sender, EventArgs e) + catch { - productsGrid1.RemoveColumnVisible = true; - await InitAsync(); - } - - private void UpdateCounts() - { - var visible = productsGrid1.GetVisibleGridEntries().ToArray(); - var plusVisibleCount = visible.Count(e => e.LibraryBook.IsAudiblePlus); - - var checkedCount = visible.Count(e => e.Remove is true); - var plusCheckedCount = visible.Count(e => e.LibraryBook.IsAudiblePlus && e.Remove is true); - - deletedCheckedLbl.Text = $"Checked: {checkedCount} of {visible.Length}"; - plusBookcSheckedLbl.Text = $"Checked: {plusCheckedCount} of {plusVisibleCount}"; - - everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged; - everythingCb.CheckState = checkedCount == 0 || visible.Length == 0 ? CheckState.Unchecked - : checkedCount == visible.Length ? CheckState.Checked - : CheckState.Indeterminate; - everythingCb.CheckStateChanged += everythingCb_CheckStateChanged; - - audiblePlusCb.CheckStateChanged -= audiblePlusCb_CheckStateChanged; - audiblePlusCb.CheckState = plusCheckedCount == 0 || plusVisibleCount == 0 ? CheckState.Unchecked - : plusCheckedCount == plusVisibleCount ? CheckState.Checked - : CheckState.Indeterminate; - audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged; - } - - private async Task InitAsync() - { - var deletedBooks = DbContexts.GetDeletedLibraryBooks(); - SearchEngine.ReindexSearchEngine(deletedBooks); - await productsGrid1.BindToGridAsync(deletedBooks); - } - - private void Reload() - { - var deletedBooks = DbContexts.GetDeletedLibraryBooks(); - SearchEngine.ReindexSearchEngine(deletedBooks); - productsGrid1.UpdateGrid(deletedBooks); - } - - private async void permanentlyDeleteBtn_Click(object sender, EventArgs e) - { - setControlsEnabled(false); - - var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync(); - if (qtyChanges > 0) - Reload(); - - setControlsEnabled(true); - } - - private async void restoreBtn_Click(object sender, EventArgs e) - { - setControlsEnabled(false); - - var qtyChanges = await GetCheckedBooks().RestoreBooksAsync(); - if (qtyChanges > 0) - Reload(); - - setControlsEnabled(true); - } - - private void setControlsEnabled(bool enabled) - => Invoke(() => productsGrid1.Enabled = restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = everythingCb.Enabled = enabled); - - private void textBox1_KeyDown(object sender, KeyEventArgs e) - { - if (e.KeyCode == Keys.Enter) - searchBtn_Click(sender, e); - } - - private void searchBtn_Click(object sender, EventArgs e) - { - try - { - productsGrid1.Filter(textBox1.Text); - lastGoodFilter = textBox1.Text; - } - catch - { - productsGrid1.Filter(lastGoodFilter); - } - } - - private void audiblePlusCb_CheckStateChanged(object? sender, EventArgs e) - { - switch (audiblePlusCb.CheckState) - { - case CheckState.Checked: - SetVisibleChecked(e => e.IsAudiblePlus, isChecked: true); - break; - case CheckState.Unchecked: - SetVisibleChecked(e => e.IsAudiblePlus, isChecked: false); - break; - default: - audiblePlusCb.CheckState = CheckState.Unchecked; - break; - } - } - private void everythingCb_CheckStateChanged(object? sender, EventArgs e) - { - switch (everythingCb.CheckState) - { - case CheckState.Checked: - SetVisibleChecked(_ => true, isChecked: true); - break; - case CheckState.Unchecked: - SetVisibleChecked(_ => true, isChecked: false); - break; - default: - everythingCb.CheckState = CheckState.Unchecked; - break; - } - } - - public void SetVisibleChecked(Func<LibraryBook, bool> predicate, bool isChecked) - { - productsGrid1.GetVisibleGridEntries().Where(e => predicate(e.LibraryBook)).ForEach(i => i.Remove = isChecked); - UpdateCounts(); + productsGrid1.Filter(lastGoodFilter); } } + + private void audiblePlusCb_CheckStateChanged(object? sender, EventArgs e) + { + switch (audiblePlusCb.CheckState) + { + case CheckState.Checked: + SetVisibleChecked(e => e.IsAudiblePlus, isChecked: true); + break; + case CheckState.Unchecked: + SetVisibleChecked(e => e.IsAudiblePlus, isChecked: false); + break; + default: + audiblePlusCb.CheckState = CheckState.Unchecked; + break; + } + } + private void everythingCb_CheckStateChanged(object? sender, EventArgs e) + { + switch (everythingCb.CheckState) + { + case CheckState.Checked: + SetVisibleChecked(_ => true, isChecked: true); + break; + case CheckState.Unchecked: + SetVisibleChecked(_ => true, isChecked: false); + break; + default: + everythingCb.CheckState = CheckState.Unchecked; + break; + } + } + + public void SetVisibleChecked(Func<LibraryBook, bool> predicate, bool isChecked) + { + productsGrid1.GetVisibleGridEntries().Where(e => predicate(e.LibraryBook)).ForEach(i => i.Remove = isChecked); + UpdateCounts(); + } } diff --git a/Source/LibationWinForms/Dialogs/UpgradeNotificationDialog.cs b/Source/LibationWinForms/Dialogs/UpgradeNotificationDialog.cs index ae8c18e4..39dca7a3 100644 --- a/Source/LibationWinForms/Dialogs/UpgradeNotificationDialog.cs +++ b/Source/LibationWinForms/Dialogs/UpgradeNotificationDialog.cs @@ -4,53 +4,52 @@ using LibationFileManager; using System; using System.Windows.Forms; -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs; + +public partial class UpgradeNotificationDialog : Form { - public partial class UpgradeNotificationDialog : Form + private string? PackageUrl { get; } + + public UpgradeNotificationDialog() { - private string PackageUrl { get; } + InitializeComponent(); + this.SetLibationIcon(); + } - public UpgradeNotificationDialog() - { - InitializeComponent(); - this.SetLibationIcon(); - } + public UpgradeNotificationDialog(UpgradeProperties upgradeProperties) : this() + { + Text = $"Libation version {upgradeProperties.LatestRelease.ToVersionString()} is now available."; + PackageUrl = upgradeProperties.ZipUrl; + packageDlLink.Text = upgradeProperties.ZipName; + releaseNotesTbox.Text = upgradeProperties.Notes; - public UpgradeNotificationDialog(UpgradeProperties upgradeProperties) : this() - { - Text = $"Libation version {upgradeProperties.LatestRelease.ToVersionString()} is now available."; - PackageUrl = upgradeProperties.ZipUrl; - packageDlLink.Text = upgradeProperties.ZipName; - releaseNotesTbox.Text = upgradeProperties.Notes; + Shown += (_, _) => yesBtn.Focus(); + } - Shown += (_, _) => yesBtn.Focus(); - } + private void PackageDlLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + => Go.To.Url(PackageUrl); - private void PackageDlLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - => Go.To.Url(PackageUrl); + private void GoToGithub_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + => Go.To.Url(LibationScaffolding.RepositoryUrl); - private void GoToGithub_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - => Go.To.Url(LibationScaffolding.RepositoryUrl); + private void GoToWebsite_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + => Go.To.Url(LibationScaffolding.WebsiteUrl); - private void GoToWebsite_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - => Go.To.Url(LibationScaffolding.WebsiteUrl); + private void YesBtn_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.Yes; + Close(); + } - private void YesBtn_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.Yes; - Close(); - } + private void DontRemindBtn_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.Ignore; + Close(); + } - private void DontRemindBtn_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.Ignore; - Close(); - } - - private void NoBtn_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.No; - Close(); - } + private void NoBtn_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.No; + Close(); } } diff --git a/Source/LibationWinForms/Form1.BackupCounts.cs b/Source/LibationWinForms/Form1.BackupCounts.cs index 877e3214..f7af433c 100644 --- a/Source/LibationWinForms/Form1.BackupCounts.cs +++ b/Source/LibationWinForms/Form1.BackupCounts.cs @@ -1,95 +1,92 @@ using ApplicationServices; using DataLayer; -using Dinah.Core; using Dinah.Core.Threading; using System.Collections.Generic; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 + private System.ComponentModel.BackgroundWorker updateCountsBw = new(); + + protected void Configure_BackupCounts() { - private System.ComponentModel.BackgroundWorker updateCountsBw = new(); + // init formattable + beginBookBackupsToolStripMenuItem.Format(0); + beginPdfBackupsToolStripMenuItem.Format(0); - protected void Configure_BackupCounts() + LibraryCommands.LibrarySizeChanged += setBackupCounts; + //Pass null to the runner to get the whole library. + LibraryCommands.BookUserDefinedItemCommitted += (_, _) + => setBackupCounts(null, null); + + updateCountsBw.DoWork += UpdateCountsBw_DoWork; + updateCountsBw.RunWorkerCompleted += exportMenuEnable; + updateCountsBw.RunWorkerCompleted += updateBottomStats; + updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem; + updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem; + } + + private bool runBackupCountsAgain; + + private void setBackupCounts(object? _, List<LibraryBook>? libraryBooks) + { + runBackupCountsAgain = true; + + if (!updateCountsBw.IsBusy) + updateCountsBw.RunWorkerAsync(libraryBooks); + } + + private void UpdateCountsBw_DoWork(object? sender, System.ComponentModel.DoWorkEventArgs e) + { + while (runBackupCountsAgain) { - // init formattable - beginBookBackupsToolStripMenuItem.Format(0); - beginPdfBackupsToolStripMenuItem.Format(0); - - LibraryCommands.LibrarySizeChanged += setBackupCounts; - //Pass null to the runner to get the whole library. - LibraryCommands.BookUserDefinedItemCommitted += (_, _) - => setBackupCounts(null, null); - - updateCountsBw.DoWork += UpdateCountsBw_DoWork; - updateCountsBw.RunWorkerCompleted += exportMenuEnable; - updateCountsBw.RunWorkerCompleted += updateBottomStats; - updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem; - updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem; + runBackupCountsAgain = false; + e.Result = LibraryCommands.GetCounts(e.Argument as IEnumerable<LibraryBook>); } + } - private bool runBackupCountsAgain; + private void exportMenuEnable(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + Invoke(() => exportLibraryToolStripMenuItem.Enabled = libraryStats?.HasBookResults is true); + } - private void setBackupCounts(object? _, List<LibraryBook>? libraryBooks) + private void updateBottomStats(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = libraryStats?.StatusString ?? "ERROR GETTING STATUS"); + } + + // update 'begin book and pdf backups' menu item + private void update_BeginBookBackups_menuItem(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + + var menuItemText + = libraryStats?.HasPendingBooks is true + ? $"{libraryStats.PendingBooks} remaining" + : "All books have been liberated"; + menuStrip1.UIThreadAsync(() => { - runBackupCountsAgain = true; + beginBookBackupsToolStripMenuItem.Format(menuItemText); + beginBookBackupsToolStripMenuItem.Enabled = libraryStats?.HasPendingBooks is true; + }); + } - if (!updateCountsBw.IsBusy) - updateCountsBw.RunWorkerAsync(libraryBooks); - } + // update 'begin pdf only backups' menu item + private void udpate_BeginPdfOnlyBackups_menuItem(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; - private void UpdateCountsBw_DoWork(object? sender, System.ComponentModel.DoWorkEventArgs e) + var menuItemText + = libraryStats?.pdfsNotDownloaded > 0 + ? $"{libraryStats.pdfsNotDownloaded} remaining" + : "All PDFs have been downloaded"; + menuStrip1.UIThreadAsync(() => { - while (runBackupCountsAgain) - { - runBackupCountsAgain = false; - e.Result = LibraryCommands.GetCounts(e.Argument as IEnumerable<LibraryBook>); - } - } - - private void exportMenuEnable(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) - { - var libraryStats = e.Result as LibraryCommands.LibraryStats; - Invoke(() => exportLibraryToolStripMenuItem.Enabled = libraryStats?.HasBookResults is true); - } - - private void updateBottomStats(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) - { - var libraryStats = e.Result as LibraryCommands.LibraryStats; - statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = libraryStats?.StatusString ?? "ERROR GETTING STATUS"); - } - - // update 'begin book and pdf backups' menu item - private void update_BeginBookBackups_menuItem(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) - { - var libraryStats = e.Result as LibraryCommands.LibraryStats; - - var menuItemText - = libraryStats?.HasPendingBooks is true - ? $"{libraryStats.PendingBooks} remaining" - : "All books have been liberated"; - menuStrip1.UIThreadAsync(() => - { - beginBookBackupsToolStripMenuItem.Format(menuItemText); - beginBookBackupsToolStripMenuItem.Enabled = libraryStats?.HasPendingBooks is true; - }); - } - - // update 'begin pdf only backups' menu item - private void udpate_BeginPdfOnlyBackups_menuItem(object? _, System.ComponentModel.RunWorkerCompletedEventArgs e) - { - var libraryStats = e.Result as LibraryCommands.LibraryStats; - - var menuItemText - = libraryStats?.pdfsNotDownloaded > 0 - ? $"{libraryStats.pdfsNotDownloaded} remaining" - : "All PDFs have been downloaded"; - menuStrip1.UIThreadAsync(() => - { - beginPdfBackupsToolStripMenuItem.Format(menuItemText); - beginPdfBackupsToolStripMenuItem.Enabled = libraryStats?.pdfsNotDownloaded > 0; - }); - } - } + beginPdfBackupsToolStripMenuItem.Format(menuItemText); + beginPdfBackupsToolStripMenuItem.Enabled = libraryStats?.pdfsNotDownloaded > 0; + }); + } } diff --git a/Source/LibationWinForms/Form1.Export.cs b/Source/LibationWinForms/Form1.Export.cs index 7a1350d4..a1721680 100644 --- a/Source/LibationWinForms/Form1.Export.cs +++ b/Source/LibationWinForms/Form1.Export.cs @@ -2,47 +2,45 @@ using System.Windows.Forms; using ApplicationServices; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 - { - private void Configure_Export() { } + private void Configure_Export() { } - private void exportLibraryToolStripMenuItem_Click(object sender, EventArgs e) + private void exportLibraryToolStripMenuItem_Click(object sender, EventArgs e) + { + try { - try + var saveFileDialog = new SaveFileDialog { - var saveFileDialog = new SaveFileDialog - { - Title = "Where to export Library", - Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*" - }; + Title = "Where to export Library", + Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*" + }; - if (saveFileDialog.ShowDialog() != DialogResult.OK) - return; + if (saveFileDialog.ShowDialog() != DialogResult.OK) + return; - // FilterIndex is 1-based, NOT 0-based - switch (saveFileDialog.FilterIndex) - { - case 1: // xlsx - default: - LibraryExporter.ToXlsx(saveFileDialog.FileName); - break; - case 2: // csv - LibraryExporter.ToCsv(saveFileDialog.FileName); - break; - case 3: // json - LibraryExporter.ToJson(saveFileDialog.FileName); - break; - } - - MessageBox.Show("Library exported to:\r\n" + saveFileDialog.FileName); - } - catch (Exception ex) + // FilterIndex is 1-based, NOT 0-based + switch (saveFileDialog.FilterIndex) { - MessageBoxLib.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex); + case 1: // xlsx + default: + LibraryExporter.ToXlsx(saveFileDialog.FileName); + break; + case 2: // csv + LibraryExporter.ToCsv(saveFileDialog.FileName); + break; + case 3: // json + LibraryExporter.ToJson(saveFileDialog.FileName); + break; } + + MessageBox.Show("Library exported to:\r\n" + saveFileDialog.FileName); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex); } } } diff --git a/Source/LibationWinForms/Form1.Filter.cs b/Source/LibationWinForms/Form1.Filter.cs index ca705225..522f9a90 100644 --- a/Source/LibationWinForms/Form1.Filter.cs +++ b/Source/LibationWinForms/Form1.Filter.cs @@ -2,76 +2,74 @@ using System.Windows.Forms; using LibationWinForms.Dialogs; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 - { - protected void Configure_Filter() { } + protected void Configure_Filter() { } - private void filterHelpBtn_Click(object sender, EventArgs e) => ShowSearchSyntaxDialog(); + private void filterHelpBtn_Click(object sender, EventArgs e) => ShowSearchSyntaxDialog(); - private void filterSearchTb_TextCleared(object sender, EventArgs e) + private void filterSearchTb_TextCleared(object sender, EventArgs e) + { + performFilter(string.Empty); + } + private void filterSearchTb_KeyPress(object sender, KeyPressEventArgs e) + { + if (e.KeyChar == (char)Keys.Return) { - performFilter(string.Empty); + performFilter(this.filterSearchTb.Text); + + // silence the 'ding' + e.Handled = true; } - private void filterSearchTb_KeyPress(object sender, KeyPressEventArgs e) - { - if (e.KeyChar == (char)Keys.Return) - { - performFilter(this.filterSearchTb.Text); + } - // silence the 'ding' - e.Handled = true; - } + private void filterBtn_Click(object sender, EventArgs e) => performFilter(this.filterSearchTb.Text); + + private string? lastGoodFilter = null; + private void performFilter(string? filterString) + { + this.filterSearchTb.Text = filterString; + + try + { + productsDisplay.Filter(filterString); + lastGoodFilter = filterString; } - - private void filterBtn_Click(object sender, EventArgs e) => performFilter(this.filterSearchTb.Text); - - private string? lastGoodFilter = null; - private void performFilter(string? filterString) + catch (Exception ex) { - this.filterSearchTb.Text = filterString; + MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error); - try - { - productsDisplay.Filter(filterString); - lastGoodFilter = filterString; - } - catch (Exception ex) - { - MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error); - - // re-apply last good filter - performFilter(lastGoodFilter); - } + // re-apply last good filter + performFilter(lastGoodFilter); } + } - public SearchSyntaxDialog ShowSearchSyntaxDialog() + public SearchSyntaxDialog ShowSearchSyntaxDialog() + { + var dialog = new SearchSyntaxDialog(); + dialog.TagDoubleClicked += Dialog_TagDoubleClicked; + dialog.FormClosed += Dialog_Closed; + filterHelpBtn.Enabled = false; + dialog.Show(this); + return dialog; + + void Dialog_Closed(object? sender, FormClosedEventArgs e) { - var dialog = new SearchSyntaxDialog(); - dialog.TagDoubleClicked += Dialog_TagDoubleClicked; - dialog.FormClosed += Dialog_Closed; - filterHelpBtn.Enabled = false; - dialog.Show(this); - return dialog; + dialog.TagDoubleClicked -= Dialog_TagDoubleClicked; + filterHelpBtn.Enabled = true; + } + void Dialog_TagDoubleClicked(object? sender, string tag) + { + if (string.IsNullOrEmpty(tag)) return; - void Dialog_Closed(object? sender, FormClosedEventArgs e) - { - dialog.TagDoubleClicked -= Dialog_TagDoubleClicked; - filterHelpBtn.Enabled = true; - } - void Dialog_TagDoubleClicked(object? sender, string tag) - { - if (string.IsNullOrEmpty(tag)) return; + var text = filterSearchTb.Text; + var selStart = Math.Min(Math.Max(0, filterSearchTb.SelectionStart), text.Length); - var text = filterSearchTb.Text; - var selStart = Math.Min(Math.Max(0, filterSearchTb.SelectionStart), text.Length); - - filterSearchTb.Text = text.Insert(selStart, tag); - filterSearchTb.SelectionStart = selStart + tag.Length; - filterSearchTb.Focus(); - } + filterSearchTb.Text = text.Insert(selStart, tag); + filterSearchTb.SelectionStart = selStart + tag.Length; + filterSearchTb.Focus(); } } } diff --git a/Source/LibationWinForms/Form1.Liberate.cs b/Source/LibationWinForms/Form1.Liberate.cs index 7f63d123..6eea9ad5 100644 --- a/Source/LibationWinForms/Form1.Liberate.cs +++ b/Source/LibationWinForms/Form1.Liberate.cs @@ -6,55 +6,53 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 + private void Configure_Liberate() { } + + //GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread + private async void beginBookBackupsToolStripMenuItem_Click(object? _ = null, EventArgs? __ = null) { - private void Configure_Liberate() { } + var library = await Task.Run(DbContexts.GetUnliberated_Flat_NoTracking); + BackupAllBooks(library); + } - //GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread - private async void beginBookBackupsToolStripMenuItem_Click(object? _ = null, EventArgs? __ = null) + private void BackupAllBooks(IEnumerable<LibraryBook> books) + { + try { - var library = await Task.Run(DbContexts.GetUnliberated_Flat_NoTracking); - BackupAllBooks(library); - } - - private void BackupAllBooks(IEnumerable<LibraryBook> books) - { - try + var unliberated = books.UnLiberated().ToArray(); + Invoke(() => { - var unliberated = books.UnLiberated().ToArray(); - Invoke(() => - { - if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated)) - SetQueueCollapseState(false); - }); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "An error occurred while backing up all library books"); - } + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated)) + SetQueueCollapseState(false); + }); } - - private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e) + catch (Exception ex) { - if (processBookQueue1.ViewModel.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) - SetQueueCollapseState(false); - } - - private async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, EventArgs e) - { - var result = MessageBox.Show( - "This converts all m4b titles in your library to mp3 files. Original files are not deleted." - + "\r\nFor large libraries this will take a long time and will take up more disk space." - + "\r\n\r\nContinue?" - + "\r\n\r\n(To always download titles as mp3 instead of m4b, go to Settings: Download my books as .MP3 files)", - "Convert all M4b => Mp3?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Warning); - if (result == DialogResult.Yes && processBookQueue1.ViewModel.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) - SetQueueCollapseState(false); + Serilog.Log.Logger.Error(ex, "An error occurred while backing up all library books"); } } + + private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e) + { + if (processBookQueue1.ViewModel.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) + SetQueueCollapseState(false); + } + + private async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, EventArgs e) + { + var result = MessageBox.Show( + "This converts all m4b titles in your library to mp3 files. Original files are not deleted." + + "\r\nFor large libraries this will take a long time and will take up more disk space." + + "\r\n\r\nContinue?" + + "\r\n\r\n(To always download titles as mp3 instead of m4b, go to Settings: Download my books as .MP3 files)", + "Convert all M4b => Mp3?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + if (result == DialogResult.Yes && processBookQueue1.ViewModel.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) + SetQueueCollapseState(false); + } } diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index 14c1569f..aa0ddf9a 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -7,137 +7,135 @@ using System; using System.Linq; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms -{ - public partial class Form1 - { - int WidthChange = 0; - private void Configure_ProcessQueue() - { - processBookQueue1.PopoutButton.Click += ProcessBookQueue1_PopOut; - - WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; - int width = this.Width; - var coppalseState = Configuration.Instance.GetNonString(defaultValue: false, nameof(splitContainer1.Panel2Collapsed)); - SetQueueCollapseState(coppalseState); - this.Width = width; - } +namespace LibationWinForms; - private void ProductsDisplay_LiberateClicked(object sender, System.Collections.Generic.IList<LibraryBook> libraryBooks, Configuration config) +public partial class Form1 +{ + int WidthChange = 0; + private void Configure_ProcessQueue() + { + processBookQueue1.PopoutButton.Click += ProcessBookQueue1_PopOut; + + WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; + int width = this.Width; + var coppalseState = Configuration.Instance.GetNonString(defaultValue: false, nameof(splitContainer1.Panel2Collapsed)); + SetQueueCollapseState(coppalseState); + this.Width = width; + } + + private void ProductsDisplay_LiberateClicked(object sender, System.Collections.Generic.IList<LibraryBook> libraryBooks, Configuration config) + { + try { - try + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks, config)) + SetQueueCollapseState(false); + else if (libraryBooks.Count == 1 && libraryBooks[0].Book.AudioExists) { - if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks, config)) - SetQueueCollapseState(false); - else if (libraryBooks.Count == 1 && libraryBooks[0].Book.AudioExists) + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId); + if (!Go.To.File(filePath?.ShortPathName)) { - // liberated: open explorer to file - var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId); - if (!Go.To.File(filePath?.ShortPathName)) - { - var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; - MessageBox.Show($"File not found" + suffix); - } + var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; + MessageBox.Show($"File not found" + suffix); } } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); - } } - - private void ProductsDisplay_LiberateSeriesClicked(object sender, SeriesEntry series) + catch (Exception ex) { - try - { - Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); - - if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray())) - SetQueueCollapseState(false); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "An error occurred while backing up {series} episodes", series.LibraryBook); - } + Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); } + } - private void ProductsDisplay_ConvertToMp3Clicked(object sender, LibraryBook[] libraryBooks) + private void ProductsDisplay_LiberateSeriesClicked(object sender, SeriesEntry series) + { + try { - try - { - if (processBookQueue1.ViewModel.QueueConvertToMp3(libraryBooks)) - SetQueueCollapseState(false); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); - } + Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); + + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray())) + SetQueueCollapseState(false); } - - private void SetQueueCollapseState(bool collapsed) + catch (Exception ex) { - if (collapsed && !splitContainer1.Panel2Collapsed) - { - WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; - splitContainer1.Panel2.Controls.Remove(processBookQueue1); - splitContainer1.Panel2Collapsed = true; - Width -= WidthChange; - } - else if (!collapsed && splitContainer1.Panel2Collapsed) - { - if (!processBookQueue1.PopoutButton.Visible) - //Queue is in popout mode. Do nothing. - return; - - Width += WidthChange; - splitContainer1.Panel2.Controls.Add(processBookQueue1); - splitContainer1.Panel2Collapsed = false; - processBookQueue1.PopoutButton.Visible = true; - } - - Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed)); - toggleQueueHideBtn.Text = splitContainer1.Panel2Collapsed ? "❰❰❰" : "❱❱❱"; + Serilog.Log.Logger.Error(ex, "An error occurred while backing up {series} episodes", series.LibraryBook); } + } - private void ToggleQueueHideBtn_Click(object sender, EventArgs e) + private void ProductsDisplay_ConvertToMp3Clicked(object sender, LibraryBook[] libraryBooks) + { + try { - SetQueueCollapseState(!splitContainer1.Panel2Collapsed); + if (processBookQueue1.ViewModel.QueueConvertToMp3(libraryBooks)) + SetQueueCollapseState(false); } - - private void ProcessBookQueue1_PopOut(object? sender, EventArgs e) + catch (Exception ex) { - ProcessBookForm dockForm = new(); - dockForm.WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; - dockForm.RestoreSizeAndLocation(Configuration.Instance); - dockForm.FormClosing += DockForm_FormClosing; + Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); + } + } + + private void SetQueueCollapseState(bool collapsed) + { + if (collapsed && !splitContainer1.Panel2Collapsed) + { + WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; splitContainer1.Panel2.Controls.Remove(processBookQueue1); splitContainer1.Panel2Collapsed = true; - processBookQueue1.PopoutButton.Visible = false; - dockForm.PassControl(processBookQueue1); - dockForm.Show(); - this.Width -= dockForm.WidthChange; - toggleQueueHideBtn.Visible = false; - int deltax = filterBtn.Margin.Right + toggleQueueHideBtn.Width + toggleQueueHideBtn.Margin.Left; - filterBtn.Location = new System.Drawing.Point(filterBtn.Location.X + deltax, filterBtn.Location.Y); - filterSearchTb.Location = new System.Drawing.Point(filterSearchTb.Location.X + deltax, filterSearchTb.Location.Y); + Width -= WidthChange; + } + else if (!collapsed && splitContainer1.Panel2Collapsed) + { + if (!processBookQueue1.PopoutButton.Visible) + //Queue is in popout mode. Do nothing. + return; + + Width += WidthChange; + splitContainer1.Panel2.Controls.Add(processBookQueue1); + splitContainer1.Panel2Collapsed = false; + processBookQueue1.PopoutButton.Visible = true; } - private void DockForm_FormClosing(object? sender, FormClosingEventArgs e) + Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed)); + toggleQueueHideBtn.Text = splitContainer1.Panel2Collapsed ? "❰❰❰" : "❱❱❱"; + } + + private void ToggleQueueHideBtn_Click(object sender, EventArgs e) + { + SetQueueCollapseState(!splitContainer1.Panel2Collapsed); + } + + private void ProcessBookQueue1_PopOut(object? sender, EventArgs e) + { + ProcessBookForm dockForm = new(); + dockForm.WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; + dockForm.RestoreSizeAndLocation(Configuration.Instance); + dockForm.FormClosing += DockForm_FormClosing; + splitContainer1.Panel2.Controls.Remove(processBookQueue1); + splitContainer1.Panel2Collapsed = true; + processBookQueue1.PopoutButton.Visible = false; + dockForm.PassControl(processBookQueue1); + dockForm.Show(); + this.Width -= dockForm.WidthChange; + toggleQueueHideBtn.Visible = false; + int deltax = filterBtn.Margin.Right + toggleQueueHideBtn.Width + toggleQueueHideBtn.Margin.Left; + filterBtn.Location = new System.Drawing.Point(filterBtn.Location.X + deltax, filterBtn.Location.Y); + filterSearchTb.Location = new System.Drawing.Point(filterSearchTb.Location.X + deltax, filterSearchTb.Location.Y); + } + + private void DockForm_FormClosing(object? sender, FormClosingEventArgs e) + { + if (sender is ProcessBookForm dockForm) { - if (sender is ProcessBookForm dockForm) - { - this.Width += dockForm.WidthChange; - splitContainer1.Panel2.Controls.Add(dockForm.RegainControl()); - splitContainer1.Panel2Collapsed = false; - processBookQueue1.PopoutButton.Visible = true; - dockForm.SaveSizeAndLocation(Configuration.Instance); - this.Focus(); - toggleQueueHideBtn.Visible = true; - int deltax = filterBtn.Margin.Right + toggleQueueHideBtn.Width + toggleQueueHideBtn.Margin.Left; - filterBtn.Location = new System.Drawing.Point(filterBtn.Location.X - deltax, filterBtn.Location.Y); - filterSearchTb.Location = new System.Drawing.Point(filterSearchTb.Location.X - deltax, filterSearchTb.Location.Y); - } + this.Width += dockForm.WidthChange; + splitContainer1.Panel2.Controls.Add(dockForm.RegainControl()); + splitContainer1.Panel2Collapsed = false; + processBookQueue1.PopoutButton.Visible = true; + dockForm.SaveSizeAndLocation(Configuration.Instance); + this.Focus(); + toggleQueueHideBtn.Visible = true; + int deltax = filterBtn.Margin.Right + toggleQueueHideBtn.Width + toggleQueueHideBtn.Margin.Left; + filterBtn.Location = new System.Drawing.Point(filterBtn.Location.X - deltax, filterBtn.Location.Y); + filterSearchTb.Location = new System.Drawing.Point(filterSearchTb.Location.X - deltax, filterSearchTb.Location.Y); } } } diff --git a/Source/LibationWinForms/Form1.QuickFilters.cs b/Source/LibationWinForms/Form1.QuickFilters.cs index 6327a185..16af607f 100644 --- a/Source/LibationWinForms/Form1.QuickFilters.cs +++ b/Source/LibationWinForms/Form1.QuickFilters.cs @@ -4,77 +4,75 @@ using System.Windows.Forms; using LibationFileManager; using LibationWinForms.Dialogs; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 - { - private void Configure_QuickFilters() - { - Load += updateFirstFilterIsDefaultToolStripMenuItem; - Load += updateFiltersMenu; - QuickFilters.UseDefaultChanged += updateFirstFilterIsDefaultToolStripMenuItem; - QuickFilters.Updated += updateFiltersMenu; - } + private void Configure_QuickFilters() + { + Load += updateFirstFilterIsDefaultToolStripMenuItem; + Load += updateFiltersMenu; + QuickFilters.UseDefaultChanged += updateFirstFilterIsDefaultToolStripMenuItem; + QuickFilters.Updated += updateFiltersMenu; + } - private object quickFilterTag { get; } = new(); - private void updateFiltersMenu(object? _ = null, object? __ = null) - { - // remove old - var removeUs = quickFiltersToolStripMenuItem.DropDownItems - .Cast<ToolStripItem>() - .Where(item => item.Tag == quickFilterTag) - .ToList(); - foreach (var item in removeUs) - quickFiltersToolStripMenuItem.DropDownItems.Remove(item); + private object quickFilterTag { get; } = new(); + private void updateFiltersMenu(object? _ = null, object? __ = null) + { + // remove old + var removeUs = quickFiltersToolStripMenuItem.DropDownItems + .Cast<ToolStripItem>() + .Where(item => item.Tag == quickFilterTag) + .ToList(); + foreach (var item in removeUs) + quickFiltersToolStripMenuItem.DropDownItems.Remove(item); - // re-populate - var index = 0; - foreach (var filter in QuickFilters.Filters) + // re-populate + var index = 0; + foreach (var filter in QuickFilters.Filters) + { + var quickFilterMenuItem = new ToolStripMenuItem { - var quickFilterMenuItem = new ToolStripMenuItem - { - Tag = quickFilterTag, - Text = $"&{++index}: {(string.IsNullOrWhiteSpace(filter.Name) ? filter.Filter : filter.Name)}" - }; - quickFilterMenuItem.Click += (_, __) => performFilter(filter.Filter); - quickFiltersToolStripMenuItem.DropDownItems.Add(quickFilterMenuItem); - } + Tag = quickFilterTag, + Text = $"&{++index}: {(string.IsNullOrWhiteSpace(filter.Name) ? filter.Filter : filter.Name)}" + }; + quickFilterMenuItem.Click += (_, __) => performFilter(filter.Filter); + quickFiltersToolStripMenuItem.DropDownItems.Add(quickFilterMenuItem); } + } - private void updateFirstFilterIsDefaultToolStripMenuItem(object? sender, EventArgs e) - => firstFilterIsDefaultToolStripMenuItem.Checked = QuickFilters.UseDefault; + private void updateFirstFilterIsDefaultToolStripMenuItem(object? sender, EventArgs e) + => firstFilterIsDefaultToolStripMenuItem.Checked = QuickFilters.UseDefault; - private void firstFilterIsDefaultToolStripMenuItem_Click(object sender, EventArgs e) - => QuickFilters.UseDefault = !firstFilterIsDefaultToolStripMenuItem.Checked; + private void firstFilterIsDefaultToolStripMenuItem_Click(object sender, EventArgs e) + => QuickFilters.UseDefault = !firstFilterIsDefaultToolStripMenuItem.Checked; - private void addQuickFilterBtn_Click(object sender, EventArgs e) - { - QuickFilters.Add(new QuickFilters.NamedFilter(this.filterSearchTb.Text, null)); - } + private void addQuickFilterBtn_Click(object sender, EventArgs e) + { + QuickFilters.Add(new QuickFilters.NamedFilter(this.filterSearchTb.Text, null)); + } - private void editQuickFiltersToolStripMenuItem_Click(object sender, EventArgs e) => new EditQuickFilters().ShowDialog(); + private void editQuickFiltersToolStripMenuItem_Click(object sender, EventArgs e) => new EditQuickFilters().ShowDialog(); - private void productsDisplay_InitialLoaded(object sender, EventArgs e) + private void productsDisplay_InitialLoaded(object sender, EventArgs e) + { + if (QuickFilters.UseDefault) { - if (QuickFilters.UseDefault) - { - // begin verbose null checking. shouldn't be possible, yet NRE in #1578 - var f = QuickFilters.Filters; - if (f is null) - Serilog.Log.Logger.Error("Unexpected exception. QuickFilters.Filters is null"); + // begin verbose null checking. shouldn't be possible, yet NRE in #1578 + var f = QuickFilters.Filters; + if (f is null) + Serilog.Log.Logger.Error("Unexpected exception. QuickFilters.Filters is null"); - var first = f.FirstOrDefault(); - if (first is null) - Serilog.Log.Logger.Information("QuickFilters.Filters.FirstOrDefault() is null"); + var first = f?.FirstOrDefault(); + if (first is null) + Serilog.Log.Logger.Information("QuickFilters.Filters.FirstOrDefault() is null"); - var filter = first?.Filter; - if (filter is null) - Serilog.Log.Logger.Information("QuickFilters.Filters.FirstOrDefault()?.Filter is null"); - // end verbose null checking + var filter = first?.Filter; + if (filter is null) + Serilog.Log.Logger.Information("QuickFilters.Filters.FirstOrDefault()?.Filter is null"); + // end verbose null checking - performFilter(filter); - } + performFilter(filter); } } } diff --git a/Source/LibationWinForms/Form1.RemoveBooks.cs b/Source/LibationWinForms/Form1.RemoveBooks.cs index 8de5f238..1b23410b 100644 --- a/Source/LibationWinForms/Form1.RemoveBooks.cs +++ b/Source/LibationWinForms/Form1.RemoveBooks.cs @@ -4,90 +4,88 @@ using System; using System.Linq; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 + public void Configure_RemoveBooks() { } + + private async void removeBooksBtn_Click(object sender, EventArgs e) + => await productsDisplay.RemoveCheckedBooksAsync(); + + private void openTrashBinToolStripMenuItem_Click(object sender, EventArgs e) + => new TrashBinDialog().ShowDialog(this); + + private void doneRemovingBtn_Click(object sender, EventArgs e) { - public void Configure_RemoveBooks() { } + removeBooksBtn.Visible = false; + doneRemovingBtn.Visible = false; - private async void removeBooksBtn_Click(object sender, EventArgs e) - => await productsDisplay.RemoveCheckedBooksAsync(); + productsDisplay.CloseRemoveBooksColumn(); - private void openTrashBinToolStripMenuItem_Click(object sender, EventArgs e) - => new TrashBinDialog().ShowDialog(this); + //Restore the filter + filterSearchTb.Enabled = true; + filterSearchTb.Visible = true; + performFilter(filterSearchTb.Text); + } - private void doneRemovingBtn_Click(object sender, EventArgs e) + private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e) + { + // if 0 accounts, this will not be visible + // if 1 account, run scanLibrariesRemovedBooks() on this account + // if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks() + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings.GetAll(); + + if (accounts.Count != 1) + return; + + var firstAccount = accounts.Single(); + scanLibrariesRemovedBooks(firstAccount); + } + + // selectively remove books from all accounts + private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var allAccounts = persister.AccountsSettings.GetAll(); + scanLibrariesRemovedBooks(allAccounts.ToArray()); + } + + // selectively remove books from some accounts + private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e) + { + using var scanAccountsDialog = new ScanAccountsDialog(); + + if (scanAccountsDialog.ShowDialog() != DialogResult.OK) + return; + + if (!scanAccountsDialog.CheckedAccounts.Any()) + return; + + scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray()); + } + + private async void scanLibrariesRemovedBooks(params Account[] accounts) + { + //This action is meant to operate on the entire library. + //For removing books within a filter set, use + //Visible Books > Remove from library + filterSearchTb.Enabled = false; + filterSearchTb.Visible = false; + productsDisplay.Filter(null); + + removeBooksBtn.Visible = true; + doneRemovingBtn.Visible = true; + await productsDisplay.ScanAndRemoveBooksAsync(accounts); + } + + private void productsDisplay_RemovableCountChanged(object sender, int removeCount) + { + removeBooksBtn.Text = removeCount switch { - removeBooksBtn.Visible = false; - doneRemovingBtn.Visible = false; - - productsDisplay.CloseRemoveBooksColumn(); - - //Restore the filter - filterSearchTb.Enabled = true; - filterSearchTb.Visible = true; - performFilter(filterSearchTb.Text); - } - - private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e) - { - // if 0 accounts, this will not be visible - // if 1 account, run scanLibrariesRemovedBooks() on this account - // if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks() - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var accounts = persister.AccountsSettings.GetAll(); - - if (accounts.Count != 1) - return; - - var firstAccount = accounts.Single(); - scanLibrariesRemovedBooks(firstAccount); - } - - // selectively remove books from all accounts - private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e) - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var allAccounts = persister.AccountsSettings.GetAll(); - scanLibrariesRemovedBooks(allAccounts.ToArray()); - } - - // selectively remove books from some accounts - private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e) - { - using var scanAccountsDialog = new ScanAccountsDialog(); - - if (scanAccountsDialog.ShowDialog() != DialogResult.OK) - return; - - if (!scanAccountsDialog.CheckedAccounts.Any()) - return; - - scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray()); - } - - private async void scanLibrariesRemovedBooks(params Account[] accounts) - { - //This action is meant to operate on the entire library. - //For removing books within a filter set, use - //Visible Books > Remove from library - filterSearchTb.Enabled = false; - filterSearchTb.Visible = false; - productsDisplay.Filter(null); - - removeBooksBtn.Visible = true; - doneRemovingBtn.Visible = true; - await productsDisplay.ScanAndRemoveBooksAsync(accounts); - } - - private void productsDisplay_RemovableCountChanged(object sender, int removeCount) - { - removeBooksBtn.Text = removeCount switch - { - 1 => "Remove 1 Book from Libation", - _ => $"Remove {removeCount} Books from Libation" - }; - } + 1 => "Remove 1 Book from Libation", + _ => $"Remove {removeCount} Books from Libation" + }; } } diff --git a/Source/LibationWinForms/Form1.ScanAuto.cs b/Source/LibationWinForms/Form1.ScanAuto.cs index 21901e3f..5664d19f 100644 --- a/Source/LibationWinForms/Form1.ScanAuto.cs +++ b/Source/LibationWinForms/Form1.ScanAuto.cs @@ -7,96 +7,95 @@ using AudibleUtilities; using Dinah.Core; using LibationFileManager; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +// This is for the auto-scanner. It is unrelated to manual scanning/import +public partial class Form1 { - // This is for the auto-scanner. It is unrelated to manual scanning/import - public partial class Form1 + private InterruptableTimer? autoScanTimer; + + private void Configure_ScanAuto() { - private InterruptableTimer autoScanTimer; + // creating InterruptableTimer inside 'Configure_' is a break from the pattern. As long as no one else needs to access or subscribe to it, this is ok - private void Configure_ScanAuto() - { - // creating InterruptableTimer inside 'Configure_' is a break from the pattern. As long as no one else needs to access or subscribe to it, this is ok + autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5)); - autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5)); - - // subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI - autoScanTimer.Elapsed += async (_, __) => - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var accounts = persister.AccountsSettings - .GetAll() - .Where(a => a.LibraryScan) - .ToArray(); - - // in autoScan, new books SHALL NOT show dialog - try - { - await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts)); - } - catch (OperationCanceledException) - { - Serilog.Log.Information("Audible login attempt cancelled by user"); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); - } - }; - - // load init state to menu checkbox - Load += updateAutoScanLibraryToolStripMenuItem; - // if enabled: begin on load - Shown += startAutoScan; - - // if new 'default' account is added, run autoscan - AccountsSettingsPersister.Saving += accountsPreSave; - AccountsSettingsPersister.Saved += accountsPostSave; - - Configuration.Instance.PropertyChanged += Configuration_PropertyChanged; - } - - - [PropertyChangeFilter(nameof(Configuration.AutoScan))] - private void Configuration_PropertyChanged(object? sender, PropertyChangedEventArgsEx e) - { - // when autoscan setting is changed, update menu checkbox and run autoscan - updateAutoScanLibraryToolStripMenuItem(sender, e); - startAutoScan(sender, e); - } - - private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts; - private List<(string AccountId, string LocaleName)> getDefaultAccounts() + // subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI + autoScanTimer.Elapsed += async (_, __) => { using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - return persister.AccountsSettings + var accounts = persister.AccountsSettings .GetAll() .Where(a => a.LibraryScan) - .Select(a => (a.AccountId, a.Locale.Name)) - .ToList(); - } - private void accountsPreSave(object? sender = null, EventArgs? e = null) - => preSaveDefaultAccounts = getDefaultAccounts(); - private void accountsPostSave(object? sender = null, EventArgs? e = null) - { - var postSaveDefaultAccounts = getDefaultAccounts(); - var newDefaultAccounts = postSaveDefaultAccounts.Except(preSaveDefaultAccounts).ToList(); + .ToArray(); - if (newDefaultAccounts.Any()) - startAutoScan(); - } + // in autoScan, new books SHALL NOT show dialog + try + { + await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts)); + } + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); + } + }; - private void startAutoScan(object? sender = null, EventArgs? e = null) - { - if (Configuration.Instance.AutoScan) - autoScanTimer.PerformNow(); - else - autoScanTimer.Stop(); - } + // load init state to menu checkbox + Load += updateAutoScanLibraryToolStripMenuItem; + // if enabled: begin on load + Shown += startAutoScan; - private void updateAutoScanLibraryToolStripMenuItem(object? sender, EventArgs e) => autoScanLibraryToolStripMenuItem.Checked = Configuration.Instance.AutoScan; + // if new 'default' account is added, run autoscan + AccountsSettingsPersister.Saving += accountsPreSave; + AccountsSettingsPersister.Saved += accountsPostSave; - private void autoScanLibraryToolStripMenuItem_Click(object? sender, EventArgs e) => Configuration.Instance.AutoScan = !autoScanLibraryToolStripMenuItem.Checked; + Configuration.Instance.PropertyChanged += Configuration_PropertyChanged; } + + + [PropertyChangeFilter(nameof(Configuration.AutoScan))] + private void Configuration_PropertyChanged(object? sender, PropertyChangedEventArgsEx e) + { + // when autoscan setting is changed, update menu checkbox and run autoscan + updateAutoScanLibraryToolStripMenuItem(sender, e); + startAutoScan(sender, e); + } + + private List<(string AccountId, string LocaleName)>? preSaveDefaultAccounts; + private List<(string AccountId, string LocaleName)> getDefaultAccounts() + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + return persister.AccountsSettings + .GetAll() + .Where(a => a.LibraryScan) + .Select(a => (a.AccountId, a.Locale?.Name)) + .OfType<(string, string)>() + .ToList(); + } + private void accountsPreSave(object? sender = null, EventArgs? e = null) + => preSaveDefaultAccounts = getDefaultAccounts(); + private void accountsPostSave(object? sender = null, EventArgs? e = null) + { + var postSaveDefaultAccounts = getDefaultAccounts(); + var newDefaultAccounts = postSaveDefaultAccounts.Except(preSaveDefaultAccounts ?? []).ToList(); + + if (newDefaultAccounts.Any()) + startAutoScan(); + } + + private void startAutoScan(object? sender = null, EventArgs? e = null) + { + if (Configuration.Instance.AutoScan) + autoScanTimer?.PerformNow(); + else + autoScanTimer?.Stop(); + } + + private void updateAutoScanLibraryToolStripMenuItem(object? sender, EventArgs e) => autoScanLibraryToolStripMenuItem.Checked = Configuration.Instance.AutoScan; + + private void autoScanLibraryToolStripMenuItem_Click(object? sender, EventArgs e) => Configuration.Instance.AutoScan = !autoScanLibraryToolStripMenuItem.Checked; } diff --git a/Source/LibationWinForms/Form1.ScanManual.cs b/Source/LibationWinForms/Form1.ScanManual.cs index 7b0d49ec..d295d344 100644 --- a/Source/LibationWinForms/Form1.ScanManual.cs +++ b/Source/LibationWinForms/Form1.ScanManual.cs @@ -8,106 +8,104 @@ using AudibleUtilities; using LibationFileManager; using LibationWinForms.Dialogs; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +// this is for manual scan/import. Unrelated to auto-scan +public partial class Form1 { - // this is for manual scan/import. Unrelated to auto-scan - public partial class Form1 + private void Configure_ScanManual() { - private void Configure_ScanManual() - { - this.Load += refreshImportMenu; - AccountsSettingsPersister.Saved += (_, _) => Invoke(refreshImportMenu, null, null); - locateAudiobooksToolStripMenuItem.ToolTipText = Configuration.GetHelpText("LocateAudiobooks"); + this.Load += refreshImportMenu; + AccountsSettingsPersister.Saved += (_, _) => Invoke(refreshImportMenu, null, null); + locateAudiobooksToolStripMenuItem.ToolTipText = Configuration.GetHelpText("LocateAudiobooks"); + } + + private void refreshImportMenu(object? _, EventArgs? __) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var count = persister.AccountsSettings.Accounts.Count; + + autoScanLibraryToolStripMenuItem.Visible = count > 0; + + noAccountsYetAddAccountToolStripMenuItem.Visible = count == 0; + scanLibraryToolStripMenuItem.Visible = count == 1; + scanLibraryOfAllAccountsToolStripMenuItem.Visible = count > 1; + scanLibraryOfSomeAccountsToolStripMenuItem.Visible = count > 1; + + removeLibraryBooksToolStripMenuItem.Visible = count > 0; + removeSomeAccountsToolStripMenuItem.Visible = count > 1; + removeAllAccountsToolStripMenuItem.Visible = count > 1; + } + + private void noAccountsYetAddAccountToolStripMenuItem_Click(object sender, EventArgs e) + { + MessageBox.Show("To load your Audible library, come back here to the Import menu after adding your account"); + new AccountsDialog().ShowDialog(); + } + + private async void scanLibraryToolStripMenuItem_Click(object sender, EventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + if (persister.AccountsSettings.GetAll().FirstOrDefault() is { } firstAccount) + await scanLibrariesAsync(firstAccount); + } + + private async void scanLibraryOfAllAccountsToolStripMenuItem_Click(object sender, EventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var allAccounts = persister.AccountsSettings.GetAll(); + await scanLibrariesAsync(allAccounts); + } + + private async void scanLibraryOfSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e) + { + using var scanAccountsDialog = new ScanAccountsDialog(); + + if (scanAccountsDialog.ShowDialog() != DialogResult.OK) + return; + + if (!scanAccountsDialog.CheckedAccounts.Any()) + return; + + await scanLibrariesAsync(scanAccountsDialog.CheckedAccounts); + } + + private async Task scanLibrariesAsync(IEnumerable<Account> accounts) => await scanLibrariesAsync(accounts.ToArray()); + private async Task scanLibrariesAsync(params Account[] accounts) + { + try + { + var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts)); + + // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop + if (Configuration.Instance.ShowImportedStats && newAdded > 0) + MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); } - - private void refreshImportMenu(object? _, EventArgs? __) + catch (OperationCanceledException) { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var count = persister.AccountsSettings.Accounts.Count; - - autoScanLibraryToolStripMenuItem.Visible = count > 0; - - noAccountsYetAddAccountToolStripMenuItem.Visible = count == 0; - scanLibraryToolStripMenuItem.Visible = count == 1; - scanLibraryOfAllAccountsToolStripMenuItem.Visible = count > 1; - scanLibraryOfSomeAccountsToolStripMenuItem.Visible = count > 1; - - removeLibraryBooksToolStripMenuItem.Visible = count > 0; - removeSomeAccountsToolStripMenuItem.Visible = count > 1; - removeAllAccountsToolStripMenuItem.Visible = count > 1; + Serilog.Log.Information("Audible login attempt cancelled by user"); } - - private void noAccountsYetAddAccountToolStripMenuItem_Click(object sender, EventArgs e) + catch (Exception ex) { - MessageBox.Show("To load your Audible library, come back here to the Import menu after adding your account"); - new AccountsDialog().ShowDialog(); - } - - private async void scanLibraryToolStripMenuItem_Click(object sender, EventArgs e) - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - if (persister.AccountsSettings.GetAll().FirstOrDefault() is { } firstAccount) - await scanLibrariesAsync(firstAccount); - } - - private async void scanLibraryOfAllAccountsToolStripMenuItem_Click(object sender, EventArgs e) - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var allAccounts = persister.AccountsSettings.GetAll(); - await scanLibrariesAsync(allAccounts); - } - - private async void scanLibraryOfSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e) - { - using var scanAccountsDialog = new ScanAccountsDialog(); - - if (scanAccountsDialog.ShowDialog() != DialogResult.OK) - return; - - if (!scanAccountsDialog.CheckedAccounts.Any()) - return; - - await scanLibrariesAsync(scanAccountsDialog.CheckedAccounts); - } - - private async Task scanLibrariesAsync(IEnumerable<Account> accounts) => await scanLibrariesAsync(accounts.ToArray()); - private async Task scanLibrariesAsync(params Account[] accounts) - { - try - { - var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts)); - - // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop - if (Configuration.Instance.ShowImportedStats && newAdded > 0) - MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); - } - catch (OperationCanceledException) - { - Serilog.Log.Information("Audible login attempt cancelled by user"); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - this, - "Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator", - "Error importing library", - ex); - } - } - - private void locateAudiobooksToolStripMenuItem_Click(object sender, EventArgs e) - { - var result = MessageBox.Show( + MessageBoxLib.ShowAdminAlert( this, - Configuration.GetHelpText(nameof(LocateAudiobooksDialog)), - "Locate Previously-Liberated Audiobook Files", - MessageBoxButtons.OKCancel, - MessageBoxIcon.Information, - MessageBoxDefaultButton.Button1); - - if (result is DialogResult.OK) - new LocateAudiobooksDialog().ShowDialog(); + "Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator", + "Error importing library", + ex); } } + + private void locateAudiobooksToolStripMenuItem_Click(object sender, EventArgs e) + { + var result = MessageBox.Show( + this, + Configuration.GetHelpText(nameof(LocateAudiobooksDialog)), + "Locate Previously-Liberated Audiobook Files", + MessageBoxButtons.OKCancel, + MessageBoxIcon.Information, + MessageBoxDefaultButton.Button1); + + if (result is DialogResult.OK) + new LocateAudiobooksDialog().ShowDialog(); + } } diff --git a/Source/LibationWinForms/Form1.ScanNotification.cs b/Source/LibationWinForms/Form1.ScanNotification.cs index a19b4c06..004e597b 100644 --- a/Source/LibationWinForms/Form1.ScanNotification.cs +++ b/Source/LibationWinForms/Form1.ScanNotification.cs @@ -1,45 +1,42 @@ -using System; -using ApplicationServices; +using ApplicationServices; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +// This is for the Scanning notification in the upper right. This shown for manual scanning and auto-scan +public partial class Form1 { - // This is for the Scanning notification in the upper right. This shown for manual scanning and auto-scan - public partial class Form1 - { - private void Configure_ScanNotification() - { - LibraryCommands.ScanBegin += LibraryCommands_ScanBegin; - LibraryCommands.ScanEnd += LibraryCommands_ScanEnd; - } + private void Configure_ScanNotification() + { + LibraryCommands.ScanBegin += LibraryCommands_ScanBegin; + LibraryCommands.ScanEnd += LibraryCommands_ScanEnd; + } - private void LibraryCommands_ScanBegin(object? sender, int accountsLength) - { - removeLibraryBooksToolStripMenuItem.Enabled = false; - removeAllAccountsToolStripMenuItem.Enabled = false; - removeSomeAccountsToolStripMenuItem.Enabled = false; - scanLibraryToolStripMenuItem.Enabled = false; - scanLibraryOfAllAccountsToolStripMenuItem.Enabled = false; - scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = false; + private void LibraryCommands_ScanBegin(object? sender, int accountsLength) + { + removeLibraryBooksToolStripMenuItem.Enabled = false; + removeAllAccountsToolStripMenuItem.Enabled = false; + removeSomeAccountsToolStripMenuItem.Enabled = false; + scanLibraryToolStripMenuItem.Enabled = false; + scanLibraryOfAllAccountsToolStripMenuItem.Enabled = false; + scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = false; - this.scanningToolStripMenuItem.Image = System.Windows.Forms.Application.IsDarkModeEnabled ? Properties.Resources.import_16x16_dark : Properties.Resources.import_16x16; - this.scanningToolStripMenuItem.Visible = true; - this.scanningToolStripMenuItem.Text - = (accountsLength == 1) - ? "Scanning..." - : $"Scanning {accountsLength} accounts..."; - } + this.scanningToolStripMenuItem.Image = System.Windows.Forms.Application.IsDarkModeEnabled ? Properties.Resources.import_16x16_dark : Properties.Resources.import_16x16; + this.scanningToolStripMenuItem.Visible = true; + this.scanningToolStripMenuItem.Text + = (accountsLength == 1) + ? "Scanning..." + : $"Scanning {accountsLength} accounts..."; + } - private void LibraryCommands_ScanEnd(object? sender, int newCount) - { - removeLibraryBooksToolStripMenuItem.Enabled = true; - removeAllAccountsToolStripMenuItem.Enabled = true; - removeSomeAccountsToolStripMenuItem.Enabled = true; - scanLibraryToolStripMenuItem.Enabled = true; - scanLibraryOfAllAccountsToolStripMenuItem.Enabled = true; - scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = true; + private void LibraryCommands_ScanEnd(object? sender, int newCount) + { + removeLibraryBooksToolStripMenuItem.Enabled = true; + removeAllAccountsToolStripMenuItem.Enabled = true; + removeSomeAccountsToolStripMenuItem.Enabled = true; + scanLibraryToolStripMenuItem.Enabled = true; + scanLibraryOfAllAccountsToolStripMenuItem.Enabled = true; + scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = true; - this.scanningToolStripMenuItem.Visible = false; - } + this.scanningToolStripMenuItem.Visible = false; } } diff --git a/Source/LibationWinForms/Form1.Settings.cs b/Source/LibationWinForms/Form1.Settings.cs index b1b79eef..b991e66e 100644 --- a/Source/LibationWinForms/Form1.Settings.cs +++ b/Source/LibationWinForms/Form1.Settings.cs @@ -2,53 +2,51 @@ using System.Windows.Forms; using LibationWinForms.Dialogs; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 - { - private void Configure_Settings() + private void Configure_Settings() + { + Shown += FormShown_Settings; + } + + private void FormShown_Settings(object? sender, EventArgs e) + { + if (LibationFileManager.AudibleFileStorage.BooksDirectory is null) { - Shown += FormShown_Settings; - } + var result = MessageBox.Show( + this, + "Please set a valid Books location in the settings dialog.", + "Books Directory Not Set", + MessageBoxButtons.OKCancel, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button1); - private void FormShown_Settings(object? sender, EventArgs e) + if (result is DialogResult.OK) + new SettingsDialog().ShowDialog(this); + } + } + + private void accountsToolStripMenuItem_Click(object sender, EventArgs e) => new AccountsDialog().ShowDialog(); + + private void basicSettingsToolStripMenuItem_Click(object sender, EventArgs e) => new SettingsDialog().ShowDialog(); + + private void aboutToolStripMenuItem_Click(object sender, EventArgs e) => new AboutDialog().ShowDialog(this); + private async void tourToolStripMenuItem_Click(object sender, EventArgs e) + => await new Walkthrough(this).RunAsync(); + private void scanForHigherQualityBooksStripMenuItem_Click(object sender, EventArgs e) + => new FindBetterQualityBooksDialog().ShowDialog(this); + + private void launchHangoverToolStripMenuItem_Click(object sender, EventArgs e) + { + try { - if (LibationFileManager.AudibleFileStorage.BooksDirectory is null) - { - var result = MessageBox.Show( - this, - "Please set a valid Books location in the settings dialog.", - "Books Directory Not Set", - MessageBoxButtons.OKCancel, - MessageBoxIcon.Warning, - MessageBoxDefaultButton.Button1); - - if (result is DialogResult.OK) - new SettingsDialog().ShowDialog(this); - } + System.Diagnostics.Process.Start("Hangover.exe"); } - - private void accountsToolStripMenuItem_Click(object sender, EventArgs e) => new AccountsDialog().ShowDialog(); - - private void basicSettingsToolStripMenuItem_Click(object sender, EventArgs e) => new SettingsDialog().ShowDialog(); - - private void aboutToolStripMenuItem_Click(object sender, EventArgs e) => new AboutDialog().ShowDialog(this); - private async void tourToolStripMenuItem_Click(object sender, EventArgs e) - => await new Walkthrough(this).RunAsync(); - private void scanForHigherQualityBooksStripMenuItem_Click(object sender, EventArgs e) - => new FindBetterQualityBooksDialog().ShowDialog(this); - - private void launchHangoverToolStripMenuItem_Click(object sender, EventArgs e) - { - try - { - System.Diagnostics.Process.Start("Hangover.exe"); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Failed to launch Hangover"); - } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to launch Hangover"); } } } diff --git a/Source/LibationWinForms/Form1.Upgrade.cs b/Source/LibationWinForms/Form1.Upgrade.cs index 4a9a6db9..f75f14ca 100644 --- a/Source/LibationWinForms/Form1.Upgrade.cs +++ b/Source/LibationWinForms/Form1.Upgrade.cs @@ -3,36 +3,34 @@ using LibationWinForms.Dialogs; using System.Threading.Tasks; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms -{ - public partial class Form1 - { - private void Configure_Upgrade() - { - setProgressVisible(false); -#pragma warning disable CS8321 // Local function is declared but never used - async Task upgradeAvailable(UpgradeEventArgs e) - { - var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties).ShowDialogAsync(this); +namespace LibationWinForms; - e.Ignore = notificationResult == DialogResult.Ignore; - e.InstallUpgrade = notificationResult == DialogResult.Yes; - } +public partial class Form1 +{ + private void Configure_Upgrade() + { + setProgressVisible(false); +#pragma warning disable CS8321 // Local function is declared but never used + async Task upgradeAvailable(UpgradeEventArgs e) + { + var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties).ShowDialogAsync(this); + + e.Ignore = notificationResult == DialogResult.Ignore; + e.InstallUpgrade = notificationResult == DialogResult.Yes; + } #pragma warning restore CS8321 // Local function is declared but never used - var upgrader = new Upgrader(); - upgrader.DownloadProgress += (_, e) => Invoke(() => upgradePb.Value = int.Max(0, int.Min(100, (int)(e.ProgressPercentage ?? 0)))); - upgrader.DownloadBegin += (_, _) => Invoke(() => setProgressVisible(true)); - upgrader.DownloadCompleted += (_, _) => Invoke(() => setProgressVisible(false)); - upgrader.UpgradeFailed += (_, message) => Invoke(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); }); + var upgrader = new Upgrader(); + upgrader.DownloadProgress += (_, e) => Invoke(() => upgradePb.Value = int.Max(0, int.Min(100, (int)(e.ProgressPercentage ?? 0)))); + upgrader.DownloadBegin += (_, _) => Invoke(() => setProgressVisible(true)); + upgrader.DownloadCompleted += (_, _) => Invoke(() => setProgressVisible(false)); + upgrader.UpgradeFailed += (_, message) => Invoke(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); }); #if !DEBUG - Shown += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable); + Shown += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable); #endif - } - - private void setProgressVisible(bool visible) => upgradeLbl.Visible = upgradePb.Visible = visible; - } + + private void setProgressVisible(bool visible) => upgradeLbl.Visible = upgradePb.Visible = visible; + } diff --git a/Source/LibationWinForms/Form1.VisibleBooks.cs b/Source/LibationWinForms/Form1.VisibleBooks.cs index 8c6ed5a9..8ae96f92 100644 --- a/Source/LibationWinForms/Form1.VisibleBooks.cs +++ b/Source/LibationWinForms/Form1.VisibleBooks.cs @@ -5,183 +5,180 @@ using System.Windows.Forms; using ApplicationServices; using DataLayer; using Dinah.Core.Threading; -using LibationUiBase; using LibationWinForms.Dialogs; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 + protected void Configure_VisibleBooks() { - protected void Configure_VisibleBooks() + // init formattable + visibleCountLbl.Format(0); + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(0); + liberateVisibleToolStripMenuItem_LiberateMenu.Format(0); + + // top menu strip + visibleBooksToolStripMenuItem.Format(0); + + LibraryCommands.BookUserDefinedItemCommitted += setLiberatedVisibleMenuItemAsync; + } + private async void setLiberatedVisibleMenuItemAsync(object? _, object __) + => await Task.Run(setLiberatedVisibleMenuItem); + + private static DateTime lastVisibleCountUpdated; + void setLiberatedVisibleMenuItem() + { + //Assume that all calls to update arrive in order, + //Only display results of the latest book count. + var updaterTime = lastVisibleCountUpdated = DateTime.UtcNow; + var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible()); + if (updaterTime < lastVisibleCountUpdated) + return; + + this.UIThreadSync(() => { - // init formattable - visibleCountLbl.Format(0); - liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(0); - liberateVisibleToolStripMenuItem_LiberateMenu.Format(0); - - // top menu strip - visibleBooksToolStripMenuItem.Format(0); - - LibraryCommands.BookUserDefinedItemCommitted += setLiberatedVisibleMenuItemAsync; - } - private async void setLiberatedVisibleMenuItemAsync(object? _, object __) - => await Task.Run(setLiberatedVisibleMenuItem); - - private static DateTime lastVisibleCountUpdated; - void setLiberatedVisibleMenuItem() - { - //Assume that all calls to update arrive in order, - //Only display results of the latest book count. - var updaterTime = lastVisibleCountUpdated = DateTime.UtcNow; - var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible()); - if (updaterTime < lastVisibleCountUpdated) - return; - - this.UIThreadSync(() => + if (libraryStats.HasPendingBooks) { - if (libraryStats.HasPendingBooks) - { - liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(libraryStats.PendingBooks); - liberateVisibleToolStripMenuItem_LiberateMenu.Format(libraryStats.PendingBooks); - } - else - { - liberateVisibleToolStripMenuItem_VisibleBooksMenu.Text = "All visible books are liberated"; - liberateVisibleToolStripMenuItem_LiberateMenu.Text = "All visible books are liberated"; - } - - liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = libraryStats.HasPendingBooks; - liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = libraryStats.HasPendingBooks; - }); - } - - private void liberateVisible(object sender, EventArgs e) - { - try - { - if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray())) - SetQueueCollapseState(false); + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(libraryStats.PendingBooks); + liberateVisibleToolStripMenuItem_LiberateMenu.Format(libraryStats.PendingBooks); } - catch (Exception ex) + else { - Serilog.Log.Logger.Error(ex, "An error occurred while backing up visible library books"); + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Text = "All visible books are liberated"; + liberateVisibleToolStripMenuItem_LiberateMenu.Text = "All visible books are liberated"; } + + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = libraryStats.HasPendingBooks; + liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = libraryStats.HasPendingBooks; + }); + } + + private void liberateVisible(object sender, EventArgs e) + { + try + { + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray())) + SetQueueCollapseState(false); } - - private async void replaceTagsToolStripMenuItem_Click(object sender, EventArgs e) + catch (Exception ex) { - var dialog = new TagsBatchDialog(); - var result = dialog.ShowDialog(); - if (result != DialogResult.OK) - return; - - var visibleLibraryBooks = productsDisplay.GetVisible(); - - var confirmationResult = MessageBoxLib.ShowConfirmationDialog( - visibleLibraryBooks, - // do not use `$` string interpolation. See impl. - "Are you sure you want to replace tags in {0}?", - "Replace tags?"); - - if (confirmationResult != DialogResult.Yes) - return; - - await visibleLibraryBooks.UpdateTagsAsync(dialog.NewTags); - } - - private async void setBookDownloadedManualToolStripMenuItem_Click(object sender, EventArgs e) - { - var dialog = new LiberatedStatusBatchManualDialog(); - var result = dialog.ShowDialog(); - if (result != DialogResult.OK) - return; - - var visibleLibraryBooks = productsDisplay.GetVisible(); - - var confirmationResult = MessageBoxLib.ShowConfirmationDialog( - visibleLibraryBooks, - // do not use `$` string interpolation. See impl. - "Are you sure you want to replace book downloaded status in {0}?", - "Replace downloaded status?"); - - if (confirmationResult != DialogResult.Yes) - return; - - await visibleLibraryBooks.UpdateBookStatusAsync(dialog.BookLiberatedStatus); - } - - private async void setPdfDownloadedManualToolStripMenuItem_Click(object sender, EventArgs e) - { - var dialog = new LiberatedStatusBatchManualDialog(isPdf: true); - var result = dialog.ShowDialog(); - if (result != DialogResult.OK) - return; - - var visibleLibraryBooks = productsDisplay.GetVisible(); - - var confirmationResult = MessageBoxLib.ShowConfirmationDialog( - visibleLibraryBooks, - // do not use `$` string interpolation. See impl. - "Are you sure you want to replace PDF downloaded status in {0}?", - "Replace downloaded status?"); - - if (confirmationResult != DialogResult.Yes) - return; - - await visibleLibraryBooks.UpdatePdfStatusAsync(dialog.BookLiberatedStatus); - } - - private async void setDownloadedAutoToolStripMenuItem_Click(object sender, EventArgs e) - { - var dialog = new LiberatedStatusBatchAutoDialog(); - var result = dialog.ShowDialog(); - if (result != DialogResult.OK) - return; - - var bulkSetStatus = new BulkSetDownloadStatus(productsDisplay.GetVisible(), dialog.SetDownloaded, dialog.SetNotDownloaded); - var count = await Task.Run(() => bulkSetStatus.Discover()); - - if (count == 0) - return; - - var confirmationResult = MessageBox.Show( - bulkSetStatus.AggregateMessage, - "Replace downloaded status?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question, - MessageBoxDefaultButton.Button1); - - if (confirmationResult != DialogResult.Yes) - return; - - await bulkSetStatus.ExecuteAsync(); - } - - private async void removeToolStripMenuItem_Click(object sender, EventArgs e) - { - var visibleLibraryBooks = productsDisplay.GetVisible(); - - var confirmationResult = MessageBoxLib.ShowConfirmationDialog( - visibleLibraryBooks, - // do not use `$` string interpolation. See impl. - "Are you sure you want to remove {0} from Libation's library?", - "Remove books from Libation?"); - - if (confirmationResult is DialogResult.Yes) - await visibleLibraryBooks.RemoveBooksAsync(); - } - - private async void productsDisplay_VisibleCountChanged(object sender, int qty) - { - // bottom-left visible count - visibleCountLbl.Format(qty); - - // top menu strip - visibleBooksToolStripMenuItem.Format(qty); - visibleBooksToolStripMenuItem.Enabled = qty > 0; - - await Task.Run(setLiberatedVisibleMenuItem); + Serilog.Log.Logger.Error(ex, "An error occurred while backing up visible library books"); } } + + private async void replaceTagsToolStripMenuItem_Click(object sender, EventArgs e) + { + var dialog = new TagsBatchDialog(); + var result = dialog.ShowDialog(); + if (result != DialogResult.OK) + return; + + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + // do not use `$` string interpolation. See impl. + "Are you sure you want to replace tags in {0}?", + "Replace tags?"); + + if (confirmationResult != DialogResult.Yes) + return; + + await visibleLibraryBooks.UpdateTagsAsync(dialog.NewTags); + } + + private async void setBookDownloadedManualToolStripMenuItem_Click(object sender, EventArgs e) + { + var dialog = new LiberatedStatusBatchManualDialog(); + var result = dialog.ShowDialog(); + if (result != DialogResult.OK) + return; + + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + // do not use `$` string interpolation. See impl. + "Are you sure you want to replace book downloaded status in {0}?", + "Replace downloaded status?"); + + if (confirmationResult != DialogResult.Yes) + return; + + await visibleLibraryBooks.UpdateBookStatusAsync(dialog.BookLiberatedStatus); + } + + private async void setPdfDownloadedManualToolStripMenuItem_Click(object sender, EventArgs e) + { + var dialog = new LiberatedStatusBatchManualDialog(isPdf: true); + var result = dialog.ShowDialog(); + if (result != DialogResult.OK) + return; + + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + // do not use `$` string interpolation. See impl. + "Are you sure you want to replace PDF downloaded status in {0}?", + "Replace downloaded status?"); + + if (confirmationResult != DialogResult.Yes) + return; + + await visibleLibraryBooks.UpdatePdfStatusAsync(dialog.BookLiberatedStatus); + } + + private async void setDownloadedAutoToolStripMenuItem_Click(object sender, EventArgs e) + { + var dialog = new LiberatedStatusBatchAutoDialog(); + var result = dialog.ShowDialog(); + if (result != DialogResult.OK) + return; + + var bulkSetStatus = new BulkSetDownloadStatus(productsDisplay.GetVisible(), dialog.SetDownloaded, dialog.SetNotDownloaded); + var count = await Task.Run(() => bulkSetStatus.Discover()); + + if (count == 0) + return; + + var confirmationResult = MessageBox.Show( + bulkSetStatus.AggregateMessage, + "Replace downloaded status?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question, + MessageBoxDefaultButton.Button1); + + if (confirmationResult != DialogResult.Yes) + return; + + await bulkSetStatus.ExecuteAsync(); + } + + private async void removeToolStripMenuItem_Click(object sender, EventArgs e) + { + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + // do not use `$` string interpolation. See impl. + "Are you sure you want to remove {0} from Libation's library?", + "Remove books from Libation?"); + + if (confirmationResult is DialogResult.Yes) + await visibleLibraryBooks.RemoveBooksAsync(); + } + + private async void productsDisplay_VisibleCountChanged(object sender, int qty) + { + // bottom-left visible count + visibleCountLbl.Format(qty); + + // top menu strip + visibleBooksToolStripMenuItem.Format(qty); + visibleBooksToolStripMenuItem.Enabled = qty > 0; + + await Task.Run(setLiberatedVisibleMenuItem); + } } diff --git a/Source/LibationWinForms/Form1._NonUI.cs b/Source/LibationWinForms/Form1._NonUI.cs index 72466898..165a8c4b 100644 --- a/Source/LibationWinForms/Form1._NonUI.cs +++ b/Source/LibationWinForms/Form1._NonUI.cs @@ -6,91 +6,89 @@ using FileManager; using LibationFileManager; using LibationUiBase; -#nullable enable -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 { - public partial class Form1 - { - private void Configure_NonUI() + private void Configure_NonUI() + { + AudibleApiStorage.LoadError += AudibleApiStorage_LoadError; + + // init default/placeholder cover art + var format = System.Drawing.Imaging.ImageFormat.Jpeg; + PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format)); + + BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault); + BaseUtil.SetLoadResourceImageDelegate(LoadResourceImage); + + // wire-up event to automatically download after scan. + // winforms only. this should NOT be allowed in cli + updateCountsBw.RunWorkerCompleted += (object? sender, System.ComponentModel.RunWorkerCompletedEventArgs e) => { - AudibleApiStorage.LoadError += AudibleApiStorage_LoadError; + if (!Configuration.Instance.AutoDownloadEpisodes || e.Result is not LibraryCommands.LibraryStats libraryStats) + return; - // init default/placeholder cover art - var format = System.Drawing.Imaging.ImageFormat.Jpeg; - PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format)); - PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format)); - PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format)); - PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format)); + if ((libraryStats.PendingBooks + libraryStats.pdfsNotDownloaded) > 0) + BackupAllBooks(libraryStats.LibraryBooks); + }; + } - BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault); - BaseUtil.SetLoadResourceImageDelegate(LoadResourceImage); + private static object? LoadResourceImage(string resourceName) + { + if (Application.IsDarkModeEnabled) + resourceName += "_dark"; + return Properties.Resources.ResourceManager.GetObject(resourceName); + } - // wire-up event to automatically download after scan. - // winforms only. this should NOT be allowed in cli - updateCountsBw.RunWorkerCompleted += (object? sender, System.ComponentModel.RunWorkerCompletedEventArgs e) => - { - if (!Configuration.Instance.AutoDownloadEpisodes || e.Result is not LibraryCommands.LibraryStats libraryStats) - return; + 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"); - if ((libraryStats.PendingBooks + libraryStats.pdfsNotDownloaded) > 0) - BackupAllBooks(libraryStats.LibraryBooks); - }; + AudibleApiStorage.EnsureAccountsSettingsFileExists(); + e.Handled = true; + + showAccountSettingsRecoveredMessage(backupFile); + } + catch + { + showAccountSettingsUnrecoveredMessage(); } - private static object? LoadResourceImage(string resourceName) - { - if (Application.IsDarkModeEnabled) - resourceName += "_dark"; - return Properties.Resources.ResourceManager.GetObject(resourceName); - } + void showAccountSettingsRecoveredMessage(LongPath backupFile) + => MessageBox.Show(this, $""" + Libation could not load your account settings, so it had created a new, empty account settings file. - 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"); + You will need to re-add you Audible account(s) before scanning or downloading. - AudibleApiStorage.EnsureAccountsSettingsFileExists(); - e.Handled = true; + The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' - showAccountSettingsRecoveredMessage(backupFile); - } - catch - { - showAccountSettingsUnrecoveredMessage(); - } + {e.GetException().ToString()} + """, + "Error Loading Account Settings", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); - void showAccountSettingsRecoveredMessage(LongPath backupFile) - => MessageBox.Show(this, $""" - Libation could not load your account settings, so it had created a new, empty account settings file. + void showAccountSettingsUnrecoveredMessage() + => MessageBox.Show(this, $""" + Libation could not load your account settings. The file may be corrupted, but Libation is unable to delete it. - You will need to re-add you Audible account(s) before scanning or downloading. + Please move or delete the account settings file '{e.SettingsFilePath}' - The old account settings file has been archived at '{backupFile.PathWithoutPrefix}' - - {e.GetException().ToString()} - """, - "Error Loading Account Settings", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); - - void showAccountSettingsUnrecoveredMessage() - => 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, - MessageBoxIcon.Error); - } + {e.GetException().ToString()} + """, + "Error Loading Account Settings", + MessageBoxButtons.OK, + MessageBoxIcon.Error); } } diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index f8413dcb..41a8bce9 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; using ApplicationServices; @@ -8,95 +7,93 @@ using AudibleUtilities; using DataLayer; using LibationFileManager; using LibationWinForms.Login; -using Octokit; -namespace LibationWinForms +namespace LibationWinForms; + +public partial class Form1 : Form { - public partial class Form1 : Form + public Form1() { - public Form1() + InitializeComponent(); + //Set this size before restoring form size and position + splitContainer1.Panel2MinSize = this.DpiScale(350); + this.RestoreSizeAndLocation(Configuration.Instance); + FormClosing += Form1_FormClosing; + + // this looks like a perfect opportunity to refactor per below. + // since this loses design-time tooling and internal access, for now I'm opting for partial classes + // var modules = new ConfigurableModuleBase[] + // { + // new PictureStorageModule(), + // new BackupCountsModule(), + // new VisibleBooksModule(), + // // ... + // }; + // foreach(ConfigurableModuleBase m in modules) + // m.Configure(this); + + // these should do nothing interesting yet (storing simple var, subscribe to events) and should never rely on each other for order. + // otherwise, order could be an issue. + // eg: if one of these init'd productsGrid, then another can't reliably subscribe to it + Configure_BackupCounts(); + Configure_ScanAuto(); + Configure_ScanNotification(); + Configure_VisibleBooks(); + Configure_QuickFilters(); + Configure_ScanManual(); + Configure_RemoveBooks(); + Configure_Liberate(); + Configure_Export(); + Configure_Settings(); + Configure_ProcessQueue(); + Configure_Filter(); + Configure_Upgrade(); + // misc which belongs in winforms app but doesn't have a UI element + Configure_NonUI(); + + // Configure_Grid(); // since it's just this, can keep here. If it needs more, then give grid it's own 'partial class Form1' { - InitializeComponent(); - //Set this size before restoring form size and position - splitContainer1.Panel2MinSize = this.DpiScale(350); - this.RestoreSizeAndLocation(Configuration.Instance); - FormClosing += Form1_FormClosing; + LibraryCommands.LibrarySizeChanged += (_, fullLibrary) + => Invoke(() => productsDisplay.DisplayAsync(fullLibrary)); + } + Shown += Form1_Shown; + ApiExtended.LoginChoiceFactory = account => new WinformLoginChoiceEager(account, this); + } - // this looks like a perfect opportunity to refactor per below. - // since this loses design-time tooling and internal access, for now I'm opting for partial classes - // var modules = new ConfigurableModuleBase[] - // { - // new PictureStorageModule(), - // new BackupCountsModule(), - // new VisibleBooksModule(), - // // ... - // }; - // foreach(ConfigurableModuleBase m in modules) - // m.Configure(this); + private void Form1_FormClosing(object? sender, FormClosingEventArgs e) + { + //Always close the queue before saving the form to prevent + //Form1 from getting excessively wide when it's restored. + SetQueueCollapseState(true); + this.SaveSizeAndLocation(Configuration.Instance); + } - // these should do nothing interesting yet (storing simple var, subscribe to events) and should never rely on each other for order. - // otherwise, order could be an issue. - // eg: if one of these init'd productsGrid, then another can't reliably subscribe to it - Configure_BackupCounts(); - Configure_ScanAuto(); - Configure_ScanNotification(); - Configure_VisibleBooks(); - Configure_QuickFilters(); - Configure_ScanManual(); - Configure_RemoveBooks(); - Configure_Liberate(); - Configure_Export(); - Configure_Settings(); - Configure_ProcessQueue(); - Configure_Filter(); - Configure_Upgrade(); - // misc which belongs in winforms app but doesn't have a UI element - Configure_NonUI(); + private async void Form1_Shown(object? sender, EventArgs e) + { + if (Configuration.Instance.FirstLaunch) + { + var result = MessageBox.Show(this, "Would you like a guided tour to get started?", "Libation Walkthrough", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); - // Configure_Grid(); // since it's just this, can keep here. If it needs more, then give grid it's own 'partial class Form1' + if (result is DialogResult.Yes) { - LibraryCommands.LibrarySizeChanged += (object _, List<LibraryBook> fullLibrary) - => Invoke(() => productsDisplay.DisplayAsync(fullLibrary)); + await new Walkthrough(this).RunAsync(); } - Shown += Form1_Shown; - ApiExtended.LoginChoiceFactory = account => new WinformLoginChoiceEager(account, this); - } - private void Form1_FormClosing(object sender, FormClosingEventArgs e) - { - //Always close the queue before saving the form to prevent - //Form1 from getting excessively wide when it's restored. - SetQueueCollapseState(true); - this.SaveSizeAndLocation(Configuration.Instance); - } - - private async void Form1_Shown(object sender, EventArgs e) - { - if (Configuration.Instance.FirstLaunch) - { - var result = 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; - } - } - - public async Task InitLibraryAsync(List<LibraryBook> libraryBooks) - { - runBackupCountsAgain = true; - setBackupCounts(null, libraryBooks); - await productsDisplay.DisplayAsync(libraryBooks); - } - - private void Form1_Load(object sender, EventArgs e) - { - if (this.DesignMode) - return; - // I'm leaving this empty call here as a reminder that if we use this, it should probably be after DesignMode check + Configuration.Instance.FirstLaunch = false; } } + + public async Task InitLibraryAsync(List<LibraryBook> libraryBooks) + { + runBackupCountsAgain = true; + setBackupCounts(null, libraryBooks); + await productsDisplay.DisplayAsync(libraryBooks); + } + + private void Form1_Load(object sender, EventArgs e) + { + if (this.DesignMode) + return; + // I'm leaving this empty call here as a reminder that if we use this, it should probably be after DesignMode check + } } diff --git a/Source/LibationWinForms/FormSaveExtension.cs b/Source/LibationWinForms/FormSaveExtension.cs index 677ccb1d..e8a8e21e 100644 --- a/Source/LibationWinForms/FormSaveExtension.cs +++ b/Source/LibationWinForms/FormSaveExtension.cs @@ -2,112 +2,113 @@ using System.Linq; using System.Windows.Forms; using LibationFileManager; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; -namespace LibationWinForms +namespace LibationWinForms; + +public static class FormSaveExtension { - public static class FormSaveExtension + static readonly Icon? libationIcon; + static FormSaveExtension() { - static readonly Icon libationIcon; - static FormSaveExtension() + var resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1)); + libationIcon = resources.GetObject("$this.Icon") as Icon; + } + + public static void SetLibationIcon(this Form form) + { + form.Icon = libationIcon; + } + + public static void RestoreSizeAndLocation(this Form form, Configuration config) + { + var savedState = config.GetNonString<FormSizeAndPosition?>(defaultValue: null, form.Name); + + if (savedState is null) + return; + + // too small -- something must have gone wrong. use defaults + if (savedState.Width < 25 || savedState.Height < 25) { - var resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1)); - libationIcon = (Icon)resources.GetObject("$this.Icon"); + savedState.Width = form.Width; + savedState.Height = form.Height; } - public static void SetLibationIcon(this Form form) + var workingArea = ThemeExtensions.GetPrimaryScreenWorkingArea(); + if (workingArea.Width == 0 || workingArea.Height == 0) + return; + // Fit to the current screen size in case the screen resolution changed since the size was last persisted + if (savedState.Width > workingArea.Width) + savedState.Width = workingArea.Width; + if (savedState.Height > workingArea.Height) + savedState.Height = workingArea.Height; + + var x = savedState.X; + var y = savedState.Y; + + var rect = new Rectangle(x, y, savedState.Width, savedState.Height); + + if (savedState.IsMaximized) { - form.Icon = libationIcon; + //When a window is maximized, the client rectangle is not on a screen (y is negative). + form.StartPosition = FormStartPosition.Manual; + form.DesktopBounds = rect; + + // FINAL: for Maximized: start normal state, set size and location, THEN set max state + form.WindowState = FormWindowState.Maximized; } - - public static void RestoreSizeAndLocation(this Form form, Configuration config) + else { - var savedState = config.GetNonString<FormSizeAndPosition>(defaultValue: null, form.Name); - - if (savedState is null) - return; - - // too small -- something must have gone wrong. use defaults - if (savedState.Width < 25 || savedState.Height < 25) + // is proposed rect on a screen? + if (Screen.AllScreens.Any(screen => screen.WorkingArea.Contains(rect))) { - savedState.Width = form.Width; - savedState.Height = form.Height; - } - - // Fit to the current screen size in case the screen resolution changed since the size was last persisted - if (savedState.Width > Screen.PrimaryScreen.WorkingArea.Width) - savedState.Width = Screen.PrimaryScreen.WorkingArea.Width; - if (savedState.Height > Screen.PrimaryScreen.WorkingArea.Height) - savedState.Height = Screen.PrimaryScreen.WorkingArea.Height; - - var x = savedState.X; - var y = savedState.Y; - - var rect = new Rectangle(x, y, savedState.Width, savedState.Height); - - if (savedState.IsMaximized) - { - //When a window is maximized, the client rectangle is not on a screen (y is negative). form.StartPosition = FormStartPosition.Manual; form.DesktopBounds = rect; - - // FINAL: for Maximized: start normal state, set size and location, THEN set max state - form.WindowState = FormWindowState.Maximized; } else { - // is proposed rect on a screen? - if (Screen.AllScreens.Any(screen => screen.WorkingArea.Contains(rect))) - { - form.StartPosition = FormStartPosition.Manual; - form.DesktopBounds = rect; - } - else - { - form.StartPosition = FormStartPosition.WindowsDefaultLocation; - form.Size = rect.Size; - } - - form.WindowState = FormWindowState.Normal; - } - } - - public static void SaveSizeAndLocation(this Form form, Configuration config) - { - Point location; - Size size; - var saveState = new FormSizeAndPosition(); - - // save location and size if the state is normal - if (form.WindowState == FormWindowState.Normal) - { - location = form.Location; - size = form.Size; - } - else - { - // save the RestoreBounds if the form is minimized or maximized - location = form.RestoreBounds.Location; - size = form.RestoreBounds.Size; + form.StartPosition = FormStartPosition.WindowsDefaultLocation; + form.Size = rect.Size; } - saveState.X = location.X; - saveState.Y = location.Y; - - saveState.Width = size.Width; - saveState.Height = size.Height; - - saveState.IsMaximized = form.WindowState == FormWindowState.Maximized; - - config.SetNonString(saveState, form.Name); + form.WindowState = FormWindowState.Normal; } } - record FormSizeAndPosition + + public static void SaveSizeAndLocation(this Form form, Configuration config) { - public int X; - public int Y; - public int Height; - public int Width; - public bool IsMaximized; + Point location; + Size size; + var saveState = new FormSizeAndPosition(); + + // save location and size if the state is normal + if (form.WindowState == FormWindowState.Normal) + { + location = form.Location; + size = form.Size; + } + else + { + // save the RestoreBounds if the form is minimized or maximized + location = form.RestoreBounds.Location; + size = form.RestoreBounds.Size; + } + + saveState.X = location.X; + saveState.Y = location.Y; + + saveState.Width = size.Width; + saveState.Height = size.Height; + + saveState.IsMaximized = form.WindowState == FormWindowState.Maximized; + + config.SetNonString(saveState, form.Name); } } +record FormSizeAndPosition +{ + public int X; + public int Y; + public int Height; + public int Width; + public bool IsMaximized; +} diff --git a/Source/LibationWinForms/FormattableLabel.cs b/Source/LibationWinForms/FormattableLabel.cs index fb4474a4..3060e935 100644 --- a/Source/LibationWinForms/FormattableLabel.cs +++ b/Source/LibationWinForms/FormattableLabel.cs @@ -1,32 +1,31 @@ -using System; -using System.Drawing; +using System.Diagnostics.CodeAnalysis; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public class FormattableLabel : Label { - public class FormattableLabel : Label - { - public string FormatText { get; set; } + public string FormatText { get; set; } - /// <summary>Text set: first non-null, non-whitespace <see cref="Text"/> set is also saved as <see cref="FormatText"/></summary> - public override string Text - { - get => base.Text; - set - { - if (string.IsNullOrWhiteSpace(FormatText)) - FormatText = value; + /// <summary>Text set: first non-null, non-whitespace <see cref="Text"/> set is also saved as <see cref="FormatText"/></summary> + [AllowNull] + public override string Text + { + get => base.Text; + set + { + if (string.IsNullOrWhiteSpace(FormatText) && !string.IsNullOrWhiteSpace(value)) + FormatText = value; - base.Text = value; - } - } + base.Text = value; + } + } - #region ctor.s - public FormattableLabel() : base() { } - #endregion + #region ctor.s + public FormattableLabel() : base() { FormatText = string.Empty; } + #endregion - /// <summary>Replaces the format item in a specified string with the string representation of a corresponding object in a specified array. Returns <see cref="Text"/> for convenience.</summary> - /// <param name="args">An object array that contains zero or more objects to format.</param> - public string Format(params object[] args) => Text = string.Format(FormatText, args); - } + /// <summary>Replaces the format item in a specified string with the string representation of a corresponding object in a specified array. Returns <see cref="Text"/> for convenience.</summary> + /// <param name="args">An object array that contains zero or more objects to format.</param> + public string Format(params object[] args) => Text = string.Format(FormatText, args); } diff --git a/Source/LibationWinForms/FormattableToolStripMenuItem.cs b/Source/LibationWinForms/FormattableToolStripMenuItem.cs index a1032b8d..ce6a81c2 100644 --- a/Source/LibationWinForms/FormattableToolStripMenuItem.cs +++ b/Source/LibationWinForms/FormattableToolStripMenuItem.cs @@ -2,38 +2,37 @@ using System.Drawing; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public class FormattableToolStripMenuItem : ToolStripMenuItem { - public class FormattableToolStripMenuItem : ToolStripMenuItem - { - public string FormatText { get; set; } + public string FormatText { get; set; } - /// <summary>Text set: first non-null, non-whitespace <see cref="Text"/> set is also saved as <see cref="FormatText"/></summary> - public override string Text - { - get => base.Text; - set - { - if (string.IsNullOrWhiteSpace(FormatText)) - FormatText = value; + /// <summary>Text set: first non-null, non-whitespace <see cref="Text"/> set is also saved as <see cref="FormatText"/></summary> + public override string? Text + { + get => base.Text; + set + { + if (string.IsNullOrWhiteSpace(FormatText) && !string.IsNullOrWhiteSpace(value)) + FormatText = value; - base.Text = value; - } - } + base.Text = value; + } + } - #region ctor.s - public FormattableToolStripMenuItem() : base() { } - public FormattableToolStripMenuItem(string text) : base(text) => FormatText = text; - public FormattableToolStripMenuItem(Image image) : base(image) { } - public FormattableToolStripMenuItem(string text, Image image) : base(text, image) => FormatText = text; - public FormattableToolStripMenuItem(string text, Image image, EventHandler onClick) : base(text, image, onClick) => FormatText = text; - public FormattableToolStripMenuItem(string text, Image image, params ToolStripItem[] dropDownItems) : base(text, image, dropDownItems) => FormatText = text; - public FormattableToolStripMenuItem(string text, Image image, EventHandler onClick, Keys shortcutKeys) : base(text, image, onClick, shortcutKeys) => FormatText = text; - public FormattableToolStripMenuItem(string text, Image image, EventHandler onClick, string name) : base(text, image, onClick, name) => FormatText = text; - #endregion + #region ctor.s + public FormattableToolStripMenuItem() : base() { FormatText = string.Empty; } + public FormattableToolStripMenuItem(string text) : base(text) => FormatText = text; + public FormattableToolStripMenuItem(Image image) : base(image) { FormatText = string.Empty; } + public FormattableToolStripMenuItem(string text, Image image) : base(text, image) => FormatText = text; + public FormattableToolStripMenuItem(string text, Image image, EventHandler onClick) : base(text, image, onClick) => FormatText = text; + public FormattableToolStripMenuItem(string text, Image image, params ToolStripItem[] dropDownItems) : base(text, image, dropDownItems) => FormatText = text; + public FormattableToolStripMenuItem(string text, Image image, EventHandler onClick, Keys shortcutKeys) : base(text, image, onClick, shortcutKeys) => FormatText = text; + public FormattableToolStripMenuItem(string text, Image image, EventHandler onClick, string name) : base(text, image, onClick, name) => FormatText = text; + #endregion - /// <summary>Replaces the format item in a specified string with the string representation of a corresponding object in a specified array. Returns <see cref="Text"/> for convenience.</summary> - /// <param name="args">An object array that contains zero or more objects to format.</param> - public string Format(params object[] args) => Text = string.Format(FormatText, args); - } + /// <summary>Replaces the format item in a specified string with the string representation of a corresponding object in a specified array. Returns <see cref="Text"/> for convenience.</summary> + /// <param name="args">An object array that contains zero or more objects to format.</param> + public string Format(params object[] args) => Text = string.Format(FormatText, args); } diff --git a/Source/LibationWinForms/FormattableToolStripStatusLabel.cs b/Source/LibationWinForms/FormattableToolStripStatusLabel.cs index a3fb88d5..cbdb8a74 100644 --- a/Source/LibationWinForms/FormattableToolStripStatusLabel.cs +++ b/Source/LibationWinForms/FormattableToolStripStatusLabel.cs @@ -2,36 +2,35 @@ using System.Drawing; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +public class FormattableToolStripStatusLabel : ToolStripStatusLabel { - public class FormattableToolStripStatusLabel : ToolStripStatusLabel - { - public string FormatText { get; set; } + public string FormatText { get; set; } - /// <summary>Text set: first non-null, non-whitespace <see cref="Text"/> set is also saved as <see cref="FormatText"/></summary> - public override string Text - { - get => base.Text; - set - { - if (string.IsNullOrWhiteSpace(FormatText)) - FormatText = value; + /// <summary>Text set: first non-null, non-whitespace <see cref="Text"/> set is also saved as <see cref="FormatText"/></summary> + public override string? Text + { + get => base.Text; + set + { + if (string.IsNullOrWhiteSpace(FormatText) && !string.IsNullOrWhiteSpace(value)) + FormatText = value; - base.Text = value; - } - } + base.Text = value; + } + } - #region ctor.s - public FormattableToolStripStatusLabel() : base() { } - public FormattableToolStripStatusLabel(string text) : base(text) => FormatText = text; - public FormattableToolStripStatusLabel(Image image) : base(image) { } - public FormattableToolStripStatusLabel(string text, Image image) : base(text, image) => FormatText = text; - public FormattableToolStripStatusLabel(string text, Image image, EventHandler onClick) : base(text, image, onClick) => FormatText = text; - public FormattableToolStripStatusLabel(string text, Image image, EventHandler onClick, string name) : base(text, image, onClick, name) => FormatText = text; - #endregion + #region ctor.s + public FormattableToolStripStatusLabel() : base() { FormatText = string.Empty; } + public FormattableToolStripStatusLabel(string text) : base(text) => FormatText = text; + public FormattableToolStripStatusLabel(Image image) : base(image) { FormatText = string.Empty; } + public FormattableToolStripStatusLabel(string text, Image image) : base(text, image) => FormatText = text; + public FormattableToolStripStatusLabel(string text, Image image, EventHandler onClick) : base(text, image, onClick) => FormatText = text; + public FormattableToolStripStatusLabel(string text, Image image, EventHandler onClick, string name) : base(text, image, onClick, name) => FormatText = text; + #endregion - /// <summary>Replaces the format item in a specified string with the string representation of a corresponding object in a specified array. Returns <see cref="Text"/> for convenience.</summary> - /// <param name="args">An object array that contains zero or more objects to format.</param> - public string Format(params object[] args) => Text = string.Format(FormatText, args); - } + /// <summary>Replaces the format item in a specified string with the string representation of a corresponding object in a specified array. Returns <see cref="Text"/> for convenience.</summary> + /// <param name="args">An object array that contains zero or more objects to format.</param> + public string Format(params object[] args) => Text = string.Format(FormatText, args); } diff --git a/Source/LibationWinForms/GridView/DataGridViewImageButtonCell.cs b/Source/LibationWinForms/GridView/DataGridViewImageButtonCell.cs index 3f195d4e..9621d571 100644 --- a/Source/LibationWinForms/GridView/DataGridViewImageButtonCell.cs +++ b/Source/LibationWinForms/GridView/DataGridViewImageButtonCell.cs @@ -1,21 +1,20 @@ using System.Drawing; -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public class DataGridViewImageButtonCell : AccessibleDataGridViewButtonCell { - public class DataGridViewImageButtonCell : AccessibleDataGridViewButtonCell - { - public DataGridViewImageButtonCell(string accessibilityName) : base(accessibilityName) { } + public DataGridViewImageButtonCell(string accessibilityName) : base(accessibilityName) { } - protected void DrawButtonImage(Graphics graphics, Image image, Rectangle cellBounds) - { - var scaleFactor = OwningColumn is IDataGridScaleColumn scCol ? scCol.ScaleFactor : 1f; + protected void DrawButtonImage(Graphics graphics, Image image, Rectangle cellBounds) + { + var scaleFactor = OwningColumn is IDataGridScaleColumn scCol ? scCol.ScaleFactor : 1f; - var w = (int)float.Round(graphics.ScaleX(image.Width) * scaleFactor); - var h = (int)float.Round(graphics.ScaleY(image.Height) * scaleFactor); - var x = cellBounds.Left + (cellBounds.Width - w) / 2; - var y = cellBounds.Top + (cellBounds.Height - h) / 2; + var w = (int)float.Round(graphics.ScaleX(image.Width) * scaleFactor); + var h = (int)float.Round(graphics.ScaleY(image.Height) * scaleFactor); + var x = cellBounds.Left + (cellBounds.Width - w) / 2; + var y = cellBounds.Top + (cellBounds.Height - h) / 2; - graphics.DrawImage(image, new Rectangle(x, y, w, h)); - } + graphics.DrawImage(image, new Rectangle(x, y, w, h)); } } diff --git a/Source/LibationWinForms/GridView/DescriptionDisplay.cs b/Source/LibationWinForms/GridView/DescriptionDisplay.cs index ba9ec65e..b286337d 100644 --- a/Source/LibationWinForms/GridView/DescriptionDisplay.cs +++ b/Source/LibationWinForms/GridView/DescriptionDisplay.cs @@ -3,45 +3,44 @@ using System.Drawing; using System.Runtime.InteropServices; using System.Windows.Forms; -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public partial class DescriptionDisplay : Form { - public partial class DescriptionDisplay : Form + public int BorderThickness { - public int BorderThickness + get => field; + set { - get => field; - set - { - field = value; - textBox1.Location = new Point(field, field); - textBox1.Size = new Size(Width - 2 * field, Height - 2 * field); - } - } = 5; - public string DescriptionText { get => textBox1.Text; set => textBox1.Text = value; } - public Point SpawnLocation { get; set; } - public DescriptionDisplay() - { - InitializeComponent(); - textBox1.LostFocus += (_, _) => Close(); - Shown += DescriptionDisplay_Shown; + field = value; + textBox1.Location = new Point(field, field); + textBox1.Size = new Size(Width - 2 * field, Height - 2 * field); } - - private void DescriptionDisplay_Shown(object sender, EventArgs e) - { - textBox1.DeselectAll(); - HideCaret(textBox1.Handle); - } - - protected override void OnLoad(EventArgs e) - { - base.OnLoad(e); - int lineCount = textBox1.GetLineFromCharIndex(int.MaxValue) + 2; - Height = Height - textBox1.Height + lineCount * TextRenderer.MeasureText("X", textBox1.Font).Height; - - Location = new Point(SpawnLocation.X, Math.Min(SpawnLocation.Y, Screen.PrimaryScreen.WorkingArea.Height - Height)); - } - - [DllImport("user32.dll")] - static extern bool HideCaret(IntPtr hWnd); + } = 5; + public string? DescriptionText { get => textBox1.Text; set => textBox1.Text = value; } + public Point SpawnLocation { get; set; } + public DescriptionDisplay() + { + InitializeComponent(); + textBox1.LostFocus += (_, _) => Close(); + Shown += DescriptionDisplay_Shown; } + + private void DescriptionDisplay_Shown(object? sender, EventArgs e) + { + textBox1.DeselectAll(); + HideCaret(textBox1.Handle); + } + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + int lineCount = textBox1.GetLineFromCharIndex(int.MaxValue) + 2; + Height = Height - textBox1.Height + lineCount * TextRenderer.MeasureText("X", textBox1.Font).Height; + + Location = new Point(SpawnLocation.X, Math.Min(SpawnLocation.Y, ThemeExtensions.GetPrimaryScreenWorkingArea().Height - Height)); + } + + [DllImport("user32.dll")] + static extern bool HideCaret(IntPtr hWnd); } diff --git a/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs b/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs index 422de6de..7555cf2b 100644 --- a/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs +++ b/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs @@ -3,50 +3,49 @@ using System.Windows.Forms; using Dinah.Core.WindowsDesktop.Forms; using LibationUiBase.GridView; -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public interface IDataGridScaleColumn { - public interface IDataGridScaleColumn + float ScaleFactor { get; set; } +} +public class EditTagsDataGridViewImageButtonColumn : DataGridViewButtonColumn, IDataGridScaleColumn +{ + public EditTagsDataGridViewImageButtonColumn() { - float ScaleFactor { get; set; } + CellTemplate = new EditTagsDataGridViewImageButtonCell(); } - public class EditTagsDataGridViewImageButtonColumn : DataGridViewButtonColumn, IDataGridScaleColumn + + public float ScaleFactor { get; set; } +} + +internal class EditTagsDataGridViewImageButtonCell : DataGridViewImageButtonCell +{ + public EditTagsDataGridViewImageButtonCell() : base("Edit Tags button") { } + + private static Image ButtonImage => Application.IsDarkModeEnabled ? Properties.Resources.edit_25x25_dark : Properties.Resources.edit_25x25; + + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { - public EditTagsDataGridViewImageButtonColumn() + // series + if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is SeriesEntry) { - CellTemplate = new EditTagsDataGridViewImageButtonCell(); + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); } - - public float ScaleFactor { get; set; } - } - - internal class EditTagsDataGridViewImageButtonCell : DataGridViewImageButtonCell - { - public EditTagsDataGridViewImageButtonCell() : base("Edit Tags button") { } - - private static Image ButtonImage => Application.IsDarkModeEnabled ? Properties.Resources.edit_25x25_dark : Properties.Resources.edit_25x25; - - protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + // tag: empty + else if (value is string tagStr && tagStr.Length == 0) { - // series - if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is SeriesEntry) - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); - } - // tag: empty - else if (value is string tagStr && tagStr.Length == 0) - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); - DrawButtonImage(graphics, ButtonImage, cellBounds); - AccessibilityDescription = "Click to edit tags"; - } - // tag: not empty - else - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); + DrawButtonImage(graphics, ButtonImage, cellBounds); + AccessibilityDescription = "Click to edit tags"; + } + // tag: not empty + else + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); - AccessibilityDescription = (string)value; - } - } + AccessibilityDescription = value as string; + } } } diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 3f0bcd74..23c39e45 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -5,242 +5,240 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; -#nullable enable -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +/* + * Allows filtering and sorting of the underlying BindingList<GridEntry> + * by implementing IBindingListView and using SearchEngineCommands + * + * When filtering is applied, the filtered-out items are removed + * from the base list and added to the private FilterRemoved list. + * When filtering is removed, items in the FilterRemoved list are + * added back to the base list. + * + * Remove is overridden to ensure that removed items are removed from + * the base list (visible items) as well as the FilterRemoved list. + * + * Using BindingList.Add/Insert and BindingList.Remove will cause the + * BindingList to subscribe/unsibscribe to/from the item's PropertyChanged + * event. Adding or removing from the underlying list will not change the + * BindingList's subscription to that item. + */ +internal class GridEntryBindingList : BindingList<GridEntry>, IBindingListView { - /* - * Allows filtering and sorting of the underlying BindingList<GridEntry> - * by implementing IBindingListView and using SearchEngineCommands - * - * When filtering is applied, the filtered-out items are removed - * from the base list and added to the private FilterRemoved list. - * When filtering is removed, items in the FilterRemoved list are - * added back to the base list. - * - * Remove is overridden to ensure that removed items are removed from - * the base list (visible items) as well as the FilterRemoved list. - * - * Using BindingList.Add/Insert and BindingList.Remove will cause the - * BindingList to subscribe/unsibscribe to/from the item's PropertyChanged - * event. Adding or removing from the underlying list will not change the - * BindingList's subscription to that item. - */ - internal class GridEntryBindingList : BindingList<GridEntry>, IBindingListView + public GridEntryBindingList(IEnumerable<GridEntry> enumeration) : base(new List<GridEntry>(enumeration)) { - public GridEntryBindingList(IEnumerable<GridEntry> enumeration) : base(new List<GridEntry>(enumeration)) + SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; + ListChanged += GridEntryBindingList_ListChanged; + } + + /// <returns>All items in the list, including those filtered out.</returns> + public List<GridEntry> AllItems() => Items.Concat(FilterRemoved).ToList(); + + /// <summary>All items that pass the current filter</summary> + public IEnumerable<LibraryBookEntry> GetFilteredInItems() + => FilteredInGridEntries? + .OfType<LibraryBookEntry>() + ?? FilterRemoved + .OfType<LibraryBookEntry>() + .Union(Items.OfType<LibraryBookEntry>()); + + public ISearchEngine? SearchEngine { get; set; } + + public bool SupportsFiltering => true; + public string? Filter + { + get => FilterString; + set { - SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; - ListChanged += GridEntryBindingList_ListChanged; - } + FilterString = value; - /// <returns>All items in the list, including those filtered out.</returns> - public List<GridEntry> AllItems() => Items.Concat(FilterRemoved).ToList(); + if (Items.Count + FilterRemoved.Count == 0) + return; - /// <summary>All items that pass the current filter</summary> - public IEnumerable<LibraryBookEntry> GetFilteredInItems() - => FilteredInGridEntries? - .OfType<LibraryBookEntry>() - ?? FilterRemoved - .OfType<LibraryBookEntry>() - .Union(Items.OfType<LibraryBookEntry>()); - - public ISearchEngine? SearchEngine { get; set; } - - public bool SupportsFiltering => true; - public string? Filter - { - get => FilterString; - set - { - FilterString = value; - - if (Items.Count + FilterRemoved.Count == 0) - return; - - var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString); - FilteredInGridEntries = AllItems().FilterEntries(searchResultSet); - refreshEntries(); - } - } - - protected RowComparer Comparer { get; } = new(); - protected override bool SupportsSortingCore => true; - protected override bool SupportsSearchingCore => true; - protected override bool IsSortedCore => isSorted; - protected override PropertyDescriptor? SortPropertyCore => propertyDescriptor; - protected override ListSortDirection SortDirectionCore => Comparer.SortOrder; - - /// <summary> Items that were removed from the base list due to filtering </summary> - private readonly List<GridEntry> FilterRemoved = new(); - private string? FilterString; - private bool isSorted; - private PropertyDescriptor? propertyDescriptor; - /// <summary> All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice)</summary> - private HashSet<GridEntry>? FilteredInGridEntries; - - #region Unused - Advanced Filtering - public bool SupportsAdvancedSorting => false; - - //This ApplySort overload is only called if SupportsAdvancedSorting is true. - //Otherwise BindingList.ApplySort() is used - public void ApplySort(ListSortDescriptionCollection sorts) => throw new NotImplementedException(); - - public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException(); - #endregion - - public new void Remove(GridEntry entry) - { - FilterRemoved.Remove(entry); - base.Remove(entry); - } - - /// <summary> - /// This method should be called whenever there's been a change to the - /// set of all GridEntries that affects sort order or filter status - /// </summary> - private void refreshEntries() - { - var priorState = RaiseListChangedEvents; - RaiseListChangedEvents = false; - - if (FilteredInGridEntries is null) - { - addRemovedItemsBack(FilterRemoved.ToList()); - } - else - { - var addBackEntries = FilterRemoved.Intersect(FilteredInGridEntries).ToList(); - var toRemoveEntries = Items.Except(FilteredInGridEntries).ToList(); - - addRemovedItemsBack(addBackEntries); - - foreach (var newRemove in toRemoveEntries) - { - FilterRemoved.Add(newRemove); - base.Remove(newRemove); - } - } - - SortInternal(); - - ResetList(); - RaiseListChangedEvents = priorState; - - void addRemovedItemsBack(List<GridEntry> addBackEntries) - { - //Add removed entries back into Items so they are displayed - //(except for episodes that are collapsed) - foreach (var addBack in addBackEntries) - { - if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && se.Liberate?.Expanded is not true) - continue; - - FilterRemoved.Remove(addBack); - Add(addBack); - } - } - } - - private void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs e) - { var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString); - var filterResults = AllItems().FilterEntries(searchResultSet); - - if (FilteredInGridEntries.SearchSetsDiffer(filterResults)) - { - FilteredInGridEntries = filterResults; - refreshEntries(); - } - } - - public void CollapseAll() - { - foreach (var series in Items.SeriesEntries().ToList()) - CollapseItem(series); - } - - public void ExpandAll() - { - foreach (var series in Items.SeriesEntries().ToList()) - ExpandItem(series); - } - - public void CollapseItem(SeriesEntry sEntry) - { - foreach (var episode in sEntry.Children.Intersect(Items.BookEntries()).ToList()) - { - FilterRemoved.Add(episode); - base.Remove(episode); - } - - sEntry.Liberate?.Expanded = false; - } - - public void ExpandItem(SeriesEntry sEntry) - { - var sindex = Items.IndexOf(sEntry); - - foreach (var episode in Comparer.OrderEntries(sEntry.Children.Intersect(FilterRemoved.BookEntries())).ToList()) - { - if (FilteredInGridEntries?.Contains(episode) ?? true) - { - FilterRemoved.Remove(episode); - InsertItem(++sindex, episode); - } - } - sEntry.Liberate?.Expanded = true; - } - - public void RemoveFilter() - { - FilterString = null; - FilteredInGridEntries = null; + FilteredInGridEntries = AllItems().FilterEntries(searchResultSet); refreshEntries(); } + } - protected override void ApplySortCore(PropertyDescriptor property, ListSortDirection direction) + protected RowComparer Comparer { get; } = new(); + protected override bool SupportsSortingCore => true; + protected override bool SupportsSearchingCore => true; + protected override bool IsSortedCore => isSorted; + protected override PropertyDescriptor? SortPropertyCore => propertyDescriptor; + protected override ListSortDirection SortDirectionCore => Comparer.SortOrder; + + /// <summary> Items that were removed from the base list due to filtering </summary> + private readonly List<GridEntry> FilterRemoved = new(); + private string? FilterString; + private bool isSorted; + private PropertyDescriptor? propertyDescriptor; + /// <summary> All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice)</summary> + private HashSet<GridEntry>? FilteredInGridEntries; + + #region Unused - Advanced Filtering + public bool SupportsAdvancedSorting => false; + + //This ApplySort overload is only called if SupportsAdvancedSorting is true. + //Otherwise BindingList.ApplySort() is used + public void ApplySort(ListSortDescriptionCollection sorts) => throw new NotImplementedException(); + + public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException(); + #endregion + + public new void Remove(GridEntry entry) + { + FilterRemoved.Remove(entry); + base.Remove(entry); + } + + /// <summary> + /// This method should be called whenever there's been a change to the + /// set of all GridEntries that affects sort order or filter status + /// </summary> + private void refreshEntries() + { + var priorState = RaiseListChangedEvents; + RaiseListChangedEvents = false; + + if (FilteredInGridEntries is null) { - Comparer.PropertyName = property.Name; - Comparer.SortOrder = direction; + addRemovedItemsBack(FilterRemoved.ToList()); + } + else + { + var addBackEntries = FilterRemoved.Intersect(FilteredInGridEntries).ToList(); + var toRemoveEntries = Items.Except(FilteredInGridEntries).ToList(); - SortInternal(); + addRemovedItemsBack(addBackEntries); - propertyDescriptor = property; - isSorted = true; - - ResetList(); + foreach (var newRemove in toRemoveEntries) + { + FilterRemoved.Add(newRemove); + base.Remove(newRemove); + } } - private void SortInternal() - { - var itemsList = (List<GridEntry>)Items; - //User Order/OrderDescending and replace items in list instead of using List.Sort() to achieve stable sorting. - var sortedItems = Comparer.OrderEntries(itemsList).ToList(); + SortInternal(); - itemsList.Clear(); - itemsList.AddRange(sortedItems); - } + ResetList(); + RaiseListChangedEvents = priorState; - private void GridEntryBindingList_ListChanged(object? sender, ListChangedEventArgs e) + void addRemovedItemsBack(List<GridEntry> addBackEntries) { - if (e.ListChangedType == ListChangedType.ItemChanged && IsSortedCore && e.PropertyDescriptor == SortPropertyCore) - refreshEntries(); - } + //Add removed entries back into Items so they are displayed + //(except for episodes that are collapsed) + foreach (var addBack in addBackEntries) + { + if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && se.Liberate?.Expanded is not true) + continue; - protected override void RemoveSortCore() - { - isSorted = false; - propertyDescriptor = base.SortPropertyCore; - Comparer.SortOrder = base.SortDirectionCore; - ResetList(); - } - - private void ResetList() - { - var priorState = RaiseListChangedEvents; - RaiseListChangedEvents = true; - OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); - RaiseListChangedEvents = priorState; + FilterRemoved.Remove(addBack); + Add(addBack); + } } } + + private void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs e) + { + var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString); + var filterResults = AllItems().FilterEntries(searchResultSet); + + if (FilteredInGridEntries.SearchSetsDiffer(filterResults)) + { + FilteredInGridEntries = filterResults; + refreshEntries(); + } + } + + public void CollapseAll() + { + foreach (var series in Items.SeriesEntries().ToList()) + CollapseItem(series); + } + + public void ExpandAll() + { + foreach (var series in Items.SeriesEntries().ToList()) + ExpandItem(series); + } + + public void CollapseItem(SeriesEntry sEntry) + { + foreach (var episode in sEntry.Children.Intersect(Items.BookEntries()).ToList()) + { + FilterRemoved.Add(episode); + base.Remove(episode); + } + + sEntry.Liberate?.Expanded = false; + } + + public void ExpandItem(SeriesEntry sEntry) + { + var sindex = Items.IndexOf(sEntry); + + foreach (var episode in Comparer.OrderEntries(sEntry.Children.Intersect(FilterRemoved.BookEntries())).ToList()) + { + if (FilteredInGridEntries?.Contains(episode) ?? true) + { + FilterRemoved.Remove(episode); + InsertItem(++sindex, episode); + } + } + sEntry.Liberate?.Expanded = true; + } + + public void RemoveFilter() + { + FilterString = null; + FilteredInGridEntries = null; + refreshEntries(); + } + + protected override void ApplySortCore(PropertyDescriptor property, ListSortDirection direction) + { + Comparer.PropertyName = property.Name; + Comparer.SortOrder = direction; + + SortInternal(); + + propertyDescriptor = property; + isSorted = true; + + ResetList(); + } + + private void SortInternal() + { + var itemsList = (List<GridEntry>)Items; + //User Order/OrderDescending and replace items in list instead of using List.Sort() to achieve stable sorting. + var sortedItems = Comparer.OrderEntries(itemsList).ToList(); + + itemsList.Clear(); + itemsList.AddRange(sortedItems); + } + + private void GridEntryBindingList_ListChanged(object? sender, ListChangedEventArgs e) + { + if (e.ListChangedType == ListChangedType.ItemChanged && IsSortedCore && e.PropertyDescriptor == SortPropertyCore) + refreshEntries(); + } + + protected override void RemoveSortCore() + { + isSorted = false; + propertyDescriptor = base.SortPropertyCore; + Comparer.SortOrder = base.SortDirectionCore; + ResetList(); + } + + private void ResetList() + { + var priorState = RaiseListChangedEvents; + RaiseListChangedEvents = true; + OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + RaiseListChangedEvents = priorState; + } } diff --git a/Source/LibationWinForms/GridView/ImageDisplay.cs b/Source/LibationWinForms/GridView/ImageDisplay.cs index 0f04bb2e..5ded2971 100644 --- a/Source/LibationWinForms/GridView/ImageDisplay.cs +++ b/Source/LibationWinForms/GridView/ImageDisplay.cs @@ -3,117 +3,116 @@ using System.Drawing; using System.IO; using System.Windows.Forms; -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public partial class ImageDisplay : Form { - public partial class ImageDisplay : Form + public string? PictureFileName { get; set; } + public string? BookSaveDirectory { get; set; } + + public ImageDisplay() { - public string PictureFileName { get; set; } - public string BookSaveDirectory { get; set; } + InitializeComponent(); + lastWidth = Width; + lastHeight = Height; + } - public ImageDisplay() - { - InitializeComponent(); - lastWidth = Width; - lastHeight = Height; - } + public void SetCoverArt(byte[] cover) + { + pictureBox1.Image = WinFormsUtil.TryLoadImageOrDefault(cover); + } - public void SetCoverArt(byte[] cover) - { - pictureBox1.Image = WinFormsUtil.TryLoadImageOrDefault(cover); - } + #region Make the form's aspect ratio always match the picture's aspect ratio. - #region Make the form's aspect ratio always match the picture's aspect ratio. + private bool detectedResizeDirection = false; + private bool resizingWidth = false; + private bool resizingHeight = false; - private bool detectedResizeDirection = false; - private bool resizingWidth = false; - private bool resizingHeight = false; + private int lastWidth; + private int lastHeight; + private int formExtraWidth; + private int formExtraHeight; - private int lastWidth; - private int lastHeight; - private int formExtraWidth; - private int formExtraHeight; + private double pictureAR = 1; + protected override void OnResizeBegin(EventArgs e) + { + detectedResizeDirection = false; + base.OnResizeBegin(e); + } - private double pictureAR = 1; - protected override void OnResizeBegin(EventArgs e) - { - detectedResizeDirection = false; - base.OnResizeBegin(e); - } + protected override void OnResizeEnd(EventArgs e) + { + base.OnResize(e); + base.OnResizeEnd(e); + } - protected override void OnResizeEnd(EventArgs e) + protected override void OnResize(EventArgs e) + { + if (WindowState != FormWindowState.Normal) { base.OnResize(e); - base.OnResizeEnd(e); + return; } - protected override void OnResize(EventArgs e) + int width = this.Width, height = this.Height; + + if (!detectedResizeDirection) { - if (WindowState != FormWindowState.Normal) - { - base.OnResize(e); - return; - } - - int width = this.Width, height = this.Height; - - if (!detectedResizeDirection) - { - resizingWidth = lastWidth != width; - resizingHeight = lastHeight != height; - detectedResizeDirection = true; - } - - if (resizingWidth && !resizingHeight) - height = CalculateARHeight(width); - else - width = CalculateARWidth(height); - - pictureBox1.Size = new Size(width - formExtraWidth, height - formExtraHeight); - - lastWidth = width; - lastHeight = height; - - SetBoundsCore(Location.X, Location.Y, width, height, BoundsSpecified.Width | BoundsSpecified.Height); + resizingWidth = lastWidth != width; + resizingHeight = lastHeight != height; + detectedResizeDirection = true; } - private int CalculateARHeight(int width) + if (resizingWidth && !resizingHeight) + height = CalculateARHeight(width); + else + width = CalculateARWidth(height); + + pictureBox1.Size = new Size(width - formExtraWidth, height - formExtraHeight); + + lastWidth = width; + lastHeight = height; + + SetBoundsCore(Location.X, Location.Y, width, height, BoundsSpecified.Width | BoundsSpecified.Height); + } + + private int CalculateARHeight(int width) + { + return (int)((width - formExtraWidth) * pictureAR) + formExtraHeight; + } + + private int CalculateARWidth(int height) + { + return (int)((height - formExtraHeight) * pictureAR) + formExtraWidth; + } + + #endregion + + private void ImageDisplay_Shown(object sender, EventArgs e) + { + formExtraWidth = Width - pictureBox1.Width; + formExtraHeight = Height - pictureBox1.Height; + OnResize(e); + } + + private void savePictureToolStripMenuItem_Click(object sender, EventArgs e) + { + SaveFileDialog saveFileDialog = new(); + saveFileDialog.Filter = "jpeg|*.jpg"; + saveFileDialog.InitialDirectory = Directory.Exists(BookSaveDirectory) ? BookSaveDirectory : Path.GetDirectoryName(BookSaveDirectory); + saveFileDialog.FileName = PictureFileName; + + if (saveFileDialog.ShowDialog() != DialogResult.OK) + return; + + try { - return (int)((width - formExtraWidth) * pictureAR) + formExtraHeight; + pictureBox1.Image?.Save(saveFileDialog.FileName); } - - private int CalculateARWidth(int height) + catch (Exception ex) { - return (int)((height - formExtraHeight) * pictureAR) + formExtraWidth; - } - - #endregion - - private void ImageDisplay_Shown(object sender, EventArgs e) - { - formExtraWidth = Width - pictureBox1.Width; - formExtraHeight = Height - pictureBox1.Height; - OnResize(e); - } - - private void savePictureToolStripMenuItem_Click(object sender, EventArgs e) - { - SaveFileDialog saveFileDialog = new(); - saveFileDialog.Filter = "jpeg|*.jpg"; - saveFileDialog.InitialDirectory = Directory.Exists(BookSaveDirectory) ? BookSaveDirectory : Path.GetDirectoryName(BookSaveDirectory); - saveFileDialog.FileName = PictureFileName; - - if (saveFileDialog.ShowDialog() != DialogResult.OK) - return; - - try - { - pictureBox1.Image.Save(saveFileDialog.FileName); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"Failed to save picture to {saveFileDialog.FileName}"); - MessageBox.Show(this, $"An error was encountered while trying to save the picture\r\n\r\n{ex.Message}", "Failed to save picture", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - } + Serilog.Log.Logger.Error(ex, $"Failed to save picture to {saveFileDialog.FileName}"); + MessageBox.Show(this, $"An error was encountered while trying to save the picture\r\n\r\n{ex.Message}", "Failed to save picture", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); } } } diff --git a/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs b/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs index 347fe37e..5ca65707 100644 --- a/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs +++ b/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs @@ -3,42 +3,41 @@ using System.Drawing; using System.Windows.Forms; using LibationUiBase.GridView; -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public class LastDownloadedGridViewColumn : DataGridViewColumn { - public class LastDownloadedGridViewColumn : DataGridViewColumn + public LastDownloadedGridViewColumn() : base(new LastDownloadedGridViewCell()) { } + public override DataGridViewCell? CellTemplate { - public LastDownloadedGridViewColumn() : base(new LastDownloadedGridViewCell()) { } - public override DataGridViewCell CellTemplate + get => base.CellTemplate; + set { - get => base.CellTemplate; - set - { - if (value is not LastDownloadedGridViewCell) - throw new InvalidCastException($"Must be a {nameof(LastDownloadedGridViewCell)}"); + if (value is not LastDownloadedGridViewCell) + throw new InvalidCastException($"Must be a {nameof(LastDownloadedGridViewCell)}"); - base.CellTemplate = value; - } - } - } - - internal class LastDownloadedGridViewCell : AccessibleDataGridViewTextBoxCell - { - private LastDownloadStatus LastDownload => (LastDownloadStatus)Value; - - public LastDownloadedGridViewCell() : base("Last Downloaded") { } - - protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); - - if (value is LastDownloadStatus lastDl) - AccessibilityDescription = lastDl.ToolTipText; - } - - protected override void OnDoubleClick(DataGridViewCellEventArgs e) - { - LastDownload.OpenReleaseUrl(); - base.OnDoubleClick(e); + base.CellTemplate = value; } } } + +internal class LastDownloadedGridViewCell : AccessibleDataGridViewTextBoxCell +{ + private LastDownloadStatus? LastDownload => Value as LastDownloadStatus; + + public LastDownloadedGridViewCell() : base("Last Downloaded") { } + + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); + + if (value is LastDownloadStatus lastDl) + AccessibilityDescription = lastDl.ToolTipText; + } + + protected override void OnDoubleClick(DataGridViewCellEventArgs e) + { + LastDownload?.OpenReleaseUrl(); + base.OnDoubleClick(e); + } +} diff --git a/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs b/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs index 164a37f1..f45a843f 100644 --- a/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs +++ b/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs @@ -3,48 +3,46 @@ using LibationUiBase.GridView; using System.Drawing; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms.GridView -{ - public class LiberateDataGridViewImageButtonColumn : DataGridViewButtonColumn, IDataGridScaleColumn - { - public LiberateDataGridViewImageButtonColumn() - { - CellTemplate = new LiberateDataGridViewImageButtonCell(); - } +namespace LibationWinForms.GridView; - public float ScaleFactor { get; set; } +public class LiberateDataGridViewImageButtonColumn : DataGridViewButtonColumn, IDataGridScaleColumn +{ + public LiberateDataGridViewImageButtonColumn() + { + CellTemplate = new LiberateDataGridViewImageButtonCell(); } - internal class LiberateDataGridViewImageButtonCell : DataGridViewImageButtonCell + public float ScaleFactor { get; set; } +} + +internal class LiberateDataGridViewImageButtonCell : DataGridViewImageButtonCell +{ + public LiberateDataGridViewImageButtonCell() : base("Liberate button") { } + + private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray)); + private static readonly Color HiddenForeColor = Color.LightGray; + private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230); + private static readonly Color SERIES_BG_COLOR_DARK = Color.FromArgb(76, 82, 93); + private static Color SeriesBgColor => Application.IsDarkModeEnabled ? SERIES_BG_COLOR_DARK:SERIES_BG_COLOR; + + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { - public LiberateDataGridViewImageButtonCell() : base("Liberate button") { } - - private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray)); - private static readonly Color HiddenForeColor = Color.LightGray; - private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230); - private static readonly Color SERIES_BG_COLOR_DARK = Color.FromArgb(76, 82, 93); - private static Color SeriesBgColor => Application.IsDarkModeEnabled ? SERIES_BG_COLOR_DARK:SERIES_BG_COLOR; - - protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + if (OwningRow is DataGridViewRow row && row.DataGridView is DataGridView grid && value is EntryStatus status) { - if (OwningRow is DataGridViewRow row && row.DataGridView is DataGridView grid && value is EntryStatus status) - { - if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable) - //Don't paint the button graphic - paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground; + if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable) + //Don't paint the button graphic + paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground; - row.DefaultCellStyle.BackColor = status.IsEpisode ? SeriesBgColor : grid.DefaultCellStyle.BackColor; - row.DefaultCellStyle.ForeColor = status.Opacity == 1 ? grid.DefaultCellStyle.ForeColor : HiddenForeColor; - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); + row.DefaultCellStyle.BackColor = status.IsEpisode ? SeriesBgColor : grid.DefaultCellStyle.BackColor; + row.DefaultCellStyle.ForeColor = status.Opacity == 1 ? grid.DefaultCellStyle.ForeColor : HiddenForeColor; + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); - if (status.ButtonImage is Image buttonImage) - DrawButtonImage(graphics, buttonImage, cellBounds); - AccessibilityDescription = status.ToolTip; + if (status.ButtonImage is Image buttonImage) + DrawButtonImage(graphics, buttonImage, cellBounds); + AccessibilityDescription = status.ToolTip; - if (status.IsUnavailable || status.Opacity < 1) - graphics.FillRectangle(DISABLED_GRAY, cellBounds); - } + if (status.IsUnavailable || status.Opacity < 1) + graphics.FillRectangle(DISABLED_GRAY, cellBounds); } } } diff --git a/Source/LibationWinForms/GridView/MyRatingCellEditor.cs b/Source/LibationWinForms/GridView/MyRatingCellEditor.cs index 0ada656d..598cce8a 100644 --- a/Source/LibationWinForms/GridView/MyRatingCellEditor.cs +++ b/Source/LibationWinForms/GridView/MyRatingCellEditor.cs @@ -3,172 +3,175 @@ using System; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Drawing; -using System.Linq; using System.Windows.Forms; -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public partial class MyRatingCellEditor : UserControl, IDataGridViewEditingControl { - public partial class MyRatingCellEditor : UserControl, IDataGridViewEditingControl - { - private const string SOLID_STAR = "★"; - private const string HOLLOW_STAR = "☆"; + private const string SOLID_STAR = "★"; + private const string HOLLOW_STAR = "☆"; - public Rating Rating - { - get => field; - set - { - field = value; - int rating = 0; - foreach (NoBorderLabel star in panelOverall.Controls) - star.Tag = star.Text = field.OverallRating > rating++ ? SOLID_STAR : HOLLOW_STAR; - - rating = 0; - foreach (NoBorderLabel star in panelPerform.Controls) - star.Tag = star.Text = field.PerformanceRating > rating++ ? SOLID_STAR : HOLLOW_STAR; - - rating = 0; - foreach (NoBorderLabel star in panelStory.Controls) - star.Tag = star.Text = field.StoryRating > rating++ ? SOLID_STAR : HOLLOW_STAR; - } - } - - public MyRatingCellEditor() + public Rating Rating + { + get => field; + set { - InitializeComponent(); - this.FontChanged += MyRatingCellEditor_FontChanged; + field = value; + int rating = 0; + foreach (NoBorderLabel star in panelOverall.Controls) + star.Tag = star.Text = field.OverallRating > rating++ ? SOLID_STAR : HOLLOW_STAR; + + rating = 0; + foreach (NoBorderLabel star in panelPerform.Controls) + star.Tag = star.Text = field.PerformanceRating > rating++ ? SOLID_STAR : HOLLOW_STAR; + + rating = 0; + foreach (NoBorderLabel star in panelStory.Controls) + star.Tag = star.Text = field.StoryRating > rating++ ? SOLID_STAR : HOLLOW_STAR; } - - private void MyRatingCellEditor_FontChanged(object sender, EventArgs e) - { - var scale = Font.Size / 9; - Scale(new SizeF(scale, scale)); - } - - private void Star_MouseEnter(object sender, EventArgs e) - { - var thisTbox = sender as NoBorderLabel; - var panel = thisTbox.Parent as Panel; - var star = SOLID_STAR; - - foreach (NoBorderLabel child in panel.Controls) - { - child.Text = star; - if (child == thisTbox) star = HOLLOW_STAR; - } - } - - private void Star_MouseLeave(object sender, EventArgs e) - { - var thisTbox = sender as NoBorderLabel; - var panel = thisTbox.Parent as Panel; - - //Artifically shrink rectangle to guarantee mouse is outside when exiting from the left (negative X) - var clientPt = panel.PointToClient(MousePosition); - var rect = new Rectangle(0, 0, panel.ClientRectangle.Width - 2, panel.ClientRectangle.Height); - if (!rect.Contains(clientPt.X - 2, clientPt.Y)) - { - //Restore defaults - foreach (NoBorderLabel child in panel.Controls) - child.Text = (string)child.Tag; - } - } - - private void Star_MouseClick(object sender, MouseEventArgs e) - { - var overall = Rating.OverallRating; - var perform = Rating.PerformanceRating; - var story = Rating.StoryRating; - - var thisTbox = sender as NoBorderLabel; - var panel = thisTbox.Parent as Panel; - - int newRatingValue = 0; - foreach (var child in panel.Controls) - { - newRatingValue++; - if (child == thisTbox) break; - } - - if (panel == panelOverall) - overall = newRatingValue; - else if (panel == panelPerform) - perform = newRatingValue; - else if (panel == panelStory) - story = newRatingValue; - - if (overall + perform + story == 0f) return; - - var newRating = new Rating(overall, perform, story); - - if (newRating == Rating) return; - - Rating = newRating; - EditingControlValueChanged = true; - EditingControlDataGridView.NotifyCurrentCellDirty(true); - } - - protected override void OnKeyDown(KeyEventArgs e) - { - if (e.KeyCode == Keys.Escape) - { - EditingControlDataGridView.RefreshEdit(); - EditingControlDataGridView.CancelEdit(); - EditingControlDataGridView.CurrentCell.DetachEditingControl(); - EditingControlDataGridView.CurrentCell = null; - - } - base.OnKeyDown(e); - } - - #region IDataGridViewEditingControl - - public DataGridView EditingControlDataGridView { get; set; } - public int EditingControlRowIndex { get; set; } - public bool EditingControlValueChanged { get; set; } - public object EditingControlFormattedValue { get => Rating; set { } } - public Cursor EditingPanelCursor => Cursor; - public bool RepositionEditingControlOnValueChange => false; - - public void ApplyCellStyleToEditingControl(DataGridViewCellStyle dataGridViewCellStyle) - { - Font = dataGridViewCellStyle.Font; - ForeColor = dataGridViewCellStyle.ForeColor; - BackColor = dataGridViewCellStyle.BackColor; - } - - public bool EditingControlWantsInputKey(Keys keyData, bool dataGridViewWantsInputKey) => keyData == Keys.Escape; - public object GetEditingControlFormattedValue(DataGridViewDataErrorContexts context) => EditingControlFormattedValue; - public void PrepareEditingControlForEdit(bool selectAll) { } - - #endregion } - public class NoBorderLabel : Panel + public MyRatingCellEditor() { - private string _text; - [Description("Label text"), Category("Data")] - [Browsable(true)] - [EditorBrowsable(EditorBrowsableState.Always)] - [AllowNull] - public override string Text + InitializeComponent(); + Rating = new Rating(0, 0, 0); + this.FontChanged += MyRatingCellEditor_FontChanged; + } + + private void MyRatingCellEditor_FontChanged(object? sender, EventArgs e) + { + var scale = Font.Size / 9; + Scale(new SizeF(scale, scale)); + } + + private void Star_MouseEnter(object sender, EventArgs e) + { + var thisTbox = sender as NoBorderLabel; + if (thisTbox?.Parent is not Panel panel) + return; + var star = SOLID_STAR; + + foreach (NoBorderLabel child in panel.Controls) { - get => _text; - set - { - _text = value; - Invalidate(); - } + child.Text = star; + if (child == thisTbox) star = HOLLOW_STAR; + } + } + + private void Star_MouseLeave(object sender, EventArgs e) + { + var thisTbox = sender as NoBorderLabel; + if (thisTbox?.Parent is not Panel panel) + return; + + //Artifically shrink rectangle to guarantee mouse is outside when exiting from the left (negative X) + var clientPt = panel.PointToClient(MousePosition); + var rect = new Rectangle(0, 0, panel.ClientRectangle.Width - 2, panel.ClientRectangle.Height); + if (!rect.Contains(clientPt.X - 2, clientPt.Y)) + { + //Restore defaults + foreach (NoBorderLabel child in panel.Controls) + child.Text = child.Tag as string; + } + } + + private void Star_MouseClick(object sender, MouseEventArgs e) + { + var overall = Rating.OverallRating; + var perform = Rating.PerformanceRating; + var story = Rating.StoryRating; + + var thisTbox = sender as NoBorderLabel; + if (thisTbox?.Parent is not Panel panel) + return; + + int newRatingValue = 0; + foreach (var child in panel.Controls) + { + newRatingValue++; + if (child == thisTbox) break; } - [Description("X and Y offset for text drawing position. May be negative."), Category("Layout")] - [Browsable(true)] - [EditorBrowsable(EditorBrowsableState.Always)] - public Point LabelOffset { get; set; } - protected override void OnPaint(PaintEventArgs e) + if (panel == panelOverall) + overall = newRatingValue; + else if (panel == panelPerform) + perform = newRatingValue; + else if (panel == panelStory) + story = newRatingValue; + + if (overall + perform + story == 0f) return; + + var newRating = new Rating(overall, perform, story); + + if (newRating == Rating) return; + + Rating = newRating; + EditingControlValueChanged = true; + EditingControlDataGridView?.NotifyCurrentCellDirty(true); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.KeyCode == Keys.Escape && EditingControlDataGridView is not null) { - TextRenderer.DrawText(e, Text, this.Font, LabelOffset, this.ForeColor); - base.OnPaint(e); + EditingControlDataGridView.RefreshEdit(); + EditingControlDataGridView.CancelEdit(); + EditingControlDataGridView.CurrentCell?.DetachEditingControl(); + EditingControlDataGridView.CurrentCell = null; + } + base.OnKeyDown(e); + } + + #region IDataGridViewEditingControl + + public DataGridView? EditingControlDataGridView { get; set; } + public int EditingControlRowIndex { get; set; } + public bool EditingControlValueChanged { get; set; } + [AllowNull] + public object EditingControlFormattedValue { get => Rating; set { } } + public Cursor EditingPanelCursor => Cursor; + public bool RepositionEditingControlOnValueChange => false; + + public void ApplyCellStyleToEditingControl(DataGridViewCellStyle dataGridViewCellStyle) + { + Font = dataGridViewCellStyle.Font; + ForeColor = dataGridViewCellStyle.ForeColor; + BackColor = dataGridViewCellStyle.BackColor; + } + + public bool EditingControlWantsInputKey(Keys keyData, bool dataGridViewWantsInputKey) => keyData == Keys.Escape; + public object GetEditingControlFormattedValue(DataGridViewDataErrorContexts context) => EditingControlFormattedValue; + public void PrepareEditingControlForEdit(bool selectAll) { } + + #endregion +} + +public class NoBorderLabel : Panel +{ + private string? _text; + [Description("Label text"), Category("Data")] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] + [AllowNull] + public override string Text + { + get => _text ?? string.Empty; + set + { + _text = value; + Invalidate(); + } + } + + [Description("X and Y offset for text drawing position. May be negative."), Category("Layout")] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] + public Point LabelOffset { get; set; } + protected override void OnPaint(PaintEventArgs e) + { + TextRenderer.DrawText(e, Text, this.Font, LabelOffset, this.ForeColor); + base.OnPaint(e); } } diff --git a/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs b/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs index 24d694cf..ddee4c25 100644 --- a/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs +++ b/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs @@ -1,67 +1,65 @@ using System; using System.ComponentModel; using System.Drawing; -using System.Linq; using System.Windows.Forms; using DataLayer; -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public class MyRatingGridViewColumn : DataGridViewColumn { - public class MyRatingGridViewColumn : DataGridViewColumn + public MyRatingGridViewColumn() : base(new MyRatingGridViewCell()) { } + + public override DataGridViewCell? CellTemplate { - public MyRatingGridViewColumn() : base(new MyRatingGridViewCell()) { } - - public override DataGridViewCell CellTemplate + get => base.CellTemplate; + set { - get => base.CellTemplate; - set - { - if (value is not MyRatingGridViewCell) - throw new InvalidCastException($"Must be a {nameof(MyRatingGridViewCell)}"); + if (value is not MyRatingGridViewCell) + throw new InvalidCastException($"Must be a {nameof(MyRatingGridViewCell)}"); - base.CellTemplate = value; - } + base.CellTemplate = value; } } - - internal class MyRatingGridViewCell : AccessibleDataGridViewTextBoxCell - { - private static Rating DefaultRating => new Rating(0, 0, 0); - public override object DefaultNewRowValue => DefaultRating; - public override Type EditType => typeof(MyRatingCellEditor); - public override Type ValueType => typeof(Rating); - - public MyRatingGridViewCell() : base("My Rating") - { - AccessibilityDescription = ReadOnly ? "" : "Click to change ratings"; - } - - public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle) - { - base.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle); - - var ctl = DataGridView.EditingControl as MyRatingCellEditor; - - ctl.Rating = Value is Rating rating ? rating : DefaultRating; - } - - protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) - { - if (value is Rating rating) - { - var starString = rating.ToStarString(); - base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, starString, starString, errorText, cellStyle, advancedBorderStyle, paintParts); - - AccessibilityDescription = ReadOnly ? "" : "Click to change ratings"; - } - else - base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, string.Empty, string.Empty, errorText, cellStyle, advancedBorderStyle, paintParts); - } - - protected override object GetFormattedValue(object value, int rowIndex, ref DataGridViewCellStyle cellStyle, TypeConverter valueTypeConverter, TypeConverter formattedValueTypeConverter, DataGridViewDataErrorContexts context) - => value is Rating rating ? rating.ToStarString() : value?.ToString(); - - public override object ParseFormattedValue(object formattedValue, DataGridViewCellStyle cellStyle, TypeConverter formattedValueTypeConverter, TypeConverter valueTypeConverter) - => formattedValue; - } +} + +internal class MyRatingGridViewCell : AccessibleDataGridViewTextBoxCell +{ + private static Rating DefaultRating => new Rating(0, 0, 0); + public override object DefaultNewRowValue => DefaultRating; + public override Type EditType => typeof(MyRatingCellEditor); + public override Type ValueType => typeof(Rating); + + public MyRatingGridViewCell() : base("My Rating") + { + AccessibilityDescription = ReadOnly ? "" : "Click to change ratings"; + } + + public override void InitializeEditingControl(int rowIndex, object? initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle) + { + base.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle); + + var ctl = DataGridView?.EditingControl as MyRatingCellEditor; + + ctl?.Rating = Value is Rating rating ? rating : DefaultRating; + } + + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + { + if (value is Rating rating) + { + var starString = rating.ToStarString(); + base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, starString, starString, errorText, cellStyle, advancedBorderStyle, paintParts); + + AccessibilityDescription = ReadOnly ? "" : "Click to change ratings"; + } + else + base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, string.Empty, string.Empty, errorText, cellStyle, advancedBorderStyle, paintParts); + } + + protected override object? GetFormattedValue(object? value, int rowIndex, ref DataGridViewCellStyle cellStyle, TypeConverter? valueTypeConverter, TypeConverter? formattedValueTypeConverter, DataGridViewDataErrorContexts context) + => value is Rating rating ? rating.ToStarString() : value?.ToString(); + + public override object? ParseFormattedValue(object? formattedValue, DataGridViewCellStyle cellStyle, TypeConverter? formattedValueTypeConverter, TypeConverter? valueTypeConverter) + => formattedValue; } diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index d1970a30..ef24faab 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -14,442 +14,446 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public partial class ProductsDisplay : UserControl { - public partial class ProductsDisplay : UserControl + /// <summary>Number of visible rows has changed</summary> + public event EventHandler<int>? VisibleCountChanged; + public event EventHandler<int>? RemovableCountChanged; + public event LiberateClickedHandler? LiberateClicked; + public event EventHandler<SeriesEntry>? LiberateSeriesClicked; + public event EventHandler<LibraryBook[]>? ConvertToMp3Clicked; + public event EventHandler? InitialLoaded; + + private bool hasBeenDisplayed; + + public ProductsDisplay() { - /// <summary>Number of visible rows has changed</summary> - public event EventHandler<int>? VisibleCountChanged; - public event EventHandler<int>? RemovableCountChanged; - public event LiberateClickedHandler? LiberateClicked; - public event EventHandler<SeriesEntry>? LiberateSeriesClicked; - public event EventHandler<LibraryBook[]>? ConvertToMp3Clicked; - public event EventHandler? InitialLoaded; + InitializeComponent(); + productsGrid.SearchEngine = MainSearchEngine.Instance; + } - private bool hasBeenDisplayed; + #region Button controls - public ProductsDisplay() + private ImageDisplay? imageDisplay; + private void productsGrid_CoverClicked(GridEntry liveGridEntry) + { + var pictureId = liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId; + if (string.IsNullOrEmpty(pictureId)) { - InitializeComponent(); - productsGrid.SearchEngine = MainSearchEngine.Instance; + MessageBox.Show(this, "No cover art is available for this book.", "No Cover Art", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; } - #region Button controls - - private ImageDisplay? imageDisplay; - private void productsGrid_CoverClicked(GridEntry liveGridEntry) + var picDef = new PictureDefinition(pictureId, PictureSize.Native); + void PictureCached(object? sender, PictureCachedEventArgs e) { - var picDef = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); + if (e.Definition.PictureId == picDef.PictureId) + imageDisplay?.SetCoverArt(e.Picture); - void PictureCached(object? sender, PictureCachedEventArgs e) - { - if (e.Definition.PictureId == picDef.PictureId) - imageDisplay?.SetCoverArt(e.Picture); - - PictureStorage.PictureCached -= PictureCached; - } - - PictureStorage.PictureCached += PictureCached; - (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); - - var windowTitle = $"{liveGridEntry.Title} - Cover"; - - if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) - { - imageDisplay = new ImageDisplay(); - imageDisplay.RestoreSizeAndLocation(Configuration.Instance); - imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); - } - - imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(liveGridEntry.LibraryBook); - imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(liveGridEntry.LibraryBook, ".jpg")); - imageDisplay.Text = windowTitle; - imageDisplay.SetCoverArt(initialImageBts); - if (!isDefault) - PictureStorage.PictureCached -= PictureCached; - - if (!imageDisplay.Visible) - imageDisplay.Show(this); + PictureStorage.PictureCached -= PictureCached; } - private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle) + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); + + var windowTitle = $"{liveGridEntry.Title} - Cover"; + + if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) { - var displayWindow = new DescriptionDisplay - { - SpawnLocation = PointToScreen(cellRectangle.Location + new Size(cellRectangle.Width, 0)), - DescriptionText = liveGridEntry.Description, - BorderThickness = 2, - }; - - void CloseWindow(object? o, EventArgs e) - { - displayWindow.Close(); - } - - productsGrid.Scroll += CloseWindow; - displayWindow.FormClosed += (_, _) => productsGrid.Scroll -= CloseWindow; - displayWindow.Show(this); + imageDisplay = new ImageDisplay(); + imageDisplay.RestoreSizeAndLocation(Configuration.Instance); + imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); } - private BookDetailsDialog? bookDetailsForm; - private void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry) + imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(liveGridEntry.LibraryBook); + imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(liveGridEntry.LibraryBook, ".jpg")); + imageDisplay.Text = windowTitle; + imageDisplay.SetCoverArt(initialImageBts); + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplay.Visible) + imageDisplay.Show(this); + } + + private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle) + { + var displayWindow = new DescriptionDisplay { - if (bookDetailsForm is null || bookDetailsForm.IsDisposed || !bookDetailsForm.Visible) - { - bookDetailsForm = new(); - bookDetailsForm.RestoreSizeAndLocation(Configuration.Instance); - bookDetailsForm.FormClosed += bookDetailsForm_FormClosed; - } + SpawnLocation = PointToScreen(cellRectangle.Location + new Size(cellRectangle.Width, 0)), + DescriptionText = liveGridEntry.Description, + BorderThickness = 2, + }; - bookDetailsForm.LibraryBook = liveGridEntry.LibraryBook; - if (!bookDetailsForm.Visible) - bookDetailsForm.Show(this); - - async void bookDetailsForm_FormClosed(object? sender, FormClosedEventArgs e) - { - bookDetailsForm.FormClosed -= bookDetailsForm_FormClosed; - bookDetailsForm.SaveSizeAndLocation(Configuration.Instance); - } + void CloseWindow(object? o, EventArgs e) + { + displayWindow.Close(); } - #endregion + productsGrid.Scroll += CloseWindow; + displayWindow.FormClosed += (_, _) => productsGrid.Scroll -= CloseWindow; + displayWindow.Show(this); + } - #region Cell Context Menu - - private void productsGrid_CellContextMenuStripNeeded(GridEntry[] entries, ContextMenuStrip ctxMenu) + private BookDetailsDialog? bookDetailsForm; + private void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry) + { + if (bookDetailsForm is null || bookDetailsForm.IsDisposed || !bookDetailsForm.Visible) { - var ctx = new GridContextMenu(entries, '&'); - #region Liberate all Episodes (Single series only) - - if (entries.Length == 1 && entries[0] is SeriesEntry seriesEntry) - { - var liberateEpisodesMenuItem = new ToolStripMenuItem() - { - Text = ctx.LiberateEpisodesText, - Enabled = ctx.LiberateEpisodesEnabled - }; - - liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, seriesEntry); - ctxMenu.Items.Add(liberateEpisodesMenuItem); - } - - #endregion - #region Set Download status to Downloaded - - var setDownloadMenuItem = new ToolStripMenuItem() - { - Text = ctx.SetDownloadedText, - Enabled = ctx.SetDownloadedEnabled - }; - setDownloadMenuItem.Click += (_, _) => ctx.SetDownloaded(); - ctxMenu.Items.Add(setDownloadMenuItem); - - #endregion - #region Set Download status to Not Downloaded - - var setNotDownloadMenuItem = new ToolStripMenuItem() - { - Text = ctx.SetNotDownloadedText, - Enabled = ctx.SetNotDownloadedEnabled - }; - setNotDownloadMenuItem.Click += (_, _) => ctx.SetNotDownloaded(); - ctxMenu.Items.Add(setNotDownloadMenuItem); - - #endregion - #region Locate file (Single book only) - - if (entries.Length == 1 && entries[0] is LibraryBookEntry entry) - { - var locateFileMenuItem = new ToolStripMenuItem() { Text = ctx.LocateFileText }; - ctxMenu.Items.Add(locateFileMenuItem); - locateFileMenuItem.Click += (_, _) => - { - try - { - var openFileDialog = new OpenFileDialog - { - Title = ctx.LocateFileDialogTitle, - Filter = "All files (*.*)|*.*", - FilterIndex = 1 - }; - if (openFileDialog.ShowDialog() == DialogResult.OK) - FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert(this, ctx.LocateFileErrorMessage, ctx.LocateFileErrorMessage, ex); - } - }; - } - - #endregion - #region Remove from library - - var removeMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveText }; - removeMenuItem.Click += async (_, _) => await ctx.RemoveAsync(); - ctxMenu.Items.Add(removeMenuItem); - - #endregion - #region Liberate All (multiple books only) - if (entries.OfType<LibraryBookEntry>().Count() > 1) - { - var downloadSelectedMenuItem = new ToolStripMenuItem() - { - Text = ctx.DownloadSelectedText - }; - ctxMenu.Items.Add(downloadSelectedMenuItem); - downloadSelectedMenuItem.Click += (s, _) => - { - LiberateClicked?.Invoke(s, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray(), Configuration.Instance); - }; - } - - #endregion - #region Download split by chapters - - if (ctx.LibraryBookEntries.Length > 0) - { - var downloadChaptersMenuItem = new ToolStripMenuItem - { - Text = ctx.DownloadAsChapters, - Enabled = ctx.DownloadAsChaptersEnabled - }; - downloadChaptersMenuItem.Click += (_, e) => - { - var config = Configuration.Instance.CreateEphemeralCopy(); - config.AllowLibationFixup = config.SplitFilesByChapter = true; - var books = ctx.LibraryBookEntries.Select(e => e.LibraryBook).Where(lb => lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error).ToList(); - books.ForEach(b => b.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated); - LiberateClicked?.Invoke(this, books, config); - }; - ctxMenu.Items.Add(downloadChaptersMenuItem); - } - - #endregion - #region Convert to Mp3 - - if (ctx.LibraryBookEntries.Length > 0) - { - var convertToMp3MenuItem = new ToolStripMenuItem - { - Text = ctx.ConvertToMp3Text, - Enabled = ctx.ConvertToMp3Enabled - }; - convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(this, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray()); - ctxMenu.Items.Add(convertToMp3MenuItem); - } - - #endregion - #region Force Re-Download (Single book only) - - if (entries.Length == 1 && entries[0] is LibraryBookEntry entry4) - { - var reDownloadMenuItem = new ToolStripMenuItem() - { - Text = ctx.ReDownloadText, - Enabled = ctx.ReDownloadEnabled - }; - ctxMenu.Items.Add(reDownloadMenuItem); - reDownloadMenuItem.Click += (s, _) => - { - //No need to persist these changes. They only needs to last long for the files to start downloading - entry4.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; - if (entry4.Book.HasPdf) - entry4.Book.UserDefinedItem.SetPdfStatus(LiberatedStatus.NotLiberated); - - LiberateClicked?.Invoke(s, [entry4.LibraryBook], Configuration.Instance); - }; - } - - #endregion - #region Remove Audible Plus Books from Audible Library - - if (entries.Length != 1 || ctx.RemoveFromAudibleEnabled) - { - ctxMenu.Items.Add(new ToolStripSeparator()); - var removeFromAudibleMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveFromAudibleText, Enabled = ctx.RemoveFromAudibleEnabled }; - removeFromAudibleMenuItem.Click += async (_, _) => await ctx.RemoveFromAudibleAsync(); - ctxMenu.Items.Add(removeFromAudibleMenuItem); - } - - #endregion - if (entries.Length > 1) - return; - - ctxMenu.Items.Add(new ToolStripSeparator()); - - #region Edit Templates (Single book only) - - void editTemplate<T>(LibraryBook libraryBook, string existingTemplate, Action<string> setNewTemplate) - where T : Templates, ITemplate, new() - { - var template = ctx.CreateTemplateEditor<T>(libraryBook, existingTemplate); - var form = new EditTemplateDialog(template); - if (form.ShowDialog(this) == DialogResult.OK) - { - setNewTemplate(template.EditingTemplate.TemplateText); - } - } - - if (entries.Length == 1 && entries[0] is LibraryBookEntry entry2) - { - var folderTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FolderTemplateText }; - var fileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FileTemplateText }; - var multiFileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.MultipartTemplateText }; - folderTemplateMenuItem.Click += (s, _) => editTemplate<Templates.FolderTemplate>(entry2.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t); - fileTemplateMenuItem.Click += (s, _) => editTemplate<Templates.FileTemplate>(entry2.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t); - multiFileTemplateMenuItem.Click += (s, _) => editTemplate<Templates.ChapterFileTemplate>(entry2.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t); - - var editTemplatesMenuItem = new ToolStripMenuItem { Text = ctx.EditTemplatesText }; - editTemplatesMenuItem.DropDownItems.AddRange(new[] { folderTemplateMenuItem, fileTemplateMenuItem, multiFileTemplateMenuItem }); - - ctxMenu.Items.Add(editTemplatesMenuItem); - ctxMenu.Items.Add(new ToolStripSeparator()); - } - - #endregion - #region View Bookmarks/Clips (Single book only) - - if (entries.Length == 1 && entries[0] is LibraryBookEntry entry3) - { - var bookRecordMenuItem = new ToolStripMenuItem { Text = ctx.ViewBookmarksText }; - bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry3.LibraryBook).ShowDialog(this); - ctxMenu.Items.Add(bookRecordMenuItem); - } - - #endregion - #region View All Series (Single book only) - - if (entries.Length == 1 && entries[0].Book.SeriesLink.Any()) - { - var viewSeriesMenuItem = new ToolStripMenuItem { Text = ctx.ViewSeriesText }; - viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entries[0].LibraryBook).Show(); - ctxMenu.Items.Add(viewSeriesMenuItem); - } - - #endregion + bookDetailsForm = new(); + bookDetailsForm.RestoreSizeAndLocation(Configuration.Instance); + bookDetailsForm.FormClosed += bookDetailsForm_FormClosed; } - #endregion + bookDetailsForm.LibraryBook = liveGridEntry.LibraryBook; + if (!bookDetailsForm.Visible) + bookDetailsForm.Show(this); - #region Scan and Remove Books - - public void CloseRemoveBooksColumn() - => productsGrid.RemoveColumnVisible = false; - - public async Task RemoveCheckedBooksAsync() + async void bookDetailsForm_FormClosed(object? sender, FormClosedEventArgs e) { - var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is true).ToList(); - - if (selectedBooks.Count == 0) - return; - - var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList(); - var result = MessageBoxLib.ShowConfirmationDialog( - booksToRemove, - // do not use `$` string interpolation. See impl. - "Are you sure you want to remove {0} from Libation's library?", - "Remove books from Libation?"); - - if (result != DialogResult.Yes) - return; - - productsGrid.RemoveBooks(selectedBooks); - await booksToRemove.RemoveBooksAsync(); - } - - public async Task ScanAndRemoveBooksAsync(params Account[] accounts) - { - RemovableCountChanged?.Invoke(this, 0); - productsGrid.RemoveColumnVisible = true; - - try - { - if (accounts is null || accounts.Length == 0) - return; - - var allBooks = productsGrid.GetAllBookEntries(); - var lib = allBooks - .Select(lbe => lbe.LibraryBook) - .Where(lb => !lb.Book.HasLiberated()); - - var removedBooks = await LibraryCommands.FindInactiveBooks(lib, accounts); - - var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); - - foreach (var r in removable) - r.Remove = true; - - productsGrid_RemovableCountChanged(this, EventArgs.Empty); - } - catch (OperationCanceledException) - { - Serilog.Log.Information("Audible login attempt cancelled by user"); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - this, - "Error scanning library. You may still manually select books to remove from Libation's library.", - "Error scanning library", - ex); - } - } - - #endregion - - #region UI display functions - - public async Task DisplayAsync(List<LibraryBook>? libraryBooks = null) - { - try - { - // don't return early if lib size == 0. this will not update correctly if all books are removed - libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(includeParents: true); - - if (!hasBeenDisplayed) - { - // bind - await productsGrid.BindToGridAsync(libraryBooks); - hasBeenDisplayed = true; - InitialLoaded?.Invoke(this, new()); - } - else - productsGrid.UpdateGrid(libraryBooks); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay)); - } - } - - #endregion - - #region Filter - - public void Filter(string? searchString) - => productsGrid.Filter(searchString); - - #endregion - - internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBookEntries().ToList(); - - private void productsGrid_VisibleCountChanged(object sender, int count) - { - VisibleCountChanged?.Invoke(this, count); - } - - private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry) - { - if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error - && liveGridEntry.Liberate?.IsUnavailable is false) - LiberateClicked?.Invoke(this, [liveGridEntry.LibraryBook], Configuration.Instance); - } - - private void productsGrid_RemovableCountChanged(object sender, EventArgs e) - { - RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true)); + bookDetailsForm.FormClosed -= bookDetailsForm_FormClosed; + bookDetailsForm.SaveSizeAndLocation(Configuration.Instance); } } + + #endregion + + #region Cell Context Menu + + private void productsGrid_CellContextMenuStripNeeded(GridEntry[] entries, ContextMenuStrip ctxMenu) + { + var ctx = new GridContextMenu(entries, '&'); + #region Liberate all Episodes (Single series only) + + if (entries.Length == 1 && entries[0] is SeriesEntry seriesEntry) + { + var liberateEpisodesMenuItem = new ToolStripMenuItem() + { + Text = ctx.LiberateEpisodesText, + Enabled = ctx.LiberateEpisodesEnabled + }; + + liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, seriesEntry); + ctxMenu.Items.Add(liberateEpisodesMenuItem); + } + + #endregion + #region Set Download status to Downloaded + + var setDownloadMenuItem = new ToolStripMenuItem() + { + Text = ctx.SetDownloadedText, + Enabled = ctx.SetDownloadedEnabled + }; + setDownloadMenuItem.Click += (_, _) => ctx.SetDownloaded(); + ctxMenu.Items.Add(setDownloadMenuItem); + + #endregion + #region Set Download status to Not Downloaded + + var setNotDownloadMenuItem = new ToolStripMenuItem() + { + Text = ctx.SetNotDownloadedText, + Enabled = ctx.SetNotDownloadedEnabled + }; + setNotDownloadMenuItem.Click += (_, _) => ctx.SetNotDownloaded(); + ctxMenu.Items.Add(setNotDownloadMenuItem); + + #endregion + #region Locate file (Single book only) + + if (entries.Length == 1 && entries[0] is LibraryBookEntry entry) + { + var locateFileMenuItem = new ToolStripMenuItem() { Text = ctx.LocateFileText }; + ctxMenu.Items.Add(locateFileMenuItem); + locateFileMenuItem.Click += (_, _) => + { + try + { + var openFileDialog = new OpenFileDialog + { + Title = ctx.LocateFileDialogTitle, + Filter = "All files (*.*)|*.*", + FilterIndex = 1 + }; + if (openFileDialog.ShowDialog() == DialogResult.OK) + FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert(this, ctx.LocateFileErrorMessage, ctx.LocateFileErrorMessage, ex); + } + }; + } + + #endregion + #region Remove from library + + var removeMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveText }; + removeMenuItem.Click += async (_, _) => await ctx.RemoveAsync(); + ctxMenu.Items.Add(removeMenuItem); + + #endregion + #region Liberate All (multiple books only) + if (entries.OfType<LibraryBookEntry>().Count() > 1) + { + var downloadSelectedMenuItem = new ToolStripMenuItem() + { + Text = ctx.DownloadSelectedText + }; + ctxMenu.Items.Add(downloadSelectedMenuItem); + downloadSelectedMenuItem.Click += (s, _) => + { + LiberateClicked?.Invoke(s, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray(), Configuration.Instance); + }; + } + + #endregion + #region Download split by chapters + + if (ctx.LibraryBookEntries.Length > 0) + { + var downloadChaptersMenuItem = new ToolStripMenuItem + { + Text = ctx.DownloadAsChapters, + Enabled = ctx.DownloadAsChaptersEnabled + }; + downloadChaptersMenuItem.Click += (_, e) => + { + var config = Configuration.Instance.CreateEphemeralCopy(); + config.AllowLibationFixup = config.SplitFilesByChapter = true; + var books = ctx.LibraryBookEntries.Select(e => e.LibraryBook).Where(lb => lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error).ToList(); + books.ForEach(b => b.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated); + LiberateClicked?.Invoke(this, books, config); + }; + ctxMenu.Items.Add(downloadChaptersMenuItem); + } + + #endregion + #region Convert to Mp3 + + if (ctx.LibraryBookEntries.Length > 0) + { + var convertToMp3MenuItem = new ToolStripMenuItem + { + Text = ctx.ConvertToMp3Text, + Enabled = ctx.ConvertToMp3Enabled + }; + convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(this, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray()); + ctxMenu.Items.Add(convertToMp3MenuItem); + } + + #endregion + #region Force Re-Download (Single book only) + + if (entries.Length == 1 && entries[0] is LibraryBookEntry entry4) + { + var reDownloadMenuItem = new ToolStripMenuItem() + { + Text = ctx.ReDownloadText, + Enabled = ctx.ReDownloadEnabled + }; + ctxMenu.Items.Add(reDownloadMenuItem); + reDownloadMenuItem.Click += (s, _) => + { + //No need to persist these changes. They only needs to last long for the files to start downloading + entry4.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; + if (entry4.Book.HasPdf) + entry4.Book.UserDefinedItem.SetPdfStatus(LiberatedStatus.NotLiberated); + + LiberateClicked?.Invoke(s, [entry4.LibraryBook], Configuration.Instance); + }; + } + + #endregion + #region Remove Audible Plus Books from Audible Library + + if (entries.Length != 1 || ctx.RemoveFromAudibleEnabled) + { + ctxMenu.Items.Add(new ToolStripSeparator()); + var removeFromAudibleMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveFromAudibleText, Enabled = ctx.RemoveFromAudibleEnabled }; + removeFromAudibleMenuItem.Click += async (_, _) => await ctx.RemoveFromAudibleAsync(); + ctxMenu.Items.Add(removeFromAudibleMenuItem); + } + + #endregion + if (entries.Length > 1) + return; + + ctxMenu.Items.Add(new ToolStripSeparator()); + + #region Edit Templates (Single book only) + + void editTemplate<T>(LibraryBook libraryBook, string existingTemplate, Action<string> setNewTemplate) + where T : Templates, ITemplate, new() + { + var template = ctx.CreateTemplateEditor<T>(libraryBook, existingTemplate); + var form = new EditTemplateDialog(template); + if (form.ShowDialog(this) == DialogResult.OK) + { + setNewTemplate(template.EditingTemplate.TemplateText); + } + } + + if (entries.Length == 1 && entries[0] is LibraryBookEntry entry2) + { + var folderTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FolderTemplateText }; + var fileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FileTemplateText }; + var multiFileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.MultipartTemplateText }; + folderTemplateMenuItem.Click += (s, _) => editTemplate<Templates.FolderTemplate>(entry2.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t); + fileTemplateMenuItem.Click += (s, _) => editTemplate<Templates.FileTemplate>(entry2.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t); + multiFileTemplateMenuItem.Click += (s, _) => editTemplate<Templates.ChapterFileTemplate>(entry2.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t); + + var editTemplatesMenuItem = new ToolStripMenuItem { Text = ctx.EditTemplatesText }; + editTemplatesMenuItem.DropDownItems.AddRange(new[] { folderTemplateMenuItem, fileTemplateMenuItem, multiFileTemplateMenuItem }); + + ctxMenu.Items.Add(editTemplatesMenuItem); + ctxMenu.Items.Add(new ToolStripSeparator()); + } + + #endregion + #region View Bookmarks/Clips (Single book only) + + if (entries.Length == 1 && entries[0] is LibraryBookEntry entry3) + { + var bookRecordMenuItem = new ToolStripMenuItem { Text = ctx.ViewBookmarksText }; + bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry3.LibraryBook).ShowDialog(this); + ctxMenu.Items.Add(bookRecordMenuItem); + } + + #endregion + #region View All Series (Single book only) + + if (entries.Length == 1 && entries[0].Book.SeriesLink.Any()) + { + var viewSeriesMenuItem = new ToolStripMenuItem { Text = ctx.ViewSeriesText }; + viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entries[0].LibraryBook).Show(); + ctxMenu.Items.Add(viewSeriesMenuItem); + } + + #endregion + } + + #endregion + + #region Scan and Remove Books + + public void CloseRemoveBooksColumn() + => productsGrid.RemoveColumnVisible = false; + + public async Task RemoveCheckedBooksAsync() + { + var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is true).ToList(); + + if (selectedBooks.Count == 0) + return; + + var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var result = MessageBoxLib.ShowConfirmationDialog( + booksToRemove, + // do not use `$` string interpolation. See impl. + "Are you sure you want to remove {0} from Libation's library?", + "Remove books from Libation?"); + + if (result != DialogResult.Yes) + return; + + productsGrid.RemoveBooks(selectedBooks); + await booksToRemove.RemoveBooksAsync(); + } + + public async Task ScanAndRemoveBooksAsync(params Account[] accounts) + { + RemovableCountChanged?.Invoke(this, 0); + productsGrid.RemoveColumnVisible = true; + + try + { + if (accounts is null || accounts.Length == 0) + return; + + var allBooks = productsGrid.GetAllBookEntries(); + var lib = allBooks + .Select(lbe => lbe.LibraryBook) + .Where(lb => !lb.Book.HasLiberated()); + + var removedBooks = await LibraryCommands.FindInactiveBooks(lib, accounts); + + var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); + + foreach (var r in removable) + r.Remove = true; + + productsGrid_RemovableCountChanged(this, EventArgs.Empty); + } + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + this, + "Error scanning library. You may still manually select books to remove from Libation's library.", + "Error scanning library", + ex); + } + } + + #endregion + + #region UI display functions + + public async Task DisplayAsync(List<LibraryBook>? libraryBooks = null) + { + try + { + // don't return early if lib size == 0. this will not update correctly if all books are removed + libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(includeParents: true); + + if (!hasBeenDisplayed) + { + // bind + await productsGrid.BindToGridAsync(libraryBooks); + hasBeenDisplayed = true; + InitialLoaded?.Invoke(this, new()); + } + else + productsGrid.UpdateGrid(libraryBooks); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay)); + } + } + + #endregion + + #region Filter + + public void Filter(string? searchString) + => productsGrid.Filter(searchString); + + #endregion + + internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBookEntries().ToList(); + + private void productsGrid_VisibleCountChanged(object sender, int count) + { + VisibleCountChanged?.Invoke(this, count); + } + + private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry) + { + if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error + && liveGridEntry.Liberate?.IsUnavailable is false) + LiberateClicked?.Invoke(this, [liveGridEntry.LibraryBook], Configuration.Instance); + } + + private void productsGrid_RemovableCountChanged(object sender, EventArgs e) + { + RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true)); + } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 04d728f4..d9e52c98 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -13,642 +13,640 @@ using System.Drawing; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry); +public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry); +public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle); +public delegate void ProductsGridCellContextMenuStripNeededEventHandler(GridEntry[] liveGridEntry, ContextMenuStrip ctxMenu); + +public partial class ProductsGrid : UserControl { - public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry); - public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry); - public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle); - public delegate void ProductsGridCellContextMenuStripNeededEventHandler(GridEntry[] liveGridEntry, ContextMenuStrip ctxMenu); - - public partial class ProductsGrid : UserControl + [DefaultValue(false)] + [Category("Behavior")] + [Description("Disable the grid context menu")] + public bool DisableContextMenu { get; set; } + [DefaultValue(false)] + [Category("Behavior")] + [Description("Disable grid column reordering and don't persist width changes")] + public bool DisableColumnCustomization { - [DefaultValue(false)] - [Category("Behavior")] - [Description("Disable the grid context menu")] - public bool DisableContextMenu { get; set; } - [DefaultValue(false)] - [Category("Behavior")] - [Description("Disable grid column reordering and don't persist width changes")] - public bool DisableColumnCustomization + get => field; + set { - get => field; - set + field = value; + gridEntryDataGridView.AllowUserToOrderColumns = !value; + } + } + + /// <summary>Number of visible rows has changed</summary> + public event EventHandler<int>? VisibleCountChanged; + public event LibraryBookEntryClickedEventHandler? LiberateClicked; + public event GridEntryClickedEventHandler? CoverClicked; + public event LibraryBookEntryClickedEventHandler? DetailsClicked; + public event GridEntryRectangleClickedEventHandler? DescriptionClicked; + public new event EventHandler<ScrollEventArgs>? Scroll; + public event EventHandler? RemovableCountChanged; + public event ProductsGridCellContextMenuStripNeededEventHandler? LiberateContextMenuStripNeeded; + + private GridEntryBindingList? bindingList; + internal IEnumerable<LibraryBook> GetVisibleBookEntries() + => GetVisibleGridEntries().Select(lbe => lbe.LibraryBook); + + public IEnumerable<LibraryBookEntry> GetVisibleGridEntries() + => bindingList?.GetFilteredInItems().OfType<LibraryBookEntry>() ?? []; + + internal IEnumerable<LibraryBookEntry> GetAllBookEntries() + => bindingList?.AllItems().BookEntries() ?? Enumerable.Empty<LibraryBookEntry>(); + + public ISearchEngine? SearchEngine { get => field; set { field = value; bindingList?.SearchEngine = value; } } + + public ProductsGrid() + { + InitializeComponent(); + EnableDoubleBuffering(); + gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); + gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded; + removeGVColumn.Frozen = false; + defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font; + } + + #region Scaling + + [PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))] + private void Configuration_FontScaleChanged(object sender, PropertyChangedEventArgsEx e) + { + if (e.NewValue is float v) + setGridFontScale(v); + } + + [PropertyChangeFilter(nameof(Configuration.GridScaleFactor))] + private void Configuration_ScaleChanged(object sender, PropertyChangedEventArgsEx e) + { + if (e.NewValue is float v) + setGridScale(v); + } + + /// <summary> + /// Keep track of the original dimensions for rescaling + /// </summary> + private static readonly Dictionary<DataGridViewElement, int> originalDims = new(); + private readonly Font defaultFont; + private void setGridScale(float scale) + { + foreach (var col in gridEntryDataGridView.Columns.Cast<DataGridViewColumn>()) + { + //Only resize fixed-width columns. The rest can be adjusted by users. + if (col.Resizable is DataGridViewTriState.False) { - field = value; - gridEntryDataGridView.AllowUserToOrderColumns = !value; - } - } + if (!originalDims.ContainsKey(col)) + originalDims[col] = col.Width; - /// <summary>Number of visible rows has changed</summary> - public event EventHandler<int>? VisibleCountChanged; - public event LibraryBookEntryClickedEventHandler? LiberateClicked; - public event GridEntryClickedEventHandler? CoverClicked; - public event LibraryBookEntryClickedEventHandler? DetailsClicked; - public event GridEntryRectangleClickedEventHandler? DescriptionClicked; - public new event EventHandler<ScrollEventArgs>? Scroll; - public event EventHandler? RemovableCountChanged; - public event ProductsGridCellContextMenuStripNeededEventHandler? LiberateContextMenuStripNeeded; - - private GridEntryBindingList? bindingList; - internal IEnumerable<LibraryBook> GetVisibleBookEntries() - => GetVisibleGridEntries().Select(lbe => lbe.LibraryBook); - - public IEnumerable<LibraryBookEntry> GetVisibleGridEntries() - => bindingList?.GetFilteredInItems().OfType<LibraryBookEntry>() ?? []; - - internal IEnumerable<LibraryBookEntry> GetAllBookEntries() - => bindingList?.AllItems().BookEntries() ?? Enumerable.Empty<LibraryBookEntry>(); - - public ISearchEngine? SearchEngine { get => field; set { field = value; bindingList?.SearchEngine = value; } } - - public ProductsGrid() - { - InitializeComponent(); - EnableDoubleBuffering(); - gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); - gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded; - removeGVColumn.Frozen = false; - defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font; - } - - #region Scaling - - [PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))] - private void Configuration_FontScaleChanged(object sender, PropertyChangedEventArgsEx e) - { - if (e.NewValue is float v) - setGridFontScale(v); - } - - [PropertyChangeFilter(nameof(Configuration.GridScaleFactor))] - private void Configuration_ScaleChanged(object sender, PropertyChangedEventArgsEx e) - { - if (e.NewValue is float v) - setGridScale(v); - } - - /// <summary> - /// Keep track of the original dimensions for rescaling - /// </summary> - private static readonly Dictionary<DataGridViewElement, int> originalDims = new(); - private readonly Font defaultFont; - private void setGridScale(float scale) - { - foreach (var col in gridEntryDataGridView.Columns.Cast<DataGridViewColumn>()) - { - //Only resize fixed-width columns. The rest can be adjusted by users. - if (col.Resizable is DataGridViewTriState.False) - { - if (!originalDims.ContainsKey(col)) - originalDims[col] = col.Width; - - col.Width = this.DpiScale(originalDims[col], scale); - } - - if (col is IDataGridScaleColumn scCol) - scCol.ScaleFactor = scale; + col.Width = this.DpiScale(originalDims[col], scale); } - if (!originalDims.ContainsKey(gridEntryDataGridView.RowTemplate)) - originalDims[gridEntryDataGridView.RowTemplate] = gridEntryDataGridView.RowTemplate.Height; - - var height = gridEntryDataGridView.RowTemplate.Height = this.DpiScale(originalDims[gridEntryDataGridView.RowTemplate], scale); - - foreach (var row in gridEntryDataGridView.Rows.Cast<DataGridViewRow>()) - row.Height = height; + if (col is IDataGridScaleColumn scCol) + scCol.ScaleFactor = scale; } - private void setGridFontScale(float scale) - => gridEntryDataGridView.DefaultCellStyle.Font = new Font(defaultFont.FontFamily, defaultFont.Size * scale); + if (!originalDims.ContainsKey(gridEntryDataGridView.RowTemplate)) + originalDims[gridEntryDataGridView.RowTemplate] = gridEntryDataGridView.RowTemplate.Height; - #endregion + var height = gridEntryDataGridView.RowTemplate.Height = this.DpiScale(originalDims[gridEntryDataGridView.RowTemplate], scale); - private static string? RemoveLineBreaks(string? text) - => text?.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' '); + foreach (var row in gridEntryDataGridView.Rows.Cast<DataGridViewRow>()) + row.Height = height; + } - private void GridEntryDataGridView_CellContextMenuStripNeeded(object? sender, DataGridViewCellContextMenuStripNeededEventArgs e) + private void setGridFontScale(float scale) + => gridEntryDataGridView.DefaultCellStyle.Font = new Font(defaultFont.FontFamily, defaultFont.Size * scale); + + #endregion + + private static string? RemoveLineBreaks(string? text) + => text?.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' '); + + private void GridEntryDataGridView_CellContextMenuStripNeeded(object? sender, DataGridViewCellContextMenuStripNeededEventArgs e) + { + // header + if (DisableContextMenu || e.RowIndex < 0 || sender is not DataGridView dgv) + return; + + e.ContextMenuStrip = new ContextMenuStrip(); + // any column except cover & stop light + if (e.ColumnIndex != liberateGVColumn.Index && e.ColumnIndex != coverGVColumn.Index) { - // header - if (DisableContextMenu || e.RowIndex < 0 || sender is not DataGridView dgv) - return; - - e.ContextMenuStrip = new ContextMenuStrip(); - // any column except cover & stop light - if (e.ColumnIndex != liberateGVColumn.Index && e.ColumnIndex != coverGVColumn.Index) + e.ContextMenuStrip.Items.Add("Copy Cell Contents", null, (_, __) => { - e.ContextMenuStrip.Items.Add("Copy Cell Contents", null, (_, __) => + try { - try + string clipboardText; + + if (dgv.SelectedCells.Count <= 1) { - string clipboardText; + //Copy contents only of cell that was right-clicked on. + clipboardText = dgv[e.ColumnIndex, e.RowIndex].FormattedValue?.ToString() ?? string.Empty; + } + else + { + //Copy contents of selected cells. Each row is a new line, + //and columns are separated with tabs. Similar formatting to Microsoft Excel. + var selectedCells + = dgv.SelectedCells + .OfType<DataGridViewCell>() + .Where(c => c.OwningColumn is not null && c.OwningRow is not null) + .OrderBy(c => c.RowIndex) + .ThenBy(c => c.OwningColumn!.DisplayIndex) + .ToList(); - if (dgv.SelectedCells.Count <= 1) - { - //Copy contents only of cell that was right-clicked on. - clipboardText = dgv[e.ColumnIndex, e.RowIndex].FormattedValue?.ToString() ?? string.Empty; - } - else - { - //Copy contents of selected cells. Each row is a new line, - //and columns are separated with tabs. Similar formatting to Microsoft Excel. - var selectedCells - = dgv.SelectedCells - .OfType<DataGridViewCell>() - .Where(c => c.OwningColumn is not null && c.OwningRow is not null) - .OrderBy(c => c.RowIndex) - .ThenBy(c => c.OwningColumn!.DisplayIndex) - .ToList(); + var headerText + = string.Join("\t", + selectedCells + .Select(c => c.OwningColumn) + .Distinct() + .Select(c => RemoveLineBreaks(c?.HeaderText)) + .OfType<string>()); - var headerText - = string.Join("\t", + List<string> linesOfText = [headerText]; + foreach (var distinctRow in selectedCells.Select(c => c.RowIndex).Distinct()) + { + linesOfText.Add(string.Join("\t", selectedCells - .Select(c => c.OwningColumn) - .Distinct() - .Select(c => RemoveLineBreaks(c?.HeaderText)) - .OfType<string>()); - - List<string> linesOfText = [headerText]; - foreach (var distinctRow in selectedCells.Select(c => c.RowIndex).Distinct()) - { - linesOfText.Add(string.Join("\t", - selectedCells - .Where(c => c.RowIndex == distinctRow) - .Select(c => RemoveLineBreaks(c.FormattedValue?.ToString()) ?? string.Empty) - )); - } - clipboardText = string.Join(Environment.NewLine, linesOfText); + .Where(c => c.RowIndex == distinctRow) + .Select(c => RemoveLineBreaks(c.FormattedValue?.ToString()) ?? string.Empty) + )); } - Clipboard.SetDataObject(clipboardText, false, 5, 150); + clipboardText = string.Join(Environment.NewLine, linesOfText); } - catch(Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error copying text to clipboard"); - } - }); - e.ContextMenuStrip.Items.Add(new ToolStripSeparator()); - } - - var clickedEntry = getGridEntry(e.RowIndex); - - var allSelected - = gridEntryDataGridView - .SelectedCells - .OfType<DataGridViewCell>() - .Select(c => c.OwningRow) - .OfType<DataGridViewRow>() - .Distinct() - .OrderBy(r => r.Index) - .Select(r => r.DataBoundItem) - .OfType<GridEntry>() - .ToArray(); - - var clickedIndex = Array.IndexOf(allSelected, clickedEntry); - if (clickedIndex == -1) - { - //User didn't right-click on a selected cell - gridEntryDataGridView.ClearSelection(); - gridEntryDataGridView[e.ColumnIndex, e.RowIndex].Selected = true; - allSelected = [clickedEntry]; - } - else if (clickedIndex > 0) - { - //Ensure the clicked entry is first in the list - (allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]); - } - LiberateContextMenuStripNeeded?.Invoke(allSelected, e.ContextMenuStrip); - } - - private void EnableDoubleBuffering() - { - var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - - propertyInfo?.SetValue(gridEntryDataGridView, true, null); - } - - #region Button controls - private void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e) - { - try - { - // handle grid button click: https://stackoverflow.com/a/13687844 - if (e.RowIndex < 0) - return; - - var entry = getGridEntry(e.RowIndex); - if (entry is LibraryBookEntry lbEntry) - { - if (e.ColumnIndex == liberateGVColumn.Index) - LiberateClicked?.Invoke(lbEntry); - else if (e.ColumnIndex == tagAndDetailsGVColumn.Index) - DetailsClicked?.Invoke(lbEntry); - else if (e.ColumnIndex == descriptionGVColumn.Index) - DescriptionClicked?.Invoke(lbEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); - else if (e.ColumnIndex == coverGVColumn.Index) - CoverClicked?.Invoke(lbEntry); + Clipboard.SetDataObject(clipboardText, false, 5, 150); } - else if (entry is SeriesEntry sEntry) + catch(Exception ex) { - if (e.ColumnIndex == liberateGVColumn.Index && sEntry.Liberate is not null) - { - if (sEntry.Liberate.Expanded) - bindingList?.CollapseItem(sEntry); - else - bindingList?.ExpandItem(sEntry); - - VisibleCountChanged?.Invoke(this, bindingList?.GetFilteredInItems().Count() ?? 0); - } - else if (e.ColumnIndex == descriptionGVColumn.Index) - DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); - else if (e.ColumnIndex == coverGVColumn.Index) - CoverClicked?.Invoke(sEntry); + Serilog.Log.Logger.Error(ex, "Error copying text to clipboard"); } - - if (e.ColumnIndex == removeGVColumn.Index) - { - gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit); - RemovableCountChanged?.Invoke(this, EventArgs.Empty); - } - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"An error was encountered while processing a user click in the {nameof(ProductsGrid)}"); - } + }); + e.ContextMenuStrip.Items.Add(new ToolStripSeparator()); } - private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex); + var clickedEntry = getGridEntry(e.RowIndex); - #endregion + var allSelected + = gridEntryDataGridView + .SelectedCells + .OfType<DataGridViewCell>() + .Select(c => c.OwningRow) + .OfType<DataGridViewRow>() + .Distinct() + .OrderBy(r => r.Index) + .Select(r => r.DataBoundItem) + .OfType<GridEntry>() + .ToArray(); - #region UI display functions - - internal bool RemoveColumnVisible + var clickedIndex = Array.IndexOf(allSelected, clickedEntry); + if (clickedIndex == -1) { - get => removeGVColumn.Visible; - set - { - if (value && bindingList is not null) - { - foreach (var book in bindingList.AllItems()) - book.Remove = false; - } - - removeGVColumn.DisplayIndex = 0; - removeGVColumn.Frozen = value; - removeGVColumn.Visible = value; - } + //User didn't right-click on a selected cell + gridEntryDataGridView.ClearSelection(); + gridEntryDataGridView[e.ColumnIndex, e.RowIndex].Selected = true; + allSelected = [clickedEntry]; } - - internal async Task BindToGridAsync(List<LibraryBook> dbBooks) + else if (clickedIndex > 0) { - //Get the UI thread's synchronization context and set it on the current thread to ensure - //it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync - var sc = Invoke(() => System.Threading.SynchronizationContext.Current); - System.Threading.SynchronizationContext.SetSynchronizationContext(sc); - - var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks); - var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks); - - geList.AddRange(seriesEntries); - //Sort descending by date (default sort property) - var comparer = new RowComparer(); - geList.Sort((a, b) => comparer.Compare(b, a)); - - //Add all children beneath their parent - foreach (var series in seriesEntries) - { - var seriesIndex = geList.IndexOf(series); - foreach (var child in series.Children) - geList.Insert(++seriesIndex, child); - } - System.Threading.SynchronizationContext.SetSynchronizationContext(null); - - bindingList = new GridEntryBindingList(geList) { SearchEngine = SearchEngine }; - bindingList.CollapseAll(); - - //The syncBindingSource ensures that the IGridEntry list is added on the UI thread - syncBindingSource.DataSource = bindingList; - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + //Ensure the clicked entry is first in the list + (allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]); } + LiberateContextMenuStripNeeded?.Invoke(allSelected, e.ContextMenuStrip); + } - internal void UpdateGrid(List<LibraryBook> dbBooks) + private void EnableDoubleBuffering() + { + var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + + propertyInfo?.SetValue(gridEntryDataGridView, true, null); + } + + #region Button controls + private void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e) + { + try { - if (bindingList == null) - throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(UpdateGrid)}"); - - //First row that is in view in the DataGridView - var topRow = gridEntryDataGridView.Rows.Cast<DataGridViewRow>().FirstOrDefault(r => r.Displayed)?.Index ?? 0; - - #region Add new or update existing grid entries - - //Remove filter prior to adding/updating books - string? existingFilter = syncBindingSource.Filter; - Filter(null); - - //Add absent entries to grid, or update existing entry - - var allEntries = bindingList.AllItems().BookEntries().ToDictionarySafe(b => b.AudibleProductId); - var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); - var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); - - //Get the UI thread's synchronization context and set it on the current thread to ensure - //it's available for creation of new IGridEntry items during upsert - var sc = Invoke(() => System.Threading.SynchronizationContext.Current); - System.Threading.SynchronizationContext.SetSynchronizationContext(sc); - - bindingList.RaiseListChangedEvents = false; - foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) - { - var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null; - - if (libraryBook.Book.IsProduct()) - { - AddOrUpdateBook(libraryBook, existingEntry); - continue; - } - if (parentedEpisodes.Contains(libraryBook)) - { - //Only try to add or update is this LibraryBook is a know child of a parent - AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); - } - } - bindingList.RaiseListChangedEvents = true; - - //Re-apply filter after adding new/updating existing books to capture any changes - //The Filter call also ensures that the binding list is reset so the DataGridView - //is made aware of all changes that were made while RaiseListChangedEvents was false - Filter(existingFilter); - - #endregion - - // remove deleted from grid. - // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this - var removedBooks = - bindingList - .AllItems() - .BookEntries() - .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); - - RemoveBooks(removedBooks); - - if (topRow >= 0 && topRow < gridEntryDataGridView.RowCount) - gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; - } - - public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks) - { - if (bindingList == null) - throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(RemoveBooks)}"); - - //Remove books in series from their parents' Children list - foreach (var removed in removedBooks.Where(b => b.Liberate?.IsEpisode is true)) - removed.Parent?.RemoveChild(removed); - - //Remove series that have no children - var removedSeries = - bindingList - .AllItems() - .EmptySeries(); - - foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries)) - //no need to re-filter for removed books - bindingList.Remove(removed); - - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); - } - - private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry? existingBookEntry) - { - if (bindingList == null) - throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateBook)}"); - - if (existingBookEntry is null) - // Add the new product to top - bindingList.Insert(0, new LibraryBookEntry(book)); - else - // update existing - existingBookEntry.UpdateLibraryBook(book); - } - - private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry? existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks) - { - if (bindingList == null) - throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateEpisode)}"); - - if (existingEpisodeEntry is null) - { - LibraryBookEntry episodeEntry; - - var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); - - if (seriesEntry is null) - { - //Series doesn't exist yet, so create and add it - var seriesBook = dbBooks.FindSeriesParent(episodeBook); - - if (seriesBook is null) - { - //This is only possible if the user's db has some malformed - //entries from earlier Libation releases that could not be - //automatically fixed. Log, but don't throw. - Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames()); - return; - } - - seriesEntry = new SeriesEntry(seriesBook, episodeBook); - seriesEntries.Add(seriesEntry); - - episodeEntry = seriesEntry.Children[0]; - seriesEntry.Liberate?.Expanded = true; - bindingList.Insert(0, seriesEntry); - } - else - { - //Series exists. Create and add episode child then update the SeriesEntry - 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); - seriesEntry.UpdateLibraryBook(seriesBook); - } - - //Series entry must be expanded so its child can - //be placed in the correct position beneath it. - var isExpanded = seriesEntry.Liberate?.Expanded; - bindingList.ExpandItem(seriesEntry); - - //Add episode to the grid beneath the parent - int seriesIndex = bindingList.IndexOf(seriesEntry); - int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); - bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); - - if (isExpanded.HasValue && isExpanded.Value) - bindingList.ExpandItem(seriesEntry); - else if (isExpanded.HasValue) - bindingList.CollapseItem(seriesEntry); - } - else - existingEpisodeEntry.UpdateLibraryBook(episodeBook); - } - - #endregion - - #region Filter - - public void Filter(string? searchString) - { - if (bindingList is null) return; - - int visibleCount = bindingList.Count; - - if (string.IsNullOrEmpty(searchString)) - syncBindingSource.RemoveFilter(); - else - syncBindingSource.Filter = searchString; - - if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); - } - - #endregion - - #region Column Customizations - - private void ProductsGrid_Load(object sender, EventArgs e) - { - //DesignMode is not set in constructor - if (DesignMode) + // handle grid button click: https://stackoverflow.com/a/13687844 + if (e.RowIndex < 0) return; - setGridFontScale(Configuration.Instance.GridFontScaleFactor); - setGridScale(Configuration.Instance.GridScaleFactor); - Configuration.Instance.PropertyChanged += Configuration_ScaleChanged; - Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged; - gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - - gridEntryDataGridView.Disposed += (_, _) => + var entry = getGridEntry(e.RowIndex); + if (entry is LibraryBookEntry lbEntry) { - Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged; - Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged; - }; - - gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged; - gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged; - - showHideColumnsContextMenuStrip.Items.Add(new ToolStripLabel("Show / Hide Columns")); - showHideColumnsContextMenuStrip.Items.Add(new ToolStripSeparator()); - - //Restore Grid Display Settings - var config = Configuration.Instance; - var gridColumnsWidths = config.GridColumnsWidths; - var displayIndices = config.GridColumnsDisplayIndices; - - var cmsKiller = new ContextMenuStrip(); - - foreach (DataGridViewColumn column in gridEntryDataGridView.Columns) + if (e.ColumnIndex == liberateGVColumn.Index) + LiberateClicked?.Invoke(lbEntry); + else if (e.ColumnIndex == tagAndDetailsGVColumn.Index) + DetailsClicked?.Invoke(lbEntry); + else if (e.ColumnIndex == descriptionGVColumn.Index) + DescriptionClicked?.Invoke(lbEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); + else if (e.ColumnIndex == coverGVColumn.Index) + CoverClicked?.Invoke(lbEntry); + } + else if (entry is SeriesEntry sEntry) { - if (column == removeGVColumn) - continue; - var itemName = column.DataPropertyName; - var visible = config.GetColumnVisibility(itemName); - - var menuItem = new ToolStripMenuItem(column.HeaderText) + if (e.ColumnIndex == liberateGVColumn.Index && sEntry.Liberate is not null) { - Checked = visible, - Tag = itemName - }; - menuItem.Click += HideMenuItem_Click; - showHideColumnsContextMenuStrip.Items.Add(menuItem); + if (sEntry.Liberate.Expanded) + bindingList?.CollapseItem(sEntry); + else + bindingList?.ExpandItem(sEntry); - //Only set column widths for user resizable columns. - //Fixed column widths are set by setGridScale() - if (column.Resizable is not DataGridViewTriState.False) - column.Width = gridColumnsWidths.GetValueOrDefault(itemName, this.DpiScale(column.Width)); - - column.MinimumWidth = 10; - column.HeaderCell.ContextMenuStrip = showHideColumnsContextMenuStrip; - column.Visible = visible; - - //Setting a default ContextMenuStrip will allow the columns to handle the - //Show() event so it is not passed up to the _dataGridView.ContextMenuStrip. - //This allows the ContextMenuStrip to be shown if right-clicking in the gray - //background of _dataGridView but not shown if right-clicking inside cells. - column.ContextMenuStrip = cmsKiller; + VisibleCountChanged?.Invoke(this, bindingList?.GetFilteredInItems().Count() ?? 0); + } + else if (e.ColumnIndex == descriptionGVColumn.Index) + DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); + else if (e.ColumnIndex == coverGVColumn.Index) + CoverClicked?.Invoke(sEntry); } - //We must set DisplayIndex properties in ascending order - foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key)) + if (e.ColumnIndex == removeGVColumn.Index) { - var column = gridEntryDataGridView.Columns - .Cast<DataGridViewColumn>() - .SingleOrDefault(c => c.DataPropertyName == itemName); + gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit); + RemovableCountChanged?.Invoke(this, EventArgs.Empty); + } + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, $"An error was encountered while processing a user click in the {nameof(ProductsGrid)}"); + } + } - if (column is null) continue; + private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex); - column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); + #endregion + + #region UI display functions + + internal bool RemoveColumnVisible + { + get => removeGVColumn.Visible; + set + { + if (value && bindingList is not null) + { + foreach (var book in bindingList.AllItems()) + book.Remove = false; } - //Remove column is always first; removeGVColumn.DisplayIndex = 0; - removeGVColumn.Visible = false; - removeGVColumn.ValueType = typeof(bool?); - removeGVColumn.FalseValue = false; - removeGVColumn.TrueValue = true; - removeGVColumn.IndeterminateValue = null; + removeGVColumn.Frozen = value; + removeGVColumn.Visible = value; + } + } + + internal async Task BindToGridAsync(List<LibraryBook> dbBooks) + { + //Get the UI thread's synchronization context and set it on the current thread to ensure + //it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync + var sc = Invoke(() => System.Threading.SynchronizationContext.Current); + System.Threading.SynchronizationContext.SetSynchronizationContext(sc); + + var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks); + var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks); + + geList.AddRange(seriesEntries); + //Sort descending by date (default sort property) + var comparer = new RowComparer(); + geList.Sort((a, b) => comparer.Compare(b, a)); + + //Add all children beneath their parent + foreach (var series in seriesEntries) + { + var seriesIndex = geList.IndexOf(series); + foreach (var child in series.Children) + geList.Insert(++seriesIndex, child); + } + System.Threading.SynchronizationContext.SetSynchronizationContext(null); + + bindingList = new GridEntryBindingList(geList) { SearchEngine = SearchEngine }; + bindingList.CollapseAll(); + + //The syncBindingSource ensures that the IGridEntry list is added on the UI thread + syncBindingSource.DataSource = bindingList; + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + } + + internal void UpdateGrid(List<LibraryBook> dbBooks) + { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(UpdateGrid)}"); + + //First row that is in view in the DataGridView + var topRow = gridEntryDataGridView.Rows.Cast<DataGridViewRow>().FirstOrDefault(r => r.Displayed)?.Index ?? 0; + + #region Add new or update existing grid entries + + //Remove filter prior to adding/updating books + string? existingFilter = syncBindingSource.Filter; + Filter(null); + + //Add absent entries to grid, or update existing entry + + var allEntries = bindingList.AllItems().BookEntries().ToDictionarySafe(b => b.AudibleProductId); + var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); + var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); + + //Get the UI thread's synchronization context and set it on the current thread to ensure + //it's available for creation of new IGridEntry items during upsert + var sc = Invoke(() => System.Threading.SynchronizationContext.Current); + System.Threading.SynchronizationContext.SetSynchronizationContext(sc); + + bindingList.RaiseListChangedEvents = false; + foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) + { + var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null; + + if (libraryBook.Book.IsProduct()) + { + AddOrUpdateBook(libraryBook, existingEntry); + continue; + } + if (parentedEpisodes.Contains(libraryBook)) + { + //Only try to add or update is this LibraryBook is a know child of a parent + AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); + } + } + bindingList.RaiseListChangedEvents = true; + + //Re-apply filter after adding new/updating existing books to capture any changes + //The Filter call also ensures that the binding list is reset so the DataGridView + //is made aware of all changes that were made while RaiseListChangedEvents was false + Filter(existingFilter); + + #endregion + + // remove deleted from grid. + // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this + var removedBooks = + bindingList + .AllItems() + .BookEntries() + .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); + + RemoveBooks(removedBooks); + + if (topRow >= 0 && topRow < gridEntryDataGridView.RowCount) + gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; + } + + public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks) + { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(RemoveBooks)}"); + + //Remove books in series from their parents' Children list + foreach (var removed in removedBooks.Where(b => b.Liberate?.IsEpisode is true)) + removed.Parent?.RemoveChild(removed); + + //Remove series that have no children + var removedSeries = + bindingList + .AllItems() + .EmptySeries(); + + foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries)) + //no need to re-filter for removed books + bindingList.Remove(removed); + + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + } + + private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry? existingBookEntry) + { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateBook)}"); + + if (existingBookEntry is null) + // Add the new product to top + bindingList.Insert(0, new LibraryBookEntry(book)); + else + // update existing + existingBookEntry.UpdateLibraryBook(book); + } + + private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry? existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks) + { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateEpisode)}"); + + if (existingEpisodeEntry is null) + { + LibraryBookEntry episodeEntry; + + var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); + + if (seriesEntry is null) + { + //Series doesn't exist yet, so create and add it + var seriesBook = dbBooks.FindSeriesParent(episodeBook); + + if (seriesBook is null) + { + //This is only possible if the user's db has some malformed + //entries from earlier Libation releases that could not be + //automatically fixed. Log, but don't throw. + Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames()); + return; + } + + seriesEntry = new SeriesEntry(seriesBook, episodeBook); + seriesEntries.Add(seriesEntry); + + episodeEntry = seriesEntry.Children[0]; + seriesEntry.Liberate?.Expanded = true; + bindingList.Insert(0, seriesEntry); + } + else + { + //Series exists. Create and add episode child then update the SeriesEntry + 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); + seriesEntry.UpdateLibraryBook(seriesBook); + } + + //Series entry must be expanded so its child can + //be placed in the correct position beneath it. + var isExpanded = seriesEntry.Liberate?.Expanded; + bindingList.ExpandItem(seriesEntry); + + //Add episode to the grid beneath the parent + int seriesIndex = bindingList.IndexOf(seriesEntry); + int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); + bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); + + if (isExpanded.HasValue && isExpanded.Value) + bindingList.ExpandItem(seriesEntry); + else if (isExpanded.HasValue) + bindingList.CollapseItem(seriesEntry); + } + else + existingEpisodeEntry.UpdateLibraryBook(episodeBook); + } + + #endregion + + #region Filter + + public void Filter(string? searchString) + { + if (bindingList is null) return; + + int visibleCount = bindingList.Count; + + if (string.IsNullOrEmpty(searchString)) + syncBindingSource.RemoveFilter(); + else + syncBindingSource.Filter = searchString; + + if (visibleCount != bindingList.Count) + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + } + + #endregion + + #region Column Customizations + + private void ProductsGrid_Load(object sender, EventArgs e) + { + //DesignMode is not set in constructor + if (DesignMode) + return; + + setGridFontScale(Configuration.Instance.GridFontScaleFactor); + setGridScale(Configuration.Instance.GridScaleFactor); + Configuration.Instance.PropertyChanged += Configuration_ScaleChanged; + Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged; + gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; + + gridEntryDataGridView.Disposed += (_, _) => + { + Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged; + Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged; + }; + + gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged; + gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged; + + showHideColumnsContextMenuStrip.Items.Add(new ToolStripLabel("Show / Hide Columns")); + showHideColumnsContextMenuStrip.Items.Add(new ToolStripSeparator()); + + //Restore Grid Display Settings + var config = Configuration.Instance; + var gridColumnsWidths = config.GridColumnsWidths; + var displayIndices = config.GridColumnsDisplayIndices; + + var cmsKiller = new ContextMenuStrip(); + + foreach (DataGridViewColumn column in gridEntryDataGridView.Columns) + { + if (column == removeGVColumn) + continue; + var itemName = column.DataPropertyName; + var visible = config.GetColumnVisibility(itemName); + + var menuItem = new ToolStripMenuItem(column.HeaderText) + { + Checked = visible, + Tag = itemName + }; + menuItem.Click += HideMenuItem_Click; + showHideColumnsContextMenuStrip.Items.Add(menuItem); + + //Only set column widths for user resizable columns. + //Fixed column widths are set by setGridScale() + if (column.Resizable is not DataGridViewTriState.False) + column.Width = gridColumnsWidths.GetValueOrDefault(itemName, this.DpiScale(column.Width)); + + column.MinimumWidth = 10; + column.HeaderCell.ContextMenuStrip = showHideColumnsContextMenuStrip; + column.Visible = visible; + + //Setting a default ContextMenuStrip will allow the columns to handle the + //Show() event so it is not passed up to the _dataGridView.ContextMenuStrip. + //This allows the ContextMenuStrip to be shown if right-clicking in the gray + //background of _dataGridView but not shown if right-clicking inside cells. + column.ContextMenuStrip = cmsKiller; } - private void HideMenuItem_Click(object? sender, EventArgs e) + //We must set DisplayIndex properties in ascending order + foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key)) { - var menuItem = sender as ToolStripMenuItem; - var propertyName = menuItem?.Tag as string; - var column = gridEntryDataGridView.Columns .Cast<DataGridViewColumn>() - .FirstOrDefault(c => c.DataPropertyName == propertyName); + .SingleOrDefault(c => c.DataPropertyName == itemName); - if (column != null && menuItem != null && propertyName != null) - { - var visible = menuItem.Checked; - menuItem.Checked = !visible; - column.Visible = !visible; + if (column is null) continue; - var config = Configuration.Instance; - - var dictionary = config.GridColumnsVisibilities; - dictionary[propertyName] = column.Visible; - config.GridColumnsVisibilities = dictionary; - } + column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); } - private void gridEntryDataGridView_ColumnDisplayIndexChanged(object? sender, DataGridViewColumnEventArgs e) - { - if (DisableColumnCustomization) return; - var config = Configuration.Instance; - - var dictionary = config.GridColumnsDisplayIndices; - dictionary[e.Column.DataPropertyName] = e.Column.DisplayIndex; - config.GridColumnsDisplayIndices = dictionary; - } - - private void gridEntryDataGridView_CellToolTipTextNeeded(object? sender, DataGridViewCellToolTipTextNeededEventArgs e) - { - if (e.ColumnIndex == descriptionGVColumn.Index) - e.ToolTipText = "Click to see full description"; - else if (e.ColumnIndex == coverGVColumn.Index) - e.ToolTipText = "Click to see full size"; - } - - private void gridEntryDataGridView_ColumnWidthChanged(object? sender, DataGridViewColumnEventArgs e) - { - if (DisableColumnCustomization) return; - var config = Configuration.Instance; - - var dictionary = config.GridColumnsWidths; - dictionary[e.Column.DataPropertyName] = e.Column.Width; - config.GridColumnsWidths = dictionary; - } - - #endregion + //Remove column is always first; + removeGVColumn.DisplayIndex = 0; + removeGVColumn.Visible = false; + removeGVColumn.ValueType = typeof(bool?); + removeGVColumn.FalseValue = false; + removeGVColumn.TrueValue = true; + removeGVColumn.IndeterminateValue = null; } + + private void HideMenuItem_Click(object? sender, EventArgs e) + { + var menuItem = sender as ToolStripMenuItem; + var propertyName = menuItem?.Tag as string; + + var column = gridEntryDataGridView.Columns + .Cast<DataGridViewColumn>() + .FirstOrDefault(c => c.DataPropertyName == propertyName); + + if (column != null && menuItem != null && propertyName != null) + { + var visible = menuItem.Checked; + menuItem.Checked = !visible; + column.Visible = !visible; + + var config = Configuration.Instance; + + var dictionary = config.GridColumnsVisibilities; + dictionary[propertyName] = column.Visible; + config.GridColumnsVisibilities = dictionary; + } + } + + private void gridEntryDataGridView_ColumnDisplayIndexChanged(object? sender, DataGridViewColumnEventArgs e) + { + if (DisableColumnCustomization) return; + var config = Configuration.Instance; + + var dictionary = config.GridColumnsDisplayIndices; + dictionary[e.Column.DataPropertyName] = e.Column.DisplayIndex; + config.GridColumnsDisplayIndices = dictionary; + } + + private void gridEntryDataGridView_CellToolTipTextNeeded(object? sender, DataGridViewCellToolTipTextNeededEventArgs e) + { + if (e.ColumnIndex == descriptionGVColumn.Index) + e.ToolTipText = "Click to see full description"; + else if (e.ColumnIndex == coverGVColumn.Index) + e.ToolTipText = "Click to see full size"; + } + + private void gridEntryDataGridView_ColumnWidthChanged(object? sender, DataGridViewColumnEventArgs e) + { + if (DisableColumnCustomization) return; + var config = Configuration.Instance; + + var dictionary = config.GridColumnsWidths; + dictionary[e.Column.DataPropertyName] = e.Column.Width; + config.GridColumnsWidths = dictionary; + } + + #endregion } diff --git a/Source/LibationWinForms/GridView/RowComparer.cs b/Source/LibationWinForms/GridView/RowComparer.cs index 4867ab9b..3a65636a 100644 --- a/Source/LibationWinForms/GridView/RowComparer.cs +++ b/Source/LibationWinForms/GridView/RowComparer.cs @@ -3,18 +3,17 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; -namespace LibationWinForms.GridView -{ - internal class RowComparer : RowComparerBase - { - public ListSortDirection SortOrder { get; set; } = ListSortDirection.Descending; - public override string PropertyName { get; set; } = nameof(GridEntry.DateAdded); - protected override ListSortDirection GetSortOrder() => SortOrder; +namespace LibationWinForms.GridView; - /// <summary> - /// Helper method for ordering grid entries - /// </summary> - public IOrderedEnumerable<GridEntry> OrderEntries(IEnumerable<GridEntry> entries) - => SortOrder is ListSortDirection.Descending ? entries.OrderDescending(this) : entries.Order(this); - } +internal class RowComparer : RowComparerBase +{ + public ListSortDirection SortOrder { get; set; } = ListSortDirection.Descending; + public override string? PropertyName { get; set; } = nameof(GridEntry.DateAdded); + protected override ListSortDirection GetSortOrder() => SortOrder; + + /// <summary> + /// Helper method for ordering grid entries + /// </summary> + public IOrderedEnumerable<GridEntry> OrderEntries(IEnumerable<GridEntry> entries) + => SortOrder is ListSortDirection.Descending ? entries.OrderDescending(this) : entries.Order(this); } diff --git a/Source/LibationWinForms/GridView/SyncBindingSource.cs b/Source/LibationWinForms/GridView/SyncBindingSource.cs index 970e110a..18e22775 100644 --- a/Source/LibationWinForms/GridView/SyncBindingSource.cs +++ b/Source/LibationWinForms/GridView/SyncBindingSource.cs @@ -3,27 +3,26 @@ using System.Threading; using System.Windows.Forms; // https://stackoverflow.com/a/32886415 -namespace LibationWinForms.GridView +namespace LibationWinForms.GridView; + +public class SyncBindingSource : BindingSource { - public class SyncBindingSource : BindingSource + private SynchronizationContext? syncContext { get; } + + public SyncBindingSource() : base() + => syncContext = SynchronizationContext.Current; + public SyncBindingSource(IContainer container) : base(container) + => syncContext = SynchronizationContext.Current; + public SyncBindingSource(object dataSource, string dataMember) : base(dataSource, dataMember) + => syncContext = SynchronizationContext.Current; + + public override bool SupportsFiltering => true; + + protected override void OnListChanged(ListChangedEventArgs e) { - private SynchronizationContext syncContext { get; } - - public SyncBindingSource() : base() - => syncContext = SynchronizationContext.Current; - public SyncBindingSource(IContainer container) : base(container) - => syncContext = SynchronizationContext.Current; - public SyncBindingSource(object dataSource, string dataMember) : base(dataSource, dataMember) - => syncContext = SynchronizationContext.Current; - - public override bool SupportsFiltering => true; - - protected override void OnListChanged(ListChangedEventArgs e) - { - if (syncContext is not null) - syncContext.Send(_ => base.OnListChanged(e), null); - else - base.OnListChanged(e); - } + if (syncContext is not null) + syncContext.Send(_ => base.OnListChanged(e), null); + else + base.OnListChanged(e); } } diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index 1a49920e..19897d85 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -8,11 +8,12 @@ <ApplicationIcon>libation.ico</ApplicationIcon> <AssemblyName>Libation</AssemblyName> <UseWindowsForms>true</UseWindowsForms> + <Nullable>enable</Nullable> <PublishReadyToRun>true</PublishReadyToRun> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <IsPublishable>true</IsPublishable> - <NoWarn>$(NoWarn);WFO1000</NoWarn> + <NoWarn>$(NoWarn);WFO1000;MSB3277</NoWarn> <!-- Version is now in AppScaffolding.csproj --> </PropertyGroup> @@ -41,7 +42,7 @@ <ItemGroup> <PackageReference Include="Dinah.Core.WindowsDesktop" Version="10.0.0.1" /> - <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3650.58" /> + <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3719.77" /> </ItemGroup> <ItemGroup> diff --git a/Source/LibationWinForms/MessageBoxLib.cs b/Source/LibationWinForms/MessageBoxLib.cs index e189ef1f..07fade2e 100644 --- a/Source/LibationWinForms/MessageBoxLib.cs +++ b/Source/LibationWinForms/MessageBoxLib.cs @@ -8,51 +8,51 @@ using Dinah.Core.Threading; using LibationWinForms.Dialogs; using Serilog; -namespace LibationWinForms -{ - public static class MessageBoxLib - { - /// <summary> - /// Logs error. Displays a message box dialog with specified text and caption. - /// </summary> - /// <param name="synchronizeInvoke">Form calling this method.</param> - /// <param name="text">The text to display in the message box.</param> - /// <param name="caption">The text to display in the title bar of the message box.</param> - /// <param name="exception">Exception to log.</param> - public static void ShowAdminAlert(System.ComponentModel.ISynchronizeInvoke owner, string text, string caption, Exception exception) - { - // for development and debugging, show me what broke! - if (System.Diagnostics.Debugger.IsAttached) - //Wrap the exception to preserve its stack trace. - throw new Exception("An unhandled exception was encountered", exception); +namespace LibationWinForms; +public static class MessageBoxLib +{ + /// <summary> + /// Logs error. Displays a message box dialog with specified text and caption. + /// </summary> + /// <param name="synchronizeInvoke">Form calling this method.</param> + /// <param name="text">The text to display in the message box.</param> + /// <param name="caption">The text to display in the title bar of the message box.</param> + /// <param name="exception">Exception to log.</param> + public static void ShowAdminAlert(System.ComponentModel.ISynchronizeInvoke? owner, string text, string caption, Exception exception) + { + // for development and debugging, show me what broke! + if (System.Diagnostics.Debugger.IsAttached) + //Wrap the exception to preserve its stack trace. + throw new Exception("An unhandled exception was encountered", exception); + + try + { + Serilog.Log.Logger.Error(exception, "Alert admin error: {@DebugText}", new { text, caption }); + } + catch { } + + using var form = new MessageBoxAlertAdminDialog(text, caption, exception); + + if (owner is not null) + { try { - Serilog.Log.Logger.Error(exception, "Alert admin error: {@DebugText}", new { text, caption }); + owner.UIThreadSync(() => form.ShowDialog()); + return; } catch { } - - using var form = new MessageBoxAlertAdminDialog(text, caption, exception); - - if (owner is not null) - { - try - { - owner.UIThreadSync(() => form.ShowDialog()); - return; - } - catch { } - } - - // synchronizeInvoke is null or previous attempt failed. final try - form.ShowDialog(); } + // synchronizeInvoke is null or previous attempt failed. final try + form.ShowDialog(); + } + public static void VerboseLoggingWarning_ShowIfTrue() - { - // when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured - if (Log.Logger.IsVerboseEnabled()) - MessageBox.Show(@" + { + // when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured + if (Log.Logger.IsVerboseEnabled()) + MessageBox.Show(@" Warning: verbose logging is enabled. This should be used for debugging only. It creates many @@ -63,31 +63,30 @@ When you are finished debugging, it's highly recommended to set your debug MinimumLevel to Information and restart Libation. ".Trim(), "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - } + } - /// <summary> - /// Note: the format field should use {0} and NOT use the `$` string interpolation. Formatting is done inside this method. - /// </summary> - public static DialogResult ShowConfirmationDialog(IEnumerable<LibraryBook> libraryBooks, string format, string title) - { - if (libraryBooks is null || !libraryBooks.Any()) - return DialogResult.Cancel; + /// <summary> + /// Note: the format field should use {0} and NOT use the `$` string interpolation. Formatting is done inside this method. + /// </summary> + public static DialogResult ShowConfirmationDialog(IEnumerable<LibraryBook> libraryBooks, string format, string title) + { + if (libraryBooks is null || !libraryBooks.Any()) + return DialogResult.Cancel; - var count = libraryBooks.Count(); + var count = libraryBooks.Count(); - string thisThese = count > 1 ? "these" : "this"; - string bookBooks = count > 1 ? "books" : "book"; - string titlesAgg = libraryBooks.AggregateTitles(); + string thisThese = count > 1 ? "these" : "this"; + string bookBooks = count > 1 ? "books" : "book"; + string titlesAgg = libraryBooks.AggregateTitles(); - var message - = string.Format(format, $"{thisThese} {count} {bookBooks}") - + $"\r\n\r\n{titlesAgg}"; - return MessageBox.Show( - message, - title, - MessageBoxButtons.YesNo, - MessageBoxIcon.Question, - MessageBoxDefaultButton.Button1); - } + var message + = string.Format(format, $"{thisThese} {count} {bookBooks}") + + $"\r\n\r\n{titlesAgg}"; + return MessageBox.Show( + message, + title, + MessageBoxButtons.YesNo, + MessageBoxIcon.Question, + MessageBoxDefaultButton.Button1); } } diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs index 13d7f030..3194bcf2 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs @@ -3,120 +3,118 @@ using System; using System.Drawing; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms.ProcessQueue +namespace LibationWinForms.ProcessQueue; + +internal partial class ProcessBookControl : UserControl { - internal partial class ProcessBookControl : UserControl + private readonly int CancelBtnDistanceFromEdge; + private readonly int ProgressBarDistanceFromEdge; + private object? m_OldContext; + + public ProcessBookControl() { - private readonly int CancelBtnDistanceFromEdge; - private readonly int ProgressBarDistanceFromEdge; - private object? m_OldContext; + InitializeComponent(); + remainingTimeLbl.Visible = false; + progressBar1.Visible = false; + etaLbl.Visible = false; + moveDownBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_down_dark : Properties.Resources.move_down; + moveUpBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_up_dark : Properties.Resources.move_up; + moveFirstBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_first_dark : Properties.Resources.move_first; + moveLastBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_last_dark : Properties.Resources.move_last; - public ProcessBookControl() - { - InitializeComponent(); - remainingTimeLbl.Visible = false; - progressBar1.Visible = false; - etaLbl.Visible = false; - moveDownBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_down_dark : Properties.Resources.move_down; - moveUpBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_up_dark : Properties.Resources.move_up; - moveFirstBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_first_dark : Properties.Resources.move_first; - moveLastBtn.BackgroundImage = Application.IsDarkModeEnabled ? Properties.Resources.move_last_dark : Properties.Resources.move_last; - - CancelBtnDistanceFromEdge = Width - cancelBtn.Location.X; - ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width; - } - - protected override void OnDataContextChanged(EventArgs e) - { - if (m_OldContext is ProcessBookViewModel oldContext) - oldContext.PropertyChanged -= DataContext_PropertyChanged; - - if (DataContext is ProcessBookViewModel newContext) - { - m_OldContext = newContext; - newContext.PropertyChanged += DataContext_PropertyChanged; - DataContext_PropertyChanged(DataContext, new System.ComponentModel.PropertyChangedEventArgs(null)); - } - - base.OnDataContextChanged(e); - } - - private void DataContext_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) - { - if (sender is not ProcessBookViewModel vm) - return; - - SuspendLayout(); - if (e.PropertyName is null or nameof(vm.Cover)) - SetCover(vm.Cover as Image); - if (e.PropertyName is null or nameof(vm.Title) or nameof(vm.Author) or nameof(vm.Narrator)) - SetBookInfo($"{vm.Title}\r\nBy {vm.Author}\r\nNarrated by {vm.Narrator}"); - if (e.PropertyName is null or nameof(vm.Status) or nameof(vm.StatusText)) - SetStatus(vm.Status, vm.StatusText); - if (e.PropertyName is null or nameof(vm.Progress)) - SetProgress(vm.Progress); - if (e.PropertyName is null or nameof(vm.TimeRemaining)) - SetRemainingTime(vm.TimeRemaining); - ResumeLayout(); - } - - private void SetCover(Image? cover) => pictureBox1.Image = cover; - private void SetBookInfo(string title) => bookInfoLbl.Text = title; - private void SetRemainingTime(TimeSpan remaining) - => remainingTimeLbl.Text = $"{remaining:mm\\:ss}"; - - private void SetProgress(int progress) - { - //Disable slow fill - //https://stackoverflow.com/a/5332770/3335599 - if (progress < progressBar1.Maximum) - progressBar1.Value = progress + 1; - progressBar1.Value = progress; - } - - private void SetStatus(ProcessBookStatus status, string statusText) - { - cancelBtn.Visible = status is ProcessBookStatus.Queued or ProcessBookStatus.Working; - moveLastBtn.Visible = status == ProcessBookStatus.Queued; - moveDownBtn.Visible = status == ProcessBookStatus.Queued; - moveUpBtn.Visible = status == ProcessBookStatus.Queued; - moveFirstBtn.Visible = status == ProcessBookStatus.Queued; - remainingTimeLbl.Visible = status == ProcessBookStatus.Working; - progressBar1.Visible = status == ProcessBookStatus.Working; - etaLbl.Visible = status == ProcessBookStatus.Working; - statusLbl.Visible = status != ProcessBookStatus.Working; - statusLbl.Text = statusText; - BackColor = status.GetColor(); - - int deltaX = Width - cancelBtn.Location.X - CancelBtnDistanceFromEdge; - - if (status is ProcessBookStatus.Queued or ProcessBookStatus.Working && deltaX != 0) - { - //If the last book to occupy this control before resizing was not - //queued, the buttons were not Visible so the Anchor property was - //ignored. Manually resize and reposition everything - - cancelBtn.Location = new Point(cancelBtn.Location.X + deltaX, cancelBtn.Location.Y); - moveFirstBtn.Location = new Point(moveFirstBtn.Location.X + deltaX, moveFirstBtn.Location.Y); - moveUpBtn.Location = new Point(moveUpBtn.Location.X + deltaX, moveUpBtn.Location.Y); - moveDownBtn.Location = new Point(moveDownBtn.Location.X + deltaX, moveDownBtn.Location.Y); - moveLastBtn.Location = new Point(moveLastBtn.Location.X + deltaX, moveLastBtn.Location.Y); - etaLbl.Location = new Point(etaLbl.Location.X + deltaX, etaLbl.Location.Y); - remainingTimeLbl.Location = new Point(remainingTimeLbl.Location.X + deltaX, remainingTimeLbl.Location.Y); - progressBar1.Width = Width - ProgressBarDistanceFromEdge - progressBar1.Location.X; - } - - if (status == ProcessBookStatus.Working) - { - bookInfoLbl.Width = cancelBtn.Location.X - bookInfoLbl.Location.X - bookInfoLbl.Padding.Left + cancelBtn.Padding.Right; - } - else - { - bookInfoLbl.Width = moveLastBtn.Location.X - bookInfoLbl.Location.X - bookInfoLbl.Padding.Left + moveLastBtn.Padding.Right; - } - } - - public override string ToString() => bookInfoLbl.Text ?? "[NO TITLE]"; + CancelBtnDistanceFromEdge = Width - cancelBtn.Location.X; + ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width; } + + protected override void OnDataContextChanged(EventArgs e) + { + if (m_OldContext is ProcessBookViewModel oldContext) + oldContext.PropertyChanged -= DataContext_PropertyChanged; + + if (DataContext is ProcessBookViewModel newContext) + { + m_OldContext = newContext; + newContext.PropertyChanged += DataContext_PropertyChanged; + DataContext_PropertyChanged(DataContext, new System.ComponentModel.PropertyChangedEventArgs(null)); + } + + base.OnDataContextChanged(e); + } + + private void DataContext_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (sender is not ProcessBookViewModel vm) + return; + + SuspendLayout(); + if (e.PropertyName is null or nameof(vm.Cover)) + SetCover(vm.Cover as Image); + if (e.PropertyName is null or nameof(vm.Title) or nameof(vm.Author) or nameof(vm.Narrator)) + SetBookInfo($"{vm.Title}\r\nBy {vm.Author}\r\nNarrated by {vm.Narrator}"); + if (e.PropertyName is null or nameof(vm.Status) or nameof(vm.StatusText)) + SetStatus(vm.Status, vm.StatusText); + if (e.PropertyName is null or nameof(vm.Progress)) + SetProgress(vm.Progress); + if (e.PropertyName is null or nameof(vm.TimeRemaining)) + SetRemainingTime(vm.TimeRemaining); + ResumeLayout(); + } + + private void SetCover(Image? cover) => pictureBox1.Image = cover; + private void SetBookInfo(string title) => bookInfoLbl.Text = title; + private void SetRemainingTime(TimeSpan remaining) + => remainingTimeLbl.Text = $"{remaining:mm\\:ss}"; + + private void SetProgress(int progress) + { + //Disable slow fill + //https://stackoverflow.com/a/5332770/3335599 + if (progress < progressBar1.Maximum) + progressBar1.Value = progress + 1; + progressBar1.Value = progress; + } + + private void SetStatus(ProcessBookStatus status, string statusText) + { + cancelBtn.Visible = status is ProcessBookStatus.Queued or ProcessBookStatus.Working; + moveLastBtn.Visible = status == ProcessBookStatus.Queued; + moveDownBtn.Visible = status == ProcessBookStatus.Queued; + moveUpBtn.Visible = status == ProcessBookStatus.Queued; + moveFirstBtn.Visible = status == ProcessBookStatus.Queued; + remainingTimeLbl.Visible = status == ProcessBookStatus.Working; + progressBar1.Visible = status == ProcessBookStatus.Working; + etaLbl.Visible = status == ProcessBookStatus.Working; + statusLbl.Visible = status != ProcessBookStatus.Working; + statusLbl.Text = statusText; + BackColor = status.GetColor(); + + int deltaX = Width - cancelBtn.Location.X - CancelBtnDistanceFromEdge; + + if (status is ProcessBookStatus.Queued or ProcessBookStatus.Working && deltaX != 0) + { + //If the last book to occupy this control before resizing was not + //queued, the buttons were not Visible so the Anchor property was + //ignored. Manually resize and reposition everything + + cancelBtn.Location = new Point(cancelBtn.Location.X + deltaX, cancelBtn.Location.Y); + moveFirstBtn.Location = new Point(moveFirstBtn.Location.X + deltaX, moveFirstBtn.Location.Y); + moveUpBtn.Location = new Point(moveUpBtn.Location.X + deltaX, moveUpBtn.Location.Y); + moveDownBtn.Location = new Point(moveDownBtn.Location.X + deltaX, moveDownBtn.Location.Y); + moveLastBtn.Location = new Point(moveLastBtn.Location.X + deltaX, moveLastBtn.Location.Y); + etaLbl.Location = new Point(etaLbl.Location.X + deltaX, etaLbl.Location.Y); + remainingTimeLbl.Location = new Point(remainingTimeLbl.Location.X + deltaX, remainingTimeLbl.Location.Y); + progressBar1.Width = Width - ProgressBarDistanceFromEdge - progressBar1.Location.X; + } + + if (status == ProcessBookStatus.Working) + { + bookInfoLbl.Width = cancelBtn.Location.X - bookInfoLbl.Location.X - bookInfoLbl.Padding.Left + cancelBtn.Padding.Right; + } + else + { + bookInfoLbl.Width = moveLastBtn.Location.X - bookInfoLbl.Location.X - bookInfoLbl.Padding.Left + moveLastBtn.Padding.Right; + } + } + + public override string ToString() => bookInfoLbl.Text ?? "[NO TITLE]"; } diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookForm.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookForm.cs index b1444c84..274d2796 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessBookForm.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookForm.cs @@ -1,26 +1,25 @@ using System.Windows.Forms; -namespace LibationWinForms.ProcessQueue +namespace LibationWinForms.ProcessQueue; + +public partial class ProcessBookForm : Form { - public partial class ProcessBookForm : Form + private Control? _dockControl; + public int WidthChange { get; set; } + public ProcessBookForm() { - private Control _dockControl; - public int WidthChange { get; set; } - public ProcessBookForm() - { - InitializeComponent(); - } + InitializeComponent(); + } - public void PassControl(Control dockControl) - { - _dockControl = dockControl; - Controls.Add(_dockControl); - } + public void PassControl(Control dockControl) + { + _dockControl = dockControl; + Controls.Add(_dockControl); + } - public Control RegainControl() - { - Controls.Remove(_dockControl); - return _dockControl; - } + public Control? RegainControl() + { + Controls.Remove(_dockControl); + return _dockControl; } } diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index de89f06c..ca995b05 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -7,7 +7,6 @@ using System.Drawing; using System.Linq; using System.Windows.Forms; -#nullable enable namespace LibationWinForms.ProcessQueue; internal partial class ProcessQueueControl : UserControl diff --git a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs index 5e6e7e3e..49f44e20 100644 --- a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs +++ b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs @@ -5,309 +5,307 @@ using System.Collections.Specialized; using System.Drawing; using System.Windows.Forms; -#nullable enable -namespace LibationWinForms.ProcessQueue +namespace LibationWinForms.ProcessQueue; + +internal partial class VirtualFlowControl : UserControl { - internal partial class VirtualFlowControl : UserControl + /// <summary> + /// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked + /// </summary> + public event EventHandler<string>? ButtonClicked; + + /// <summary> + /// Gets the index of the first realized element, or -1 if no elements are realized. + /// </summary> + public int FirstRealizedIndex { get; private set; } = -1; + + /// <summary> + /// Gets the index of the last realized element, or -1 if no elements are realized. + /// </summary> + public int LastRealizedIndex { get; private set; } = -1; + + public IList? Items { get; private set; } + + private object? m_OldContext; + protected override void OnDataContextChanged(EventArgs e) { - /// <summary> - /// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked - /// </summary> - public event EventHandler<string>? ButtonClicked; + if (m_OldContext is INotifyCollectionChanged oldNotify) + oldNotify.CollectionChanged -= Items_CollectionChanged; - /// <summary> - /// Gets the index of the first realized element, or -1 if no elements are realized. - /// </summary> - public int FirstRealizedIndex { get; private set; } = -1; - - /// <summary> - /// Gets the index of the last realized element, or -1 if no elements are realized. - /// </summary> - public int LastRealizedIndex { get; private set; } = -1; - - public IList? Items { get; private set; } - - private object? m_OldContext; - protected override void OnDataContextChanged(EventArgs e) + if (DataContext is INotifyCollectionChanged newNotify) { - if (m_OldContext is INotifyCollectionChanged oldNotify) - oldNotify.CollectionChanged -= Items_CollectionChanged; - - if (DataContext is INotifyCollectionChanged newNotify) - { - m_OldContext = newNotify; - newNotify.CollectionChanged += Items_CollectionChanged; - } - - Items = DataContext as IList; - base.OnDataContextChanged(e); + m_OldContext = newNotify; + newNotify.CollectionChanged += Items_CollectionChanged; } - private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - RefreshDisplay(); - } + Items = DataContext as IList; + base.OnDataContextChanged(e); + } - public void RefreshDisplay() + private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RefreshDisplay(); + } + + public void RefreshDisplay() + { + if (InvokeRequired) { - if (InvokeRequired) - { - Invoke((MethodInvoker)delegate - { - AdjustScrollBar(); - DoVirtualScroll(); - }); - } - else + Invoke((MethodInvoker)delegate { AdjustScrollBar(); DoVirtualScroll(); - } + }); } - - #region Dynamic Properties - - /// <summary> - /// The number of virtual <see cref="ProcessBookControl"/>s in the <see cref="VirtualFlowControl"/> - /// </summary> - public int VirtualControlCount => Items?.Count ?? 0; - - int ScrollValue => Math.Max(vScrollBar1.Value, 0); - /// <summary> - /// Amount the control moves with a small scroll change - /// </summary> - private int SmallScrollChange => VirtualControlHeight * SMALL_SCROLL_CHANGE_MULTIPLE; - /// <summary> - /// Amount the control moves with a large scroll change. Equal to the number of whole <see cref="ProcessBookControl"/>s in the panel, less 1. - /// </summary> - private int LargeScrollChange => Math.Max(DisplayHeight / VirtualControlHeight - 1, SMALL_SCROLL_CHANGE_MULTIPLE) * VirtualControlHeight; - /// <summary> - /// Virtual height of all virtual controls within this <see cref="VirtualFlowControl"/> - /// </summary> - private int VirtualHeight => (VirtualControlCount + NUM_BLANK_SPACES_AT_BOTTOM) * VirtualControlHeight - DisplayHeight + 2 * TopMargin; - /// <summary> - /// Index of the first virtual <see cref="ProcessBookControl"/> - /// </summary> - private int FirstVisibleVirtualIndex => ScrollValue / VirtualControlHeight; - /// <summary> - /// The display height of this <see cref="VirtualFlowControl"/> - /// </summary> - private int DisplayHeight => DisplayRectangle.Height; - - #endregion - - #region Instance variables - - /// <summary> - /// The total height, including margins, of the repeated <see cref="ProcessBookControl"/> - /// </summary> - private readonly int VirtualControlHeight; - /// <summary> - /// Margin between the top <see cref="ProcessBookControl"/> and the top of the Panel, and the bottom <see cref="ProcessBookControl"/> and the bottom of the panel - /// </summary> - private readonly int TopMargin; - - private readonly List<ProcessBookControl> BookControls = new(); - - #endregion - - #region Global behavior settings - - /// <summary> - /// Total number of actual controls added to the panel. 23 is sufficient up to a 4k monitor height. - /// </summary> - private const int NUM_ACTUAL_CONTROLS = 23; - /// <summary> - /// Multiple of <see cref="VirtualControlHeight"/> that is moved for each small scroll change - /// </summary> - private const int SMALL_SCROLL_CHANGE_MULTIPLE = 1; - /// <summary> - /// Amount of space at the bottom of the <see cref="VirtualFlowControl"/>, in multiples of <see cref="VirtualControlHeight"/> - /// </summary> - private const int NUM_BLANK_SPACES_AT_BOTTOM = 2; - - #endregion - - public VirtualFlowControl() + else { - InitializeComponent(); - - panel1.Resize += (_, _) => RefreshDisplay(); - - var control = InitControl(0); - VirtualControlHeight = this.DpiUnscale(control.Height + control.Margin.Top + control.Margin.Bottom); - TopMargin = control.Margin.Top; - - BookControls.Add(control); - panel1.Controls.Add(control); - - for (int i = 1; i < NUM_ACTUAL_CONTROLS; i++) - { - control = InitControl(VirtualControlHeight * i); - BookControls.Add(control); - panel1.Controls.Add(control); - } - - vScrollBar1.Scroll += (_, s) => SetScrollPosition(s.NewValue); - vScrollBar1.SmallChange = SmallScrollChange; - panel1.Height += this.DpiScale(NUM_BLANK_SPACES_AT_BOTTOM * VirtualControlHeight); - } - - private ProcessBookControl InitControl(int locationY) - { - var control = new ProcessBookControl(); - control.Location = new Point(control.Margin.Left, locationY + control.Margin.Top); - control.Width = panel1.ClientRectangle.Width - control.Margin.Left - control.Margin.Right; - control.Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top; - - control.cancelBtn.Click += ControlButton_Click; - control.moveFirstBtn.Click += ControlButton_Click; - control.moveUpBtn.Click += ControlButton_Click; - control.moveDownBtn.Click += ControlButton_Click; - control.moveLastBtn.Click += ControlButton_Click; - return control; - } - - /// <summary> - /// Handles all button clicks from all <see cref="ProcessBookControl"/>, detects which one sent the click, and fires <see cref="ButtonClicked"/> to notify the model of the click - /// </summary> - private void ControlButton_Click(object? sender, EventArgs e) - { - Control? button = sender as Control; - Control? form = button?.Parent; - while (form is not null and not ProcessBookControl) - form = form?.Parent; - - if (form is not null && button?.Name is string buttonText) - ButtonClicked?.Invoke(form, buttonText); - } - - /// <summary> - /// Adjusts the <see cref="vScrollBar1"/> max width and enabled status based on the <see cref="VirtualControlCount"/> and the <see cref="DisplayHeight"/> - /// </summary> - private void AdjustScrollBar() - { - int maxFullVisible = DisplayHeight / VirtualControlHeight; - - if (VirtualControlCount <= maxFullVisible) - { - vScrollBar1.Enabled = false; - vScrollBar1.Value = 0; - - for (int i = VirtualControlCount; i < NUM_ACTUAL_CONTROLS; i++) - BookControls[i].Visible = false; - } - else - { - vScrollBar1.Enabled = true; - - //https://stackoverflow.com/a/2882878/3335599 - int newMaximum = VirtualHeight + LargeScrollChange - 1; - if (newMaximum < vScrollBar1.Maximum) - vScrollBar1.Value = Math.Max(vScrollBar1.Value - (vScrollBar1.Maximum - newMaximum), 0); - vScrollBar1.Maximum = newMaximum; - vScrollBar1.LargeChange = LargeScrollChange; - } - } - - /// <summary> - /// Scrolls the specified item into view. - /// </summary> - /// <param name="index">The index of the item.</param> - public void ScrollIntoView(int index) - { - if (index < 0 || index >= VirtualControlCount) - throw new ArgumentOutOfRangeException(nameof(index)); - int firstVisible = FirstVisibleVirtualIndex; - int lastVisible = firstVisible + (DisplayHeight / VirtualControlHeight) - 1; - if (index < firstVisible) - { - SetScrollPosition(index * VirtualControlHeight); - } - else if (index > lastVisible) - { - int newScrollPos = (index - (lastVisible - firstVisible)) * VirtualControlHeight; - SetScrollPosition(newScrollPos); - } - } - - /// <summary> - /// Calculated the virtual controls that are in view at the current scroll position and windows size, - /// positions <see cref="panel1"/> to simulate scroll activity, then fires updates the controls with - /// the context corresponding to the virtual scroll position - /// </summary> - private void DoVirtualScroll() - { - int firstVisible = FirstVisibleVirtualIndex; - - int position = ScrollValue % VirtualControlHeight; - panel1.Location = new Point(0, -position); - - int numVisible = DisplayHeight / VirtualControlHeight; - - if (DisplayHeight % VirtualControlHeight != 0) - numVisible++; - - numVisible = Math.Min(numVisible, VirtualControlCount); - numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible); - - if (Items is IList items) - { - for (int i = 0; i < numVisible; i++) - BookControls[i].DataContext = items[firstVisible + i]; - } - - for (int i = 0; i < BookControls.Count; i++) - { - BookControls[i].Visible = i < numVisible; - } - - FirstRealizedIndex = firstVisible; - LastRealizedIndex = firstVisible + numVisible - 1; - } - - /// <summary> - /// Set scroll value to an integral multiple of <see cref="SmallScrollChange"/> - /// </summary> - private void SetScrollPosition(int value) - { - if (!vScrollBar1.Enabled) return; - - int newPos = (int)Math.Round((double)value / SmallScrollChange) * SmallScrollChange; - if (vScrollBar1.Value != newPos) - { - //https://stackoverflow.com/a/2882878/3335599 - vScrollBar1.Value = Math.Min(newPos, vScrollBar1.Maximum - vScrollBar1.LargeChange + 1); - DoVirtualScroll(); - } - } - - - private const int WM_MOUSEWHEEL = 522; - private const int WHEEL_DELTA = 120; - protected override void WndProc(ref Message m) - { - //Capture mouse wheel movement and interpret it as a scroll event - if (m.Msg == WM_MOUSEWHEEL) - { - //https://docs.microsoft.com/en-us/windows/win32/inputdev/wm-mousewheel - int wheelDelta = -(short)(((ulong)m.WParam) >> 16 & ushort.MaxValue); - - int numSmallPositionMoves = Math.Abs(wheelDelta) / WHEEL_DELTA; - - int scrollDelta = Math.Sign(wheelDelta) * numSmallPositionMoves * SmallScrollChange; - - int newScrollPosition; - - if (scrollDelta > 0) - newScrollPosition = Math.Min(vScrollBar1.Value + scrollDelta, vScrollBar1.Maximum); - else - newScrollPosition = Math.Max(vScrollBar1.Value + scrollDelta, vScrollBar1.Minimum); - - SetScrollPosition(newScrollPosition); - } - - base.WndProc(ref m); + AdjustScrollBar(); + DoVirtualScroll(); } } + + #region Dynamic Properties + + /// <summary> + /// The number of virtual <see cref="ProcessBookControl"/>s in the <see cref="VirtualFlowControl"/> + /// </summary> + public int VirtualControlCount => Items?.Count ?? 0; + + int ScrollValue => Math.Max(vScrollBar1.Value, 0); + /// <summary> + /// Amount the control moves with a small scroll change + /// </summary> + private int SmallScrollChange => VirtualControlHeight * SMALL_SCROLL_CHANGE_MULTIPLE; + /// <summary> + /// Amount the control moves with a large scroll change. Equal to the number of whole <see cref="ProcessBookControl"/>s in the panel, less 1. + /// </summary> + private int LargeScrollChange => Math.Max(DisplayHeight / VirtualControlHeight - 1, SMALL_SCROLL_CHANGE_MULTIPLE) * VirtualControlHeight; + /// <summary> + /// Virtual height of all virtual controls within this <see cref="VirtualFlowControl"/> + /// </summary> + private int VirtualHeight => (VirtualControlCount + NUM_BLANK_SPACES_AT_BOTTOM) * VirtualControlHeight - DisplayHeight + 2 * TopMargin; + /// <summary> + /// Index of the first virtual <see cref="ProcessBookControl"/> + /// </summary> + private int FirstVisibleVirtualIndex => ScrollValue / VirtualControlHeight; + /// <summary> + /// The display height of this <see cref="VirtualFlowControl"/> + /// </summary> + private int DisplayHeight => DisplayRectangle.Height; + + #endregion + + #region Instance variables + + /// <summary> + /// The total height, including margins, of the repeated <see cref="ProcessBookControl"/> + /// </summary> + private readonly int VirtualControlHeight; + /// <summary> + /// Margin between the top <see cref="ProcessBookControl"/> and the top of the Panel, and the bottom <see cref="ProcessBookControl"/> and the bottom of the panel + /// </summary> + private readonly int TopMargin; + + private readonly List<ProcessBookControl> BookControls = new(); + + #endregion + + #region Global behavior settings + + /// <summary> + /// Total number of actual controls added to the panel. 23 is sufficient up to a 4k monitor height. + /// </summary> + private const int NUM_ACTUAL_CONTROLS = 23; + /// <summary> + /// Multiple of <see cref="VirtualControlHeight"/> that is moved for each small scroll change + /// </summary> + private const int SMALL_SCROLL_CHANGE_MULTIPLE = 1; + /// <summary> + /// Amount of space at the bottom of the <see cref="VirtualFlowControl"/>, in multiples of <see cref="VirtualControlHeight"/> + /// </summary> + private const int NUM_BLANK_SPACES_AT_BOTTOM = 2; + + #endregion + + public VirtualFlowControl() + { + InitializeComponent(); + + panel1.Resize += (_, _) => RefreshDisplay(); + + var control = InitControl(0); + VirtualControlHeight = this.DpiUnscale(control.Height + control.Margin.Top + control.Margin.Bottom); + TopMargin = control.Margin.Top; + + BookControls.Add(control); + panel1.Controls.Add(control); + + for (int i = 1; i < NUM_ACTUAL_CONTROLS; i++) + { + control = InitControl(VirtualControlHeight * i); + BookControls.Add(control); + panel1.Controls.Add(control); + } + + vScrollBar1.Scroll += (_, s) => SetScrollPosition(s.NewValue); + vScrollBar1.SmallChange = SmallScrollChange; + panel1.Height += this.DpiScale(NUM_BLANK_SPACES_AT_BOTTOM * VirtualControlHeight); + } + + private ProcessBookControl InitControl(int locationY) + { + var control = new ProcessBookControl(); + control.Location = new Point(control.Margin.Left, locationY + control.Margin.Top); + control.Width = panel1.ClientRectangle.Width - control.Margin.Left - control.Margin.Right; + control.Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top; + + control.cancelBtn.Click += ControlButton_Click; + control.moveFirstBtn.Click += ControlButton_Click; + control.moveUpBtn.Click += ControlButton_Click; + control.moveDownBtn.Click += ControlButton_Click; + control.moveLastBtn.Click += ControlButton_Click; + return control; + } + + /// <summary> + /// Handles all button clicks from all <see cref="ProcessBookControl"/>, detects which one sent the click, and fires <see cref="ButtonClicked"/> to notify the model of the click + /// </summary> + private void ControlButton_Click(object? sender, EventArgs e) + { + Control? button = sender as Control; + Control? form = button?.Parent; + while (form is not null and not ProcessBookControl) + form = form?.Parent; + + if (form is not null && button?.Name is string buttonText) + ButtonClicked?.Invoke(form, buttonText); + } + + /// <summary> + /// Adjusts the <see cref="vScrollBar1"/> max width and enabled status based on the <see cref="VirtualControlCount"/> and the <see cref="DisplayHeight"/> + /// </summary> + private void AdjustScrollBar() + { + int maxFullVisible = DisplayHeight / VirtualControlHeight; + + if (VirtualControlCount <= maxFullVisible) + { + vScrollBar1.Enabled = false; + vScrollBar1.Value = 0; + + for (int i = VirtualControlCount; i < NUM_ACTUAL_CONTROLS; i++) + BookControls[i].Visible = false; + } + else + { + vScrollBar1.Enabled = true; + + //https://stackoverflow.com/a/2882878/3335599 + int newMaximum = VirtualHeight + LargeScrollChange - 1; + if (newMaximum < vScrollBar1.Maximum) + vScrollBar1.Value = Math.Max(vScrollBar1.Value - (vScrollBar1.Maximum - newMaximum), 0); + vScrollBar1.Maximum = newMaximum; + vScrollBar1.LargeChange = LargeScrollChange; + } + } + + /// <summary> + /// Scrolls the specified item into view. + /// </summary> + /// <param name="index">The index of the item.</param> + public void ScrollIntoView(int index) + { + if (index < 0 || index >= VirtualControlCount) + throw new ArgumentOutOfRangeException(nameof(index)); + int firstVisible = FirstVisibleVirtualIndex; + int lastVisible = firstVisible + (DisplayHeight / VirtualControlHeight) - 1; + if (index < firstVisible) + { + SetScrollPosition(index * VirtualControlHeight); + } + else if (index > lastVisible) + { + int newScrollPos = (index - (lastVisible - firstVisible)) * VirtualControlHeight; + SetScrollPosition(newScrollPos); + } + } + + /// <summary> + /// Calculated the virtual controls that are in view at the current scroll position and windows size, + /// positions <see cref="panel1"/> to simulate scroll activity, then fires updates the controls with + /// the context corresponding to the virtual scroll position + /// </summary> + private void DoVirtualScroll() + { + int firstVisible = FirstVisibleVirtualIndex; + + int position = ScrollValue % VirtualControlHeight; + panel1.Location = new Point(0, -position); + + int numVisible = DisplayHeight / VirtualControlHeight; + + if (DisplayHeight % VirtualControlHeight != 0) + numVisible++; + + numVisible = Math.Min(numVisible, VirtualControlCount); + numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible); + + if (Items is IList items) + { + for (int i = 0; i < numVisible; i++) + BookControls[i].DataContext = items[firstVisible + i]; + } + + for (int i = 0; i < BookControls.Count; i++) + { + BookControls[i].Visible = i < numVisible; + } + + FirstRealizedIndex = firstVisible; + LastRealizedIndex = firstVisible + numVisible - 1; + } + + /// <summary> + /// Set scroll value to an integral multiple of <see cref="SmallScrollChange"/> + /// </summary> + private void SetScrollPosition(int value) + { + if (!vScrollBar1.Enabled) return; + + int newPos = (int)Math.Round((double)value / SmallScrollChange) * SmallScrollChange; + if (vScrollBar1.Value != newPos) + { + //https://stackoverflow.com/a/2882878/3335599 + vScrollBar1.Value = Math.Min(newPos, vScrollBar1.Maximum - vScrollBar1.LargeChange + 1); + DoVirtualScroll(); + } + } + + + private const int WM_MOUSEWHEEL = 522; + private const int WHEEL_DELTA = 120; + protected override void WndProc(ref Message m) + { + //Capture mouse wheel movement and interpret it as a scroll event + if (m.Msg == WM_MOUSEWHEEL) + { + //https://docs.microsoft.com/en-us/windows/win32/inputdev/wm-mousewheel + int wheelDelta = -(short)(((ulong)m.WParam) >> 16 & ushort.MaxValue); + + int numSmallPositionMoves = Math.Abs(wheelDelta) / WHEEL_DELTA; + + int scrollDelta = Math.Sign(wheelDelta) * numSmallPositionMoves * SmallScrollChange; + + int newScrollPosition; + + if (scrollDelta > 0) + newScrollPosition = Math.Min(vScrollBar1.Value + scrollDelta, vScrollBar1.Maximum); + else + newScrollPosition = Math.Max(vScrollBar1.Value + scrollDelta, vScrollBar1.Minimum); + + SetScrollPosition(newScrollPosition); + } + + base.WndProc(ref m); + } } diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 7cd67d18..fddd2bb3 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -10,184 +10,183 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +static class Program { - static class Program + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + static extern bool AllocConsole(); + + private static Form1? form1; + + [STAThread] + static void Main() { - [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] - [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] - static extern bool AllocConsole(); + Task<List<LibraryBook>> libraryLoadTask; - private static Form1 form1; - - [STAThread] - static void Main() + try { - Task<List<LibraryBook>> libraryLoadTask; + //// Uncomment to see Console. Must be called before anything writes to Console. + //// Only use while debugging. Acts erratically in the wild + //AllocConsole(); + // run as early as possible. see notes in postLoggingGlobalExceptionHandling + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + + ApplicationConfiguration.Initialize(); + + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + // Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration + var config = AppScaffolding.LibationScaffolding.RunPreConfigMigrations(); + LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = ShowMessageBox; + + // do this as soon as possible (post-config) + RunSetupIfNeededAsync(config); + + // most migrations go in here + LibationScaffolding.RunPostConfigMigrations(config); + SetThemeColor(config); + + // migrations which require Forms or are long-running + RunWindowsOnlyMigrations(config); + + //*******************************************************************// + // // + // Start loading the library as soon as possible // + // // + // Before calling anything else, including subscribing to events, // + // to ensure database exists. If we wait and let it happen lazily, // + // race conditions and errors are likely during new installs // + // // + //*******************************************************************// + libraryLoadTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + + MessageBoxLib.VerboseLoggingWarning_ShowIfTrue(); + + // logging is init'd here + LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config); + } + catch (Exception ex) + { + var title = "Fatal error, pre-logging"; + var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; try { - //// Uncomment to see Console. Must be called before anything writes to Console. - //// Only use while debugging. Acts erratically in the wild - //AllocConsole(); - - // run as early as possible. see notes in postLoggingGlobalExceptionHandling - Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); - - ApplicationConfiguration.Initialize(); - - //***********************************************// - // // - // do not use Configuration before this line // - // // - //***********************************************// - // Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration - var config = AppScaffolding.LibationScaffolding.RunPreConfigMigrations(); - LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = ShowMessageBox; - - // do this as soon as possible (post-config) - RunSetupIfNeededAsync(config); - - // most migrations go in here - LibationScaffolding.RunPostConfigMigrations(config); - SetThemeColor(config); - - // migrations which require Forms or are long-running - RunWindowsOnlyMigrations(config); - - //*******************************************************************// - // // - // Start loading the library as soon as possible // - // // - // Before calling anything else, including subscribing to events, // - // to ensure database exists. If we wait and let it happen lazily, // - // race conditions and errors are likely during new installs // - // // - //*******************************************************************// - libraryLoadTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - - MessageBoxLib.VerboseLoggingWarning_ShowIfTrue(); - - // logging is init'd here - LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config); + MessageBoxLib.ShowAdminAlert(null, body, title, ex); } - catch (Exception ex) + catch { - var title = "Fatal error, pre-logging"; - var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; - try - { - MessageBoxLib.ShowAdminAlert(null, body, title, ex); - } - catch - { - MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); - } - return; + MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); } - - // global exception handling (ShowAdminAlert) attempts to use logging. only call it after logging has been init'd - postLoggingGlobalExceptionHandling(); - - form1 = new Form1(); - form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask); - Application.Run(form1); + return; } - #region Message Box Handler for LibationUiBase - static Task<LibationUiBase.Forms.DialogResult> ShowMessageBox( - object owner, - string message, - string caption, - LibationUiBase.Forms.MessageBoxButtons buttons, - LibationUiBase.Forms.MessageBoxIcon icon, - LibationUiBase.Forms.MessageBoxDefaultButton defaultButton, - bool _) + // global exception handling (ShowAdminAlert) attempts to use logging. only call it after logging has been init'd + postLoggingGlobalExceptionHandling(); + + form1 = new Form1(); + form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask); + Application.Run(form1); + } + + #region Message Box Handler for LibationUiBase + static Task<LibationUiBase.Forms.DialogResult> ShowMessageBox( + object? owner, + string message, + string caption, + LibationUiBase.Forms.MessageBoxButtons buttons, + LibationUiBase.Forms.MessageBoxIcon icon, + LibationUiBase.Forms.MessageBoxDefaultButton defaultButton, + bool _) + { + Func<DialogResult> showMessageBox = () => MessageBox.Show( + owner as IWin32Window ?? form1, + message, + caption, + (MessageBoxButtons)buttons, + (MessageBoxIcon)icon, + (MessageBoxDefaultButton)defaultButton); + + + var result = form1 is null ? showMessageBox() : form1.Invoke(showMessageBox); + return Task.FromResult((LibationUiBase.Forms.DialogResult)result); + } + #endregion; + + private static void SetThemeColor(Configuration config) + { + var theme = config.ThemeVariant switch { - Func<DialogResult> showMessageBox = () => MessageBox.Show( - owner as IWin32Window ?? form1, - message, - caption, - (MessageBoxButtons)buttons, - (MessageBoxIcon)icon, - (MessageBoxDefaultButton)defaultButton); + Configuration.Theme.Light => SystemColorMode.Classic, + Configuration.Theme.Dark => SystemColorMode.Dark, + _ => SystemColorMode.System, + }; + Application.SetColorMode(theme); + } - var result = form1 is null ? showMessageBox() : form1.Invoke(showMessageBox); - return Task.FromResult((LibationUiBase.Forms.DialogResult)result); - } - #endregion; - - private static void SetThemeColor(Configuration config) + private static void RunSetupIfNeededAsync(Configuration config) + { + var setup = new LibationSetup(config.LibationFiles) { - var theme = config.ThemeVariant switch - { - Configuration.Theme.Light => SystemColorMode.Classic, - Configuration.Theme.Dark => SystemColorMode.Dark, - _ => SystemColorMode.System, - }; + SetupPrompt = ShowSetup, + SelectFolderPrompt = SelectInstallLocation + }; - Application.SetColorMode(theme); + if (!setup.RunSetupIfNeededAsync().GetAwaiter().GetResult()) + { + MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + Application.Exit(); + Environment.Exit(-1); } - private static void RunSetupIfNeededAsync(Configuration config) + static ILibationSetup ShowSetup() { - var setup = new LibationSetup(config.LibationFiles) - { - SetupPrompt = ShowSetup, - SelectFolderPrompt = SelectInstallLocation - }; - - if (!setup.RunSetupIfNeededAsync().GetAwaiter().GetResult()) - { - MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - Application.Exit(); - Environment.Exit(-1); - } - - static ILibationSetup ShowSetup() - { - var setupDialog = new SetupDialog(); - setupDialog.ShowDialog(); - return setupDialog; - } - - static ILibationInstallLocation SelectInstallLocation() - { - var libationFilesDialog = new LibationFilesDialog(); - return libationFilesDialog.ShowDialog() is DialogResult.OK ? libationFilesDialog : null; - } + var setupDialog = new SetupDialog(); + setupDialog.ShowDialog(); + return setupDialog; } - /// <summary>migrations which require Forms or are long-running</summary> - private static void RunWindowsOnlyMigrations(Configuration config) + static ILibationInstallLocation? SelectInstallLocation() { - // examples: - // - only supported in winforms. don't move to app scaffolding - // - long running. won't get a chance to finish in cli. don't move to app scaffolding - - const string hasMigratedKey = "hasMigratedToHighDPI"; - if (!config.GetNonString(defaultValue: false, hasMigratedKey)) - { - config.RemoveProperty(nameof(config.GridColumnsWidths)); - - foreach (var form in typeof(Program).Assembly.GetTypes().Where(t => t.IsSubclassOf(typeof(Form)))) - config.RemoveProperty(form.Name); - - config.SetNonString(true, hasMigratedKey); - } - } - - private static void postLoggingGlobalExceptionHandling() - { - // this line is all that's needed for strict handling - AppDomain.CurrentDomain.UnhandledException += (_, e) => MessageBoxLib.ShowAdminAlert(null, "Libation has crashed due to an unhandled error.", "Application crash!", (Exception)e.ExceptionObject); - - // these 2 lines makes it graceful. sync (eg in main form's ctor) and thread exceptions will still crash us, but event (sync, void async, Task async) will not - Application.ThreadException += (_, e) => MessageBoxLib.ShowAdminAlert(null, "Libation has encountered an unexpected error.", "Unexpected error", e.Exception); - // move to beginning of execution. crashes app if this is called post-RunInstaller: System.InvalidOperationException: 'Thread exception mode cannot be changed once any Controls are created on the thread.' - //// I never found a case where including made a difference. I think this enum is default and including it will override app user config file - //Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + var libationFilesDialog = new LibationFilesDialog(); + return libationFilesDialog.ShowDialog() is DialogResult.OK ? libationFilesDialog : null; } } + + /// <summary>migrations which require Forms or are long-running</summary> + private static void RunWindowsOnlyMigrations(Configuration config) + { + // examples: + // - only supported in winforms. don't move to app scaffolding + // - long running. won't get a chance to finish in cli. don't move to app scaffolding + + const string hasMigratedKey = "hasMigratedToHighDPI"; + if (!config.GetNonString(defaultValue: false, hasMigratedKey)) + { + config.RemoveProperty(nameof(config.GridColumnsWidths)); + + foreach (var form in typeof(Program).Assembly.GetTypes().Where(t => t.IsSubclassOf(typeof(Form)))) + config.RemoveProperty(form.Name); + + config.SetNonString(true, hasMigratedKey); + } + } + + private static void postLoggingGlobalExceptionHandling() + { + // this line is all that's needed for strict handling + AppDomain.CurrentDomain.UnhandledException += (_, e) => MessageBoxLib.ShowAdminAlert(null, "Libation has crashed due to an unhandled error.", "Application crash!", (Exception)e.ExceptionObject); + + // these 2 lines makes it graceful. sync (eg in main form's ctor) and thread exceptions will still crash us, but event (sync, void async, Task async) will not + Application.ThreadException += (_, e) => MessageBoxLib.ShowAdminAlert(null, "Libation has encountered an unexpected error.", "Unexpected error", e.Exception); + // move to beginning of execution. crashes app if this is called post-RunInstaller: System.InvalidOperationException: 'Thread exception mode cannot be changed once any Controls are created on the thread.' + //// I never found a case where including made a difference. I think this enum is default and including it will override app user config file + //Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + } } \ No newline at end of file diff --git a/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs b/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs index 31a88ffa..9fe6e8db 100644 --- a/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs +++ b/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs @@ -3,50 +3,49 @@ using System.Windows.Forms; using System.Windows.Forms.VisualStyles; using LibationUiBase.SeriesView; -namespace LibationWinForms.SeriesView +namespace LibationWinForms.SeriesView; + +public class DownloadButtonColumn : DataGridViewButtonColumn { - public class DownloadButtonColumn : DataGridViewButtonColumn + public DownloadButtonColumn() { - public DownloadButtonColumn() - { - CellTemplate = new DownloadButtonColumnCell(); - CellTemplate.Style.WrapMode = DataGridViewTriState.True; - } - } - internal class DownloadButtonColumnCell : AccessibleDataGridViewButtonCell - { - public DownloadButtonColumnCell() : base("Download Series button") { } - - protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) - { - if (value is not SeriesButton seriesEntry) - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); - return; - } - - string cellValue = seriesEntry.DisplayText; - AccessibilityDescription = cellValue; - - if (!seriesEntry.Enabled) - { - //Draw disabled button - Rectangle buttonArea = cellBounds; - Rectangle buttonAdjustment = BorderWidths(advancedBorderStyle); - buttonArea.X += buttonAdjustment.X; - buttonArea.Y += buttonAdjustment.Y; - buttonArea.Height -= buttonAdjustment.Height; - buttonArea.Width -= buttonAdjustment.Width; - - ButtonRenderer.DrawButton(graphics, buttonArea, cellValue, cellStyle.Font, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak, focused: false, PushButtonState.Disabled); - } - else if (seriesEntry.HasButtonAction) - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, cellValue, cellValue, errorText, cellStyle, advancedBorderStyle, paintParts); - else - { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); - TextRenderer.DrawText(graphics, cellValue, cellStyle.Font, cellBounds, cellStyle.ForeColor); - } - } + CellTemplate = new DownloadButtonColumnCell(); + CellTemplate.Style.WrapMode = DataGridViewTriState.True; + } +} +internal class DownloadButtonColumnCell : AccessibleDataGridViewButtonCell +{ + public DownloadButtonColumnCell() : base("Download Series button") { } + + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + { + if (value is not SeriesButton seriesEntry) + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + return; + } + + string cellValue = seriesEntry.DisplayText; + AccessibilityDescription = cellValue; + + if (!seriesEntry.Enabled) + { + //Draw disabled button + Rectangle buttonArea = cellBounds; + Rectangle buttonAdjustment = BorderWidths(advancedBorderStyle); + buttonArea.X += buttonAdjustment.X; + buttonArea.Y += buttonAdjustment.Y; + buttonArea.Height -= buttonAdjustment.Height; + buttonArea.Width -= buttonAdjustment.Width; + + ButtonRenderer.DrawButton(graphics, buttonArea, cellValue, cellStyle.Font, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak, focused: false, PushButtonState.Disabled); + } + else if (seriesEntry.HasButtonAction) + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, cellValue, cellValue, errorText, cellStyle, advancedBorderStyle, paintParts); + else + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + TextRenderer.DrawText(graphics, cellValue, cellStyle.Font, cellBounds, cellStyle.ForeColor); + } } } diff --git a/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs b/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs index 5a03b0d9..dae2750b 100644 --- a/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs +++ b/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs @@ -10,187 +10,194 @@ using LibationFileManager; using LibationUiBase.SeriesView; using System.Drawing; -namespace LibationWinForms.SeriesView +namespace LibationWinForms.SeriesView; + +public partial class SeriesViewDialog : Form { - public partial class SeriesViewDialog : Form + private readonly LibraryBook? LibraryBook; + + public SeriesViewDialog() { - private readonly LibraryBook LibraryBook; + InitializeComponent(); + this.RestoreSizeAndLocation(Configuration.Instance); + this.SetLibationIcon(); - public SeriesViewDialog() + Load += SeriesViewDialog_Load; + FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); + } + + public SeriesViewDialog(LibraryBook libraryBook) : this() + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook"); + } + + private async void SeriesViewDialog_Load(object? sender, EventArgs e) + { + if (LibraryBook is null) + return; + try { - InitializeComponent(); - this.RestoreSizeAndLocation(Configuration.Instance); - this.SetLibationIcon(); + var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook); - Load += SeriesViewDialog_Load; - FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); - } - - public SeriesViewDialog(LibraryBook libraryBook) : this() - { - LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook"); - } - - private async void SeriesViewDialog_Load(object sender, EventArgs e) - { - try + //Create a DataGridView for each series and add all children of that series to it. + foreach (var series in seriesEntries.Keys) { - var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook); + var dgv = createNewSeriesGrid(); + DataGridViewColumn orderColumn = dgv.Columns["Order"] ?? throw new InvalidOperationException("Order column not found"); + dgv.CellContentClick += Dgv_CellContentClick; + dgv.DataSource = new SortBindingList<SeriesItem>(seriesEntries[series]); + dgv.BindingContextChanged += (_, _) => dgv.Sort(orderColumn, ListSortDirection.Ascending); + dgv.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - //Create a DataGridView for each series and add all children of that series to it. - foreach (var series in seriesEntries.Keys) - { - var dgv = createNewSeriesGrid(); - dgv.CellContentClick += Dgv_CellContentClick; - dgv.DataSource = new SortBindingList<SeriesItem>(seriesEntries[series]); - dgv.BindingContextChanged += (_, _) => dgv.Sort(dgv.Columns["Order"], ListSortDirection.Ascending); - dgv.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - - var tab = new TabPage { Text = series.Title }; - tab.Controls.Add(dgv); - tab.VisibleChanged += (_, _) => dgv.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells); - tabControl1.Controls.Add(tab); - } - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error loading searies info"); - - var tab = new TabPage { Text = "ERROR" }; - tab.Controls.Add(new Label { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, ForeColor = Color.Red, Dock = DockStyle.Fill }); + var tab = new TabPage { Text = series.Title }; + tab.Controls.Add(dgv); + tab.VisibleChanged += (_, _) => dgv.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells); tabControl1.Controls.Add(tab); } } - - private ImageDisplay imageDisplay; - - private async void Dgv_CellContentClick(object sender, DataGridViewCellEventArgs e) + catch (Exception ex) { - if (e.RowIndex < 0) return; + Serilog.Log.Logger.Error(ex, "Error loading series info"); - var dgv = (DataGridView)sender; - var sentry = dgv.GetBoundItem<SeriesItem>(e.RowIndex); - - if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Cover)) - { - coverClicked(sentry.Item); - return; - } - else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Title)) - { - sentry.ViewOnAudible(LibraryBook.Book.Locale); - return; - } - else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Button) && sentry.Button.HasButtonAction) - { - await sentry.Button.PerformClickAsync(LibraryBook); - } - } - - private void coverClicked(Item libraryBook) - { - var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.PictureId, PictureSize.Native); - - void PictureCached(object sender, PictureCachedEventArgs e) - { - if (e.Definition.PictureId == picDef.PictureId) - imageDisplay.SetCoverArt(e.Picture); - - PictureStorage.PictureCached -= PictureCached; - } - - PictureStorage.PictureCached += PictureCached; - (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); - - var windowTitle = $"{libraryBook.Title} - Cover"; - - if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) - { - imageDisplay = new ImageDisplay(); - imageDisplay.RestoreSizeAndLocation(Configuration.Instance); - imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); - } - - imageDisplay.Text = windowTitle; - imageDisplay.SetCoverArt(initialImageBts); - if (!isDefault) - PictureStorage.PictureCached -= PictureCached; - - if (!imageDisplay.Visible) - imageDisplay.Show(); - } - - private static DataGridView createNewSeriesGrid() - { - var dgv = new DataGridView - { - Dock = DockStyle.Fill, - RowHeadersVisible = false, - ReadOnly = false, - ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize, - AllowUserToAddRows = false, - AllowUserToDeleteRows = false, - AllowUserToResizeRows = false, - AutoGenerateColumns = false - }; - - dgv.RowTemplate.Height = 80; - - dgv.Columns.Add(new DataGridViewImageColumn - { - DataPropertyName = nameof(SeriesItem.Cover), - HeaderText = "Cover", - Name = "Cover", - ReadOnly = true, - Resizable = DataGridViewTriState.False, - Width = 80 - }); - dgv.Columns.Add(new DataGridViewTextBoxColumn - { - DataPropertyName = nameof(SeriesItem.Order), - HeaderText = "Series\r\nOrder", - Name = "Order", - ReadOnly = true, - SortMode = DataGridViewColumnSortMode.Automatic, - Width = 50 - }); - dgv.Columns.Add(new DownloadButtonColumn - { - DataPropertyName = nameof(SeriesItem.Button), - HeaderText = "Availability", - Name = "DownloadButton", - ReadOnly = true, - SortMode = DataGridViewColumnSortMode.Automatic, - Width = 50 - }); - dgv.Columns.Add(new DataGridViewLinkColumn - { - DataPropertyName = nameof(SeriesItem.Title), - HeaderText = "Title", - Name = "Title", - ReadOnly = true, - TrackVisitedState = true, - SortMode = DataGridViewColumnSortMode.Automatic, - Width = 200, - LinkColor = ThemeExtensions.LinkColor, - VisitedLinkColor = ThemeExtensions.VisitedLinkColor, - }); - - dgv.CellToolTipTextNeeded += Dgv_CellToolTipTextNeeded; - - return dgv; - } - - private static void Dgv_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) - { - if (sender is not DataGridView dgv || e.ColumnIndex < 0) return; - - e.ToolTipText = dgv.Columns[e.ColumnIndex].DataPropertyName switch - { - nameof(SeriesItem.Cover) => "Click to see full size", - nameof(SeriesItem.Title) => "Open Audible product page", - _ => string.Empty - }; + var tab = new TabPage { Text = "ERROR" }; + tab.Controls.Add(new Label { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, ForeColor = Color.Red, Dock = DockStyle.Fill }); + tabControl1.Controls.Add(tab); } } + + private ImageDisplay? imageDisplay; + + private async void Dgv_CellContentClick(object? sender, DataGridViewCellEventArgs e) + { + if (LibraryBook is null || e.RowIndex < 0 || sender is not DataGridView dgv) return; + + var sentry = dgv.GetBoundItem<SeriesItem>(e.RowIndex); + + if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Cover)) + { + coverClicked(sentry.Item); + return; + } + else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Title)) + { + sentry.ViewOnAudible(LibraryBook.Book.Locale); + return; + } + else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Button) && sentry.Button.HasButtonAction) + { + await sentry.Button.PerformClickAsync(LibraryBook); + } + } + + private void coverClicked(Item libraryBook) + { + var pictureId = libraryBook.PictureLarge ?? libraryBook.PictureId; + if (string.IsNullOrEmpty(pictureId)) + { + MessageBox.Show(this, "No cover art is available for this book.", "No Cover Art", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var picDef = new PictureDefinition(pictureId, PictureSize.Native); + void PictureCached(object? sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == picDef.PictureId) + imageDisplay?.SetCoverArt(e.Picture); + + PictureStorage.PictureCached -= PictureCached; + } + + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); + + var windowTitle = $"{libraryBook.Title} - Cover"; + + if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) + { + imageDisplay = new ImageDisplay(); + imageDisplay.RestoreSizeAndLocation(Configuration.Instance); + imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); + } + + imageDisplay.Text = windowTitle; + imageDisplay.SetCoverArt(initialImageBts); + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplay.Visible) + imageDisplay.Show(); + } + + private static DataGridView createNewSeriesGrid() + { + var dgv = new DataGridView + { + Dock = DockStyle.Fill, + RowHeadersVisible = false, + ReadOnly = false, + ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, + AutoGenerateColumns = false + }; + + dgv.RowTemplate.Height = 80; + + dgv.Columns.Add(new DataGridViewImageColumn + { + DataPropertyName = nameof(SeriesItem.Cover), + HeaderText = "Cover", + Name = "Cover", + ReadOnly = true, + Resizable = DataGridViewTriState.False, + Width = 80 + }); + dgv.Columns.Add(new DataGridViewTextBoxColumn + { + DataPropertyName = nameof(SeriesItem.Order), + HeaderText = "Series\r\nOrder", + Name = "Order", + ReadOnly = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 50 + }); + dgv.Columns.Add(new DownloadButtonColumn + { + DataPropertyName = nameof(SeriesItem.Button), + HeaderText = "Availability", + Name = "DownloadButton", + ReadOnly = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 50 + }); + dgv.Columns.Add(new DataGridViewLinkColumn + { + DataPropertyName = nameof(SeriesItem.Title), + HeaderText = "Title", + Name = "Title", + ReadOnly = true, + TrackVisitedState = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 200, + LinkColor = ThemeExtensions.LinkColor, + VisitedLinkColor = ThemeExtensions.VisitedLinkColor, + }); + + dgv.CellToolTipTextNeeded += Dgv_CellToolTipTextNeeded; + + return dgv; + } + + private static void Dgv_CellToolTipTextNeeded(object? sender, DataGridViewCellToolTipTextNeededEventArgs e) + { + if (sender is not DataGridView dgv || e.ColumnIndex < 0) return; + + e.ToolTipText = dgv.Columns[e.ColumnIndex].DataPropertyName switch + { + nameof(SeriesItem.Cover) => "Click to see full size", + nameof(SeriesItem.Title) => "Open Audible product page", + _ => string.Empty + }; + } } diff --git a/Source/LibationWinForms/SortBindingList.cs b/Source/LibationWinForms/SortBindingList.cs index d4f69b3f..37a467ea 100644 --- a/Source/LibationWinForms/SortBindingList.cs +++ b/Source/LibationWinForms/SortBindingList.cs @@ -10,11 +10,11 @@ namespace LibationWinForms; /// </summary> internal class SortBindingList<TItem> : BindingList<TItem> { - private PropertyDescriptor _propertyDescriptor; + private PropertyDescriptor? _propertyDescriptor; private ListSortDirection _listSortDirection; private bool _isSortedCore; - protected override PropertyDescriptor SortPropertyCore => _propertyDescriptor; + protected override PropertyDescriptor? SortPropertyCore => _propertyDescriptor; protected override ListSortDirection SortDirectionCore => _listSortDirection; protected override bool IsSortedCore => _isSortedCore; protected override bool SupportsSortingCore => true; diff --git a/Source/LibationWinForms/ThemeExtensions.cs b/Source/LibationWinForms/ThemeExtensions.cs index ee61d126..f0db1c82 100644 --- a/Source/LibationWinForms/ThemeExtensions.cs +++ b/Source/LibationWinForms/ThemeExtensions.cs @@ -28,6 +28,11 @@ internal static class ThemeExtensions } } + public static Rectangle GetPrimaryScreenWorkingArea() + => Screen.PrimaryScreen is not null ? Screen.PrimaryScreen.WorkingArea + : Screen.AllScreens.Length > 0 ? Screen.AllScreens[0].WorkingArea + : default; + extension(ProcessBookStatus status) { public Color GetColor() => status switch diff --git a/Source/LibationWinForms/Walkthrough.cs b/Source/LibationWinForms/Walkthrough.cs index a9fb5c51..e9056ca8 100644 --- a/Source/LibationWinForms/Walkthrough.cs +++ b/Source/LibationWinForms/Walkthrough.cs @@ -11,261 +11,261 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -namespace LibationWinForms +namespace LibationWinForms; + +internal class Walkthrough { - internal class Walkthrough + private Dictionary<string, string> settingTabMessages = new() { - private Dictionary<string, string> settingTabMessages = new() - { - { "Important settings", "From here you can change where liberated books are stored and how detailed Libation's logs are.\r\n\r\nIf you experience a problem and need help, you'll be asked to provide your log file. In certain circumstances we may need you to reproduce the error with a higher level of logging detail."}, - { "Import library", "In this tab you can change how your library is scanned and imported into Libation, as well as automatic liberation."}, - { "Download/Decrypt", "These settings allow you to control how liberated files and folders are named and stored.\r\nYou can customize the 'Naming Templates' to use any number of the audiobook's properties to build a customized file and folder naming format. Learn more about the syntax from the wiki at\r\n\r\nhttps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md"}, - { "Audio File Options", "Control how audio files are decrypted, including audio format and metadata handling.\r\n\r\nIf you choose to split your audiobook into multiple files by chapter marker, you may edit the chapter file 'Naming Template' to control how each chapter file is named."}, - }; + { "Important settings", "From here you can change where liberated books are stored and how detailed Libation's logs are.\r\n\r\nIf you experience a problem and need help, you'll be asked to provide your log file. In certain circumstances we may need you to reproduce the error with a higher level of logging detail."}, + { "Import library", "In this tab you can change how your library is scanned and imported into Libation, as well as automatic liberation."}, + { "Download/Decrypt", "These settings allow you to control how liberated files and folders are named and stored.\r\nYou can customize the 'Naming Templates' to use any number of the audiobook's properties to build a customized file and folder naming format. Learn more about the syntax from the wiki at\r\n\r\nhttps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md"}, + { "Audio File Options", "Control how audio files are decrypted, including audio format and metadata handling.\r\n\r\nIf you choose to split your audiobook into multiple files by chapter marker, you may edit the chapter file 'Naming Template' to control how each chapter file is named."}, + }; - private static readonly Color FlashColor = Color.DodgerBlue; - private readonly Form1 MainForm; - private readonly AsyncStepSequence sequence = new(); - private readonly bool AutoScan; - public Walkthrough(Form1 form1) - { - AutoScan = Configuration.Instance.AutoScan; - Configuration.Instance.AutoScan = false; - MainForm = form1; - sequence[nameof(ShowAccountDialog)] = ShowAccountDialog; - sequence[nameof(ShowSettingsDialog)] = ShowSettingsDialog; - sequence[nameof(ShowAccountScanning)] = ShowAccountScanning; - sequence[nameof(ShowSearching)] = ShowSearching; - sequence[nameof(ShowQuickFilters)] = ShowQuickFilters; - sequence[nameof(ShowTourComplete)] = ShowTourComplete; - } - - public async Task RunAsync() - { - await sequence.RunAsync(); - Configuration.Instance.AutoScan = AutoScan; - } - - private async Task<bool> ShowAccountDialog() - { - if (!ProceedMessageBox("First, add your Audible account(s).", "Add Accounts")) - return false; - - await Task.Delay(750); - await displayControlAsync(MainForm.settingsToolStripMenuItem); - await displayControlAsync(MainForm.accountsToolStripMenuItem); - - using var accountSettings = MainForm.Invoke(() => new AccountsDialog()); - accountSettings.StartPosition = FormStartPosition.CenterParent; - accountSettings.Shown += (_, _) => MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account"); - MainForm.Invoke(() => accountSettings.ShowDialog(MainForm)); - return true; - } - - private async Task<bool> ShowSettingsDialog() - { - if (!ProceedMessageBox("Next, adjust Libation's settings", "Change Settings")) - return false; - - await Task.Delay(750); - await displayControlAsync(MainForm.settingsToolStripMenuItem); - await displayControlAsync(MainForm.basicSettingsToolStripMenuItem); - - using var settingsDialog = MainForm.Invoke(() => new SettingsDialog()); - - var tabsToVisit = settingsDialog.tabControl.TabPages.Cast<TabPage>().ToList(); - - settingsDialog.StartPosition = FormStartPosition.CenterParent; - settingsDialog.FormClosing += SettingsDialog_FormClosing; - settingsDialog.Shown += TabControl_TabIndexChanged; - settingsDialog.tabControl.SelectedIndexChanged += TabControl_TabIndexChanged; - settingsDialog.cancelBtn.Text = "Next Tab"; - settingsDialog.saveBtn.Visible = false; - - MainForm.Invoke(() => settingsDialog.ShowDialog(MainForm)); - - return true; - - void TabControl_TabIndexChanged(object sender, EventArgs e) - { - var selectedTab = settingsDialog.tabControl.SelectedTab; - - tabsToVisit.Remove(selectedTab); - - if (tabsToVisit.Count == 0) - { - settingsDialog.cancelBtn.Text = "Cancel"; - settingsDialog.saveBtn.Visible = true; - } - - if (!selectedTab.Visible || !settingTabMessages.ContainsKey(selectedTab.Text)) return; - - MessageBox.Show(selectedTab, settingTabMessages[selectedTab.Text], selectedTab.Text + " Tab", MessageBoxButtons.OK); - - settingTabMessages.Remove(selectedTab.Text); - } - - void SettingsDialog_FormClosing(object sender, FormClosingEventArgs e) - { - if (tabsToVisit.Count > 0) - { - settingsDialog.tabControl.SelectedTab = tabsToVisit[0]; - e.Cancel = true; - } - } - } - - private async Task<bool> ShowAccountScanning() - { - var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var count = persister.AccountsSettings.Accounts.Count; - persister.Dispose(); - - if (count < 1) - { - MainForm.Invoke(() => MessageBox.Show(MainForm, "Add an Audible account, then sync your library through the 'Import' menu.", "Add an Audible Account", MessageBoxButtons.OK, MessageBoxIcon.Information)); - return true; - } - - var accounts = count > 1 ? "accounts" :"account"; - var library = count > 1 ? "libraries" : "library"; - if (!ProceedMessageBox($"Finally, scan your Audible {accounts} to sync your {library} with Libation.\r\n\r\nIf this is your first time scanning an account, you'll be prompted to enter your account's password to log into your Audible account.", $"Scan {accounts}")) - return false; - - var scanItem = count > 1 ? MainForm.scanLibraryOfAllAccountsToolStripMenuItem : MainForm.scanLibraryToolStripMenuItem; - - await Task.Delay(750); - await displayControlAsync(MainForm.importToolStripMenuItem); - await displayControlAsync(scanItem); - - MainForm.Invoke(scanItem.PerformClick); - - var tcs = new TaskCompletionSource(); - LibraryCommands.ScanEnd += LibraryCommands_ScanEnd; - await tcs.Task; - LibraryCommands.ScanEnd -= LibraryCommands_ScanEnd; - - return true; - - void LibraryCommands_ScanEnd(object _, int __) => tcs.SetResult(); - } - - private async Task<bool> ShowSearching() - { - var books = DbContexts.GetLibrary_Flat_NoTracking(); - if (books.Count == 0) return true; - - var firstAuthor = getFirstAuthor()?.SurroundWithQuotes(); - if (firstAuthor == null) return true; - - if (!ProceedMessageBox("You can filter the grid entries by searching", "Searching")) - return false; - - await displayControlAsync(MainForm.filterSearchTb); - - MainForm.Invoke(() => MainForm.filterSearchTb.Text = string.Empty); - foreach (var c in firstAuthor) - { - MainForm.Invoke(() => MainForm.filterSearchTb.Text += c); - await Task.Delay(150); - } - - await displayControlAsync(MainForm.filterBtn); - - MainForm.Invoke(MainForm.filterBtn.PerformClick); - - await Task.Delay(1000); - - MessageBox.Show(MainForm, "Libation provides a built-in cheat sheet for its query language", "Search Cheat Sheet"); - - await displayControlAsync(MainForm.filterHelpBtn); - - using var filterHelp = MainForm.Invoke(MainForm.ShowSearchSyntaxDialog); - var tcs = new TaskCompletionSource(); - filterHelp.FormClosed += (_, _) => tcs.SetResult(); - await tcs.Task; - return true; - } - - private async Task<bool> ShowQuickFilters() - { - var firstAuthor = getFirstAuthor()?.SurroundWithQuotes(); - if (firstAuthor == null) return true; - - if (!ProceedMessageBox("Queries that you perform regularly can be added to 'Quick Filters'", "Quick Filters")) - return false; - - MainForm.Invoke(() => MainForm.filterSearchTb.Text = firstAuthor); - - await Task.Delay(750); - await displayControlAsync(MainForm.addQuickFilterBtn); - MainForm.Invoke(MainForm.addQuickFilterBtn.PerformClick); - await displayControlAsync(MainForm.quickFiltersToolStripMenuItem); - await displayControlAsync(MainForm.editQuickFiltersToolStripMenuItem); - - var editQuickFilters = MainForm.Invoke(() => new EditQuickFilters()); - editQuickFilters.Shown += (_, _) => MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters"); - MainForm.Invoke(editQuickFilters.ShowDialog); - - return true; - } - - private Task<bool> ShowTourComplete() - { - MessageBox.Show(MainForm, "You're now ready to begin using Libation.\r\n\r\nEnjoy!", "Tour Finished"); - return Task.FromResult(true); - } - - private string getFirstAuthor() - { - var books = DbContexts.GetLibrary_Flat_NoTracking(); - return books.SelectMany(lb => lb.Book.Authors).FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Name))?.Name; - } - - private async Task displayControlAsync(ToolStripMenuItem menuItem) - { - MainForm.Invoke(() => menuItem.Enabled = false); - MainForm.Invoke(MainForm.productsDisplay.Focus); - await flashControlAsync(menuItem); - MainForm.Invoke(menuItem.ShowDropDown); - await Task.Delay(500); - MainForm.Invoke(() => menuItem.Enabled = true); - } - - private async Task displayControlAsync(Control button) - { - MainForm.Invoke(() => button.Enabled = false); - MainForm.Invoke(MainForm.productsDisplay.Focus); - await flashControlAsync(button); - await Task.Delay(500); - MainForm.Invoke(() => button.Enabled = true); - } - - private async Task flashControlAsync(Control control, int flashCount = 3) - { - var backColor = MainForm.Invoke(() => control.BackColor); - for (int i = 0; i < flashCount; i++) - { - MainForm.Invoke(() => control.BackColor = FlashColor); - await Task.Delay(200); - MainForm.Invoke(() => control.BackColor = backColor); - await Task.Delay(200); - } - } - - private async Task flashControlAsync(ToolStripItem control, int flashCount = 3) - { - var backColor = MainForm.Invoke(() => control.BackColor); - for (int i = 0; i < flashCount; i++) - { - MainForm.Invoke(() => control.BackColor = FlashColor); - await Task.Delay(200); - MainForm.Invoke(() => control.BackColor = backColor); - await Task.Delay(200); - } - } - - private bool ProceedMessageBox(string message, string caption) - => MainForm.Invoke(() => MessageBox.Show(MainForm, message, caption, MessageBoxButtons.OKCancel)) is DialogResult.OK; + private static readonly Color FlashColor = Color.DodgerBlue; + private readonly Form1 MainForm; + private readonly AsyncStepSequence sequence = new(); + private readonly bool AutoScan; + public Walkthrough(Form1 form1) + { + AutoScan = Configuration.Instance.AutoScan; + Configuration.Instance.AutoScan = false; + MainForm = form1; + sequence[nameof(ShowAccountDialog)] = ShowAccountDialog; + sequence[nameof(ShowSettingsDialog)] = ShowSettingsDialog; + sequence[nameof(ShowAccountScanning)] = ShowAccountScanning; + sequence[nameof(ShowSearching)] = ShowSearching; + sequence[nameof(ShowQuickFilters)] = ShowQuickFilters; + sequence[nameof(ShowTourComplete)] = ShowTourComplete; } + + public async Task RunAsync() + { + await sequence.RunAsync(); + Configuration.Instance.AutoScan = AutoScan; + } + + private async Task<bool> ShowAccountDialog() + { + if (!ProceedMessageBox("First, add your Audible account(s).", "Add Accounts")) + return false; + + await Task.Delay(750); + await displayControlAsync(MainForm.settingsToolStripMenuItem); + await displayControlAsync(MainForm.accountsToolStripMenuItem); + + using var accountSettings = MainForm.Invoke(() => new AccountsDialog()); + accountSettings.StartPosition = FormStartPosition.CenterParent; + accountSettings.Shown += (_, _) => MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account"); + MainForm.Invoke(() => accountSettings.ShowDialog(MainForm)); + return true; + } + + private async Task<bool> ShowSettingsDialog() + { + if (!ProceedMessageBox("Next, adjust Libation's settings", "Change Settings")) + return false; + + await Task.Delay(750); + await displayControlAsync(MainForm.settingsToolStripMenuItem); + await displayControlAsync(MainForm.basicSettingsToolStripMenuItem); + + using var settingsDialog = MainForm.Invoke(() => new SettingsDialog()); + + var tabsToVisit = settingsDialog.tabControl.TabPages.Cast<TabPage>().ToList(); + + settingsDialog.StartPosition = FormStartPosition.CenterParent; + settingsDialog.FormClosing += SettingsDialog_FormClosing; + settingsDialog.Shown += TabControl_TabIndexChanged; + settingsDialog.tabControl.SelectedIndexChanged += TabControl_TabIndexChanged; + settingsDialog.cancelBtn.Text = "Next Tab"; + settingsDialog.saveBtn.Visible = false; + + MainForm.Invoke(() => settingsDialog.ShowDialog(MainForm)); + + return true; + + void TabControl_TabIndexChanged(object? sender, EventArgs e) + { + var selectedTab = settingsDialog.tabControl.SelectedTab; + if (selectedTab == null) return; + + tabsToVisit.Remove(selectedTab); + + if (tabsToVisit.Count == 0) + { + settingsDialog.cancelBtn.Text = "Cancel"; + settingsDialog.saveBtn.Visible = true; + } + + if (!selectedTab.Visible || !settingTabMessages.ContainsKey(selectedTab.Text)) return; + + MessageBox.Show(selectedTab, settingTabMessages[selectedTab.Text], selectedTab.Text + " Tab", MessageBoxButtons.OK); + + settingTabMessages.Remove(selectedTab.Text); + } + + void SettingsDialog_FormClosing(object? sender, FormClosingEventArgs e) + { + if (tabsToVisit.Count > 0) + { + settingsDialog.tabControl.SelectedTab = tabsToVisit[0]; + e.Cancel = true; + } + } + } + + private async Task<bool> ShowAccountScanning() + { + var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var count = persister.AccountsSettings.Accounts.Count; + persister.Dispose(); + + if (count < 1) + { + MainForm.Invoke(() => MessageBox.Show(MainForm, "Add an Audible account, then sync your library through the 'Import' menu.", "Add an Audible Account", MessageBoxButtons.OK, MessageBoxIcon.Information)); + return true; + } + + var accounts = count > 1 ? "accounts" :"account"; + var library = count > 1 ? "libraries" : "library"; + if (!ProceedMessageBox($"Finally, scan your Audible {accounts} to sync your {library} with Libation.\r\n\r\nIf this is your first time scanning an account, you'll be prompted to enter your account's password to log into your Audible account.", $"Scan {accounts}")) + return false; + + var scanItem = count > 1 ? MainForm.scanLibraryOfAllAccountsToolStripMenuItem : MainForm.scanLibraryToolStripMenuItem; + + await Task.Delay(750); + await displayControlAsync(MainForm.importToolStripMenuItem); + await displayControlAsync(scanItem); + + MainForm.Invoke(scanItem.PerformClick); + + var tcs = new TaskCompletionSource(); + LibraryCommands.ScanEnd += LibraryCommands_ScanEnd; + await tcs.Task; + LibraryCommands.ScanEnd -= LibraryCommands_ScanEnd; + + return true; + + void LibraryCommands_ScanEnd(object? _, int __) => tcs.SetResult(); + } + + private async Task<bool> ShowSearching() + { + var books = DbContexts.GetLibrary_Flat_NoTracking(); + if (books.Count == 0) return true; + + var firstAuthor = getFirstAuthor()?.SurroundWithQuotes(); + if (firstAuthor == null) return true; + + if (!ProceedMessageBox("You can filter the grid entries by searching", "Searching")) + return false; + + await displayControlAsync(MainForm.filterSearchTb); + + MainForm.Invoke(() => MainForm.filterSearchTb.Text = string.Empty); + foreach (var c in firstAuthor) + { + MainForm.Invoke(() => MainForm.filterSearchTb.Text += c); + await Task.Delay(150); + } + + await displayControlAsync(MainForm.filterBtn); + + MainForm.Invoke(MainForm.filterBtn.PerformClick); + + await Task.Delay(1000); + + MessageBox.Show(MainForm, "Libation provides a built-in cheat sheet for its query language", "Search Cheat Sheet"); + + await displayControlAsync(MainForm.filterHelpBtn); + + using var filterHelp = MainForm.Invoke(MainForm.ShowSearchSyntaxDialog); + var tcs = new TaskCompletionSource(); + filterHelp.FormClosed += (_, _) => tcs.SetResult(); + await tcs.Task; + return true; + } + + private async Task<bool> ShowQuickFilters() + { + var firstAuthor = getFirstAuthor()?.SurroundWithQuotes(); + if (firstAuthor == null) return true; + + if (!ProceedMessageBox("Queries that you perform regularly can be added to 'Quick Filters'", "Quick Filters")) + return false; + + MainForm.Invoke(() => MainForm.filterSearchTb.Text = firstAuthor); + + await Task.Delay(750); + await displayControlAsync(MainForm.addQuickFilterBtn); + MainForm.Invoke(MainForm.addQuickFilterBtn.PerformClick); + await displayControlAsync(MainForm.quickFiltersToolStripMenuItem); + await displayControlAsync(MainForm.editQuickFiltersToolStripMenuItem); + + var editQuickFilters = MainForm.Invoke(() => new EditQuickFilters()); + editQuickFilters.Shown += (_, _) => MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters"); + MainForm.Invoke(editQuickFilters.ShowDialog); + + return true; + } + + private Task<bool> ShowTourComplete() + { + MessageBox.Show(MainForm, "You're now ready to begin using Libation.\r\n\r\nEnjoy!", "Tour Finished"); + return Task.FromResult(true); + } + + private string? getFirstAuthor() + { + var books = DbContexts.GetLibrary_Flat_NoTracking(); + return books.SelectMany(lb => lb.Book.Authors).FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Name))?.Name; + } + + private async Task displayControlAsync(ToolStripMenuItem menuItem) + { + MainForm.Invoke(() => menuItem.Enabled = false); + MainForm.Invoke(MainForm.productsDisplay.Focus); + await flashControlAsync(menuItem); + MainForm.Invoke(menuItem.ShowDropDown); + await Task.Delay(500); + MainForm.Invoke(() => menuItem.Enabled = true); + } + + private async Task displayControlAsync(Control button) + { + MainForm.Invoke(() => button.Enabled = false); + MainForm.Invoke(MainForm.productsDisplay.Focus); + await flashControlAsync(button); + await Task.Delay(500); + MainForm.Invoke(() => button.Enabled = true); + } + + private async Task flashControlAsync(Control control, int flashCount = 3) + { + var backColor = MainForm.Invoke(() => control.BackColor); + for (int i = 0; i < flashCount; i++) + { + MainForm.Invoke(() => control.BackColor = FlashColor); + await Task.Delay(200); + MainForm.Invoke(() => control.BackColor = backColor); + await Task.Delay(200); + } + } + + private async Task flashControlAsync(ToolStripItem control, int flashCount = 3) + { + var backColor = MainForm.Invoke(() => control.BackColor); + for (int i = 0; i < flashCount; i++) + { + MainForm.Invoke(() => control.BackColor = FlashColor); + await Task.Delay(200); + MainForm.Invoke(() => control.BackColor = backColor); + await Task.Delay(200); + } + } + + private bool ProceedMessageBox(string message, string caption) + => MainForm.Invoke(() => MessageBox.Show(MainForm, message, caption, MessageBoxButtons.OKCancel)) is DialogResult.OK; } diff --git a/Source/LibationWinForms/WinFormsUtil.cs b/Source/LibationWinForms/WinFormsUtil.cs index bb2a7eca..49b66c60 100644 --- a/Source/LibationWinForms/WinFormsUtil.cs +++ b/Source/LibationWinForms/WinFormsUtil.cs @@ -3,35 +3,46 @@ using LibationFileManager; using System.Drawing; using System.Windows.Forms; -namespace LibationWinForms -{ - internal static class WinFormsUtil - { - private const float BaseDpi = 96; +namespace LibationWinForms; - private static Bitmap defaultImage; - public static Image TryLoadImageOrDefault(byte[] picture, PictureSize defaultSize = PictureSize.Native) +internal static class WinFormsUtil +{ + private const float BaseDpi = 96; + + private static Bitmap? defaultImage; + public static Image TryLoadImageOrDefault(byte[]? picture, PictureSize defaultSize = PictureSize.Native) + { + if (picture?.Length is null or 0) + return getDefaultImage(); + + try { - try - { - return ImageReader.ToImage(picture); - } - catch - { - using var ms = new System.IO.MemoryStream(PictureStorage.GetDefaultImage(defaultSize)); - return defaultImage ??= new Bitmap(ms); - } + return ImageReader.ToImage(picture); + } + catch + { + return getDefaultImage(); } - public static int DpiScale(this Control control, int value, float additionalScaleFactor = 1) - => (int)float.Round(control.DeviceDpi / BaseDpi * value * additionalScaleFactor); - - public static int DpiUnscale(this Control control, int value) - => (int)float.Round(BaseDpi / control.DeviceDpi * value); - - public static int ScaleX(this Graphics control, int value) - => (int)float.Round(control.DpiX / BaseDpi * value); - public static int ScaleY(this Graphics control, int value) - => (int)float.Round(control.DpiY / BaseDpi * value); + Image getDefaultImage() + { + if (defaultImage is null) + { + using var ms = new System.IO.MemoryStream(PictureStorage.GetDefaultImage(defaultSize)); + defaultImage = new Bitmap(ms); + } + return defaultImage; + } } + + public static int DpiScale(this Control control, int value, float additionalScaleFactor = 1) + => (int)float.Round(control.DeviceDpi / BaseDpi * value * additionalScaleFactor); + + public static int DpiUnscale(this Control control, int value) + => (int)float.Round(BaseDpi / control.DeviceDpi * value); + + public static int ScaleX(this Graphics control, int value) + => (int)float.Round(control.DpiX / BaseDpi * value); + public static int ScaleY(this Graphics control, int value) + => (int)float.Round(control.DpiY / BaseDpi * value); } diff --git a/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj b/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj index 5d2cbd8e..1b51ebf9 100644 --- a/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj +++ b/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj @@ -8,6 +8,7 @@ <RuntimeIdentifier>linux-x64</RuntimeIdentifier> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> </PropertyGroup> <PropertyGroup> diff --git a/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs b/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs index d3d08146..683308d1 100644 --- a/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs +++ b/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs @@ -41,7 +41,7 @@ namespace LinuxConfigApp RunAsRoot("apt", $"install '{upgradeBundle}'"); } - private bool FindPkexec(out string exePath) + private bool FindPkexec(out string? exePath) { if (File.Exists("/usr/bin/pkexec")) { @@ -57,7 +57,7 @@ namespace LinuxConfigApp return false; } - public Process RunAsRoot(string exe, string args) + public Process? RunAsRoot(string exe, string args) { //try to use polkit directly if (FindPkexec(out var pkexec)) diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj index 8eb78e1b..89b96b0f 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj +++ b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj @@ -9,6 +9,7 @@ <RuntimeIdentifier>osx-x64</RuntimeIdentifier> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> </PropertyGroup> <PropertyGroup> diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs index 8ab9d43f..1bc4318c 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs +++ b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs @@ -35,7 +35,7 @@ namespace MacOSConfigApp //Using osascript -e '[script]' works from the terminal, but I haven't figured //out the syntax for it to work from create_process, so write to stdin instead. - public Process RunAsRoot(string _, string command) + public Process? RunAsRoot(string _, string command) { const string osascript = "osascript"; var fullCommand = $"do shell script \"{command}\" with administrator privileges"; @@ -52,7 +52,8 @@ namespace MacOSConfigApp Serilog.Log.Logger.Information($"running {osascript} as root: {{script}}", fullCommand); - var proc = Process.Start(psi); + if (Process.Start(psi) is not { } proc) + return null; proc.ErrorDataReceived += Proc_ErrorDataReceived; proc.OutputDataReceived += Proc_OutputDataReceived; proc.BeginErrorReadLine(); diff --git a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs index 4ad2bd51..0d1f58dc 100644 --- a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs +++ b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs @@ -29,6 +29,9 @@ namespace WindowsConfigApp const string ExtractorExeName = "ZipExtractor.exe"; var thisExe = Environment.ProcessPath; var thisDir = Path.GetDirectoryName(thisExe); + if (!File.Exists(thisExe) || !Directory.Exists(thisDir)) + return; + var zipExtractor = Path.Combine(Path.GetTempPath(), ExtractorExeName); File.Copy(Path.Combine(thisDir, ExtractorExeName), zipExtractor, overwrite: true); @@ -39,7 +42,7 @@ namespace WindowsConfigApp $"--executable {thisExe.SurroundWithQuotes()}"); } - public Process RunAsRoot(string exe, string args) + public Process? RunAsRoot(string exe, string args) { var psi = new ProcessStartInfo() { diff --git a/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj b/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj index b6a1037d..16e2fd8e 100644 --- a/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj +++ b/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj @@ -8,6 +8,7 @@ <PublishReadyToRun>true</PublishReadyToRun> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> </PropertyGroup> <PropertyGroup> diff --git a/Source/_Tests/AssertionHelper/AssertionExtensions.cs b/Source/_Tests/AssertionHelper/AssertionExtensions.cs index 351a42da..dba47c4a 100644 --- a/Source/_Tests/AssertionHelper/AssertionExtensions.cs +++ b/Source/_Tests/AssertionHelper/AssertionExtensions.cs @@ -1,10 +1,13 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +[assembly: Parallelize] namespace AssertionHelper; public static class AssertionExtensions { [StackTraceHidden] + [return: NotNullIfNotNull(nameof(value))] public static T? Should<T>(this T? value) => value; [StackTraceHidden] @@ -15,6 +18,10 @@ public static class AssertionExtensions public static void BeNull<T>(this T? value) where T : class => Assert.IsNull(value); + [StackTraceHidden] + public static void BeNotNull<T>([NotNull] this T? value) where T : class + => Assert.IsNotNull(value); + [StackTraceHidden] public static void BeSameAs<T>(this T? value, T? otherValue) => Assert.AreSame(otherValue, value); diff --git a/Source/_Tests/AssertionHelper/AssertionHelper.csproj b/Source/_Tests/AssertionHelper/AssertionHelper.csproj index 06797cd0..9b340644 100644 --- a/Source/_Tests/AssertionHelper/AssertionHelper.csproj +++ b/Source/_Tests/AssertionHelper/AssertionHelper.csproj @@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="MSTest.TestFramework" Version="4.0.2" /> + <PackageReference Include="MSTest.TestFramework" Version="4.1.0" /> </ItemGroup> </Project> diff --git a/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs b/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs index 6979a239..0ac2608e 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs +++ b/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs @@ -16,16 +16,18 @@ namespace AccountsTests { protected string EMPTY_FILE { get; } = "{\r\n \"Accounts\": [],\r\n \"Cdm\": null\r\n}".Replace("\r\n", Environment.NewLine); - protected string TestFile; + private string? _testFile; protected Locale usLocale => Localization.Get("us"); protected Locale ukLocale => Localization.Get("uk"); protected void WriteToTestFile(string contents) - => File.WriteAllText(TestFile, contents); + => File.WriteAllText(TestFile , contents); - [TestInitialize] + protected string TestFile => _testFile ??= Guid.NewGuid() + ".txt"; + + [TestInitialize] public void TestInit() - => TestFile = Guid.NewGuid() + ".txt"; + => _ = TestFile; [TestCleanup] public void TestCleanup() @@ -42,7 +44,8 @@ namespace AccountsTests public void _0_accounts() { var accountsSettings = AccountsSettings.FromJson(EMPTY_FILE); - accountsSettings.Accounts.Count.Should().Be(0); + accountsSettings.BeNotNull(); + accountsSettings.Accounts.Count.Should().Be(0); } [TestMethod] @@ -61,7 +64,8 @@ namespace AccountsTests } ".Trim(); var accountsSettings = AccountsSettings.FromJson(json); - accountsSettings.Accounts.Count.Should().Be(1); + accountsSettings.BeNotNull(); + accountsSettings.Accounts.Count.Should().Be(1); accountsSettings.Accounts[0].AccountId.Should().Be("cng"); accountsSettings.Accounts[0].IdentityTokens.Should().BeNull(); } @@ -123,7 +127,8 @@ namespace AccountsTests var persister = new AccountsSettingsPersister(TestFile); var acct = persister.AccountsSettings.Accounts[0]; acct.AccountName.Should().Be("n0"); - acct.Locale.CountryCode.Should().Be("us"); + acct.Locale.BeNotNull(); + acct.Locale.CountryCode.Should().Be("us"); } } @@ -152,7 +157,8 @@ namespace AccountsTests p.AccountsSettings.Accounts.Count.Should().Be(1); var acct0 = p.AccountsSettings.Accounts[0]; acct0.AccountName.Should().Be("n0"); - acct0.Locale.CountryCode.Should().Be("us"); + acct0.Locale.BeNotNull(); + acct0.Locale.CountryCode.Should().Be("us"); } } @@ -180,7 +186,8 @@ namespace AccountsTests var acct0 = p.AccountsSettings.Accounts[0]; acct0.AccountName.Should().Be("n0"); - acct0.Locale.CountryCode.Should().Be("us"); + acct0.Locale.BeNotNull(); + acct0.Locale.CountryCode.Should().Be("us"); } // load file. create account 1 @@ -199,11 +206,13 @@ namespace AccountsTests var acct0 = p.AccountsSettings.Accounts[0]; acct0.AccountName.Should().Be("n0"); - acct0.Locale.CountryCode.Should().Be("us"); + acct0.Locale.BeNotNull(); + acct0.Locale.CountryCode.Should().Be("us"); var acct1 = p.AccountsSettings.Accounts[1]; acct1.AccountName.Should().Be("n1"); - acct1.Locale.CountryCode.Should().Be("uk"); + acct1.Locale.BeNotNull(); + acct1.Locale.CountryCode.Should().Be("uk"); } } @@ -268,11 +277,13 @@ namespace AccountsTests // new acct0.AccountName.Should().Be("new"); - // still here - acct0.Locale.CountryCode.Should().Be("us"); + // still here + acct0.Locale.BeNotNull(); + acct0.Locale.CountryCode.Should().Be("us"); var acct1 = p.AccountsSettings.Accounts[1]; acct1.AccountName.Should().Be("n1"); - acct1.Locale.CountryCode.Should().Be("uk"); + acct1.Locale.BeNotNull(); + acct1.Locale.CountryCode.Should().Be("uk"); } } @@ -311,14 +322,16 @@ namespace AccountsTests p.AccountsSettings.Accounts.Count.Should().Be(2); var acct0 = p.AccountsSettings.Accounts[0]; - // new - acct0.Locale.CountryCode.Should().Be("uk"); + // new + acct0.Locale.BeNotNull(); + acct0.Locale.CountryCode.Should().Be("uk"); // still here acct0.AccountName.Should().Be("n0"); var acct1 = p.AccountsSettings.Accounts[1]; acct1.AccountName.Should().Be("n1"); - acct1.Locale.CountryCode.Should().Be("uk"); + acct1.Locale.BeNotNull(); + acct1.Locale.CountryCode.Should().Be("uk"); } } @@ -346,9 +359,11 @@ namespace AccountsTests // update identity on existing file using (var p = new AccountsSettingsPersister(TestFile)) { - p.AccountsSettings.Accounts[0] - .IdentityTokens - .Update(new AccessToken("Atna|_NEW_", DateTime.Now.AddDays(1))); + var acct0 = p.AccountsSettings.Accounts[0]; + + acct0.IdentityTokens.BeNotNull(); + acct0.IdentityTokens + .Update(new AccessToken("Atna|_NEW_", DateTime.Now.AddDays(1))); } // re-load file. ensure both accounts still exist @@ -357,15 +372,18 @@ namespace AccountsTests p.AccountsSettings.Accounts.Count.Should().Be(2); var acct0 = p.AccountsSettings.Accounts[0]; - // new - acct0.IdentityTokens.ExistingAccessToken.TokenValue.Should().Be("Atna|_NEW_"); + // new + acct0.IdentityTokens.BeNotNull(); + acct0.IdentityTokens.ExistingAccessToken.TokenValue.Should().Be("Atna|_NEW_"); // still here acct0.AccountName.Should().Be("n0"); - acct0.Locale.CountryCode.Should().Be("us"); + acct0.Locale.BeNotNull(); + acct0.Locale.CountryCode.Should().Be("us"); var acct1 = p.AccountsSettings.Accounts[1]; acct1.AccountName.Should().Be("n1"); - acct1.Locale.CountryCode.Should().Be("uk"); + acct1.Locale.BeNotNull(); + acct1.Locale.CountryCode.Should().Be("uk"); } } } @@ -386,7 +404,9 @@ namespace AccountsTests accountsSettings.Add(acct1); accountsSettings.Add(acct2); - accountsSettings.GetAccount("cng", "uk").AccountName.Should().Be("bar"); + var acct = accountsSettings.GetAccount("cng", "uk"); + acct.BeNotNull(); + acct.AccountName.Should().Be("bar"); } } @@ -402,7 +422,9 @@ namespace AccountsTests accountsSettings.Upsert("cng", "us"); accountsSettings.Accounts.Count.Should().Be(1); - accountsSettings.GetAccount("cng", "us").AccountId.Should().Be("cng"); + var acct = accountsSettings.GetAccount("cng", "us"); + acct.BeNotNull(); + acct.AccountId.Should().Be("cng"); } [TestMethod] @@ -453,7 +475,7 @@ namespace AccountsTests public void delete_updates() { var i = 0; - void update(object sender, EventArgs e) => i++; + void update(object? sender, EventArgs e) => i++; var accountsSettings = new AccountsSettings(); accountsSettings.Updated += update; diff --git a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj index 6cb4e51d..0b650963 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj +++ b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj @@ -4,10 +4,11 @@ <TargetFramework>net10.0</TargetFramework> <OutputType>Exe</OutputType> <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="MSTest" Version="4.0.2" /> + <PackageReference Include="MSTest" Version="4.1.0" /> </ItemGroup> <ItemGroup> diff --git a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj index b8c4ee1f..626251f1 100644 --- a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj +++ b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj @@ -4,10 +4,11 @@ <TargetFramework>net10.0</TargetFramework> <OutputType>Exe</OutputType> <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="MSTest" Version="4.0.2" /> + <PackageReference Include="MSTest" Version="4.1.0" /> </ItemGroup> <ItemGroup> diff --git a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj index 524778cc..04056018 100644 --- a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj +++ b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj @@ -4,10 +4,11 @@ <TargetFramework>net10.0</TargetFramework> <OutputType>Exe</OutputType> <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="MSTest" Version="4.0.2" /> + <PackageReference Include="MSTest" Version="4.1.0" /> </ItemGroup> <ItemGroup> diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index 6e73efd4..993f5688 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -7,36 +7,36 @@ namespace NamingTemplateTests { class TemplateTag : ITemplateTag { - public string TagName { get; init; } + public required string TagName { get; init; } } class PropertyClass1 { - public string Item1 { get; set; } - public string Item2 { get; set; } - public string Item3 { get; set; } - public string NullItem { get; set; } + public string? Item1 { get; set; } + public string? Item2 { get; set; } + public string? Item3 { get; set; } + public string? NullItem { get; set; } public int Int1 { get; set; } public bool Condition { get; set; } } class PropertyClass2 { - public string Item1 { get; set; } - public string Item2 { get; set; } - public string Item3 { get; set; } - public string Item4 { get; set; } - public string NullItem { get; set; } + public string? Item1 { get; set; } + public string? Item2 { get; set; } + public string? Item3 { get; set; } + public string? Item4 { get; set; } + public string? NullItem { get; set; } public bool Condition { get; set; } } class PropertyClass3 { - public string Item1 { get; set; } - public string Item2 { get; set; } - public string Item3 { get; set; } - public string Item4 { get; set; } - public string NullItem { get; set; } - public ReferenceType RefType { get; set; } + public string? Item1 { get; set; } + public string? Item2 { get; set; } + public string? Item3 { get; set; } + public string? Item4 { get; set; } + public string? NullItem { get; set; } + public ReferenceType? RefType { get; set; } public int? Int2 { get; set; } public bool Condition { get; set; } } @@ -270,10 +270,11 @@ namespace NamingTemplateTests return value.ToString(); } - string formatString(ITemplateTag templateTag, string value, string formatString) + string formatString(ITemplateTag templateTag, string? value, string formatString) { - if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value?.ToUpper(); - else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value?.ToLower(); + if (value is null) return string.Empty; + else if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value.ToUpper(); + else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value.ToLower(); else return value; } } diff --git a/Source/_Tests/FileManager.Tests/FileUtilityTests.cs b/Source/_Tests/FileManager.Tests/FileUtilityTests.cs index 4256ef65..0530113d 100644 --- a/Source/_Tests/FileManager.Tests/FileUtilityTests.cs +++ b/Source/_Tests/FileManager.Tests/FileUtilityTests.cs @@ -15,7 +15,7 @@ namespace FileUtilityTests static readonly ReplacementCharacters Barebones = ReplacementCharacters.Barebones(Environment.OSVersion.Platform == PlatformID.Win32NT); [TestMethod] - public void null_path_throws() => Assert.ThrowsExactly<ArgumentNullException>(() => FileUtility.GetSafePath(null, Default)); + public void null_path_throws() => Assert.ThrowsExactly<ArgumentNullException>(() => FileUtility.GetSafePath(null!, Default)); [TestMethod] // non-empty replacement @@ -184,7 +184,7 @@ namespace FileUtilityTests [DataRow("txt", ".txt")] [DataRow(".txt", ".txt")] [DataRow(" .txt ", ".txt")] - public void Tests(string input, string expected) + public void Tests(string? input, string expected) => FileUtility.GetStandardizedExtension(input).Should().Be(expected); } @@ -226,7 +226,7 @@ namespace FileUtilityTests public class RemoveLastCharacter { [TestMethod] - public void is_null() => Tests(null, null); + public void is_null() => Tests(null!, null); [TestMethod] public void empty() => Tests("", ""); @@ -242,7 +242,7 @@ namespace FileUtilityTests [DataRow("1 ", "1")] [DataRow("12", "1")] [DataRow("123", "12")] - public void Tests(string input, string expected) + public void Tests(string input, string? expected) => FileUtility.RemoveLastCharacter(input).Should().Be(expected); } } diff --git a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj index 906cc9f5..3f308465 100644 --- a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj +++ b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj @@ -4,10 +4,11 @@ <TargetFramework>net10.0</TargetFramework> <OutputType>Exe</OutputType> <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="MSTest" Version="4.0.2" /> + <PackageReference Include="MSTest" Version="4.1.0" /> </ItemGroup> <ItemGroup> diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index db339c92..68a7ffa8 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -33,7 +33,7 @@ namespace TemplatesTests public static LibraryBookDto GetLibraryBook() => GetLibraryBook([new SeriesDto("Sherlock Holmes", "1", "B08376S3R2")]); - public static LibraryBookDto GetLibraryBook(IEnumerable<SeriesDto> series) + public static LibraryBookDto GetLibraryBook(IEnumerable<SeriesDto>? series) => new() { Account = "myaccount@example.co", @@ -418,6 +418,7 @@ namespace TemplatesTests PartsPosition = 1, PartsTotal = 2, Title = bookDto.Title, + OutputFileName = "outputfile.m4b" }; Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); @@ -579,10 +580,10 @@ namespace Templates_Other private class TemplateTag : ITemplateTag { - public string TagName { get; init; } - public string DefaultValue { get; } - public string Description { get; } - public string Display { get; } + public required string TagName { get; init; } + public string? DefaultValue { get; } + public string? Description { get; } + public string? Display { get; } } private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string template, string title, string extension) { @@ -613,7 +614,7 @@ namespace Templates_Other // 100-999 => 001-999 var estension = Path.GetExtension(originalPath); - var dir = Path.GetDirectoryName(originalPath); + var dir = Path.GetDirectoryName(originalPath)!; var template = Path.GetFileNameWithoutExtension(originalPath) + " - <ch# 0> - <title>" + estension; var lbDto = GetLibraryBook(); @@ -622,7 +623,7 @@ namespace Templates_Other Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate).Should().BeTrue(); return chapterFileTemplate - .GetFilename(lbDto, new AaxDecrypter.MultiConvertFileProperties { Title = suffix, PartsTotal = partsTotal, PartsPosition = partsPosition }, dir, estension, Replacements) + .GetFilename(lbDto, new MultiConvertFileProperties { Title = suffix, PartsTotal = partsTotal, PartsPosition = partsPosition, OutputFileName = string.Empty }, dir, estension, Replacements) .PathWithoutPrefix; } @@ -636,7 +637,7 @@ namespace Templates_Other var lbDto = GetLibraryBook(); lbDto.TitleWithSubtitle = @"s\l/a\s/h\e/s"; - var directory = Path.GetDirectoryName(inStr); + var directory = Path.GetDirectoryName(inStr)!; var fileName = Path.GetFileName(inStr); Templates.TryGetTemplate<Templates.FileTemplate>(fileName, out var fileNamingTemplate).Should().BeTrue(); @@ -673,7 +674,7 @@ namespace Templates_Folder_Tests [TestMethod] [DataRow(@"C:\", PlatformID.Win32NT, Templates.ERROR_FULL_PATH_IS_INVALID)] - public void Tests(string template, PlatformID platformID, params string[] expected) + public void Tests(string? template, PlatformID platformID, params string[] expected) { if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform) { @@ -735,7 +736,7 @@ namespace Templates_Folder_Tests [TestMethod] [DataRow(@"no tags", NamingTemplate.WARNING_NO_TAGS)] [DataRow("<ch#> chapter tag", NamingTemplate.WARNING_NO_TAGS)] - public void Tests(string template, params string[] expected) + public void Tests(string? template, params string[] expected) { Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate); var result = folderTemplate.Warnings; @@ -760,7 +761,7 @@ namespace Templates_Folder_Tests [DataRow(@"no tags", true)] [DataRow(@"<id>\foo\bar", false)] [DataRow("<ch#> chapter tag", true)] - public void Tests(string template, bool expected) + public void Tests(string? template, bool expected) { Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate); folderTemplate.HasWarnings.Should().Be(expected); @@ -820,7 +821,7 @@ namespace Templates_File_Tests [DataRow(@"<id>")] public void valid_tests(string template) => Tests(template, Environment.OSVersion.Platform, Array.Empty<string>()); - public void Tests(string template, PlatformID platformID, params string[] expected) + public void Tests(string? template, PlatformID platformID, params string[] expected) { if (Environment.OSVersion.Platform == platformID) { @@ -904,7 +905,7 @@ namespace Templates_ChapterFile_Tests [DataRow(@"<id>\foo\bar", true, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] [DataRow(@"<id>/foo/bar", false, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] [DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] - public void Tests(string template, bool? windows, params string[] expected) + public void Tests(string? template, bool? windows, params string[] expected) { if (windows is null || (windows is true && Environment.OSVersion.Platform is PlatformID.Win32NT) @@ -937,7 +938,7 @@ namespace Templates_ChapterFile_Tests [DataRow("<ch#> <id>", false)] [DataRow("<ch#> -- chapter tag", false)] [DataRow("<chapter count> -- chapter tag but not ch# or ch_#", true)] - public void Tests(string template, bool expected) + public void Tests(string? template, bool expected) { Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate); chapterFileTemplate.HasWarnings.Should().Be(expected); diff --git a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj index 9a82fa42..90257f6f 100644 --- a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj +++ b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj @@ -4,10 +4,11 @@ <TargetFramework>net10.0</TargetFramework> <OutputType>Exe</OutputType> <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="MSTest" Version="4.0.2" /> + <PackageReference Include="MSTest" Version="4.1.0" /> </ItemGroup> <ItemGroup> diff --git a/Source/_Tests/LibationUiBase.Tests/LibationUiBase.Tests.csproj b/Source/_Tests/LibationUiBase.Tests/LibationUiBase.Tests.csproj index d7d32bcb..3e011fd9 100644 --- a/Source/_Tests/LibationUiBase.Tests/LibationUiBase.Tests.csproj +++ b/Source/_Tests/LibationUiBase.Tests/LibationUiBase.Tests.csproj @@ -9,7 +9,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="MSTest" Version="4.0.2" /> + <PackageReference Include="MSTest" Version="4.1.0" /> </ItemGroup> <ItemGroup>