diff --git a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs index ff708f97..4504a50c 100644 --- a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs @@ -43,7 +43,21 @@ namespace AaxDecrypter return false; } + //Step 3 + if (DownloadOptions.DownloadClipsBookmarks) + { + Serilog.Log.Information("Begin Downloading Clips and Bookmarks"); + if (await Task.Run(Step_DownloadClipsBookmarks)) + Serilog.Log.Information("Completed Downloading Clips and Bookmarks"); + else + { + Serilog.Log.Information("Failed to Download Clips and Bookmarks"); + return false; + } + } + + //Step 4 Serilog.Log.Information("Begin Cleanup"); if (await Task.Run(Step_Cleanup)) Serilog.Log.Information("Completed Cleanup"); diff --git a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs index 85dc62a1..04188885 100644 --- a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs @@ -49,6 +49,19 @@ namespace AaxDecrypter } //Step 4 + if (DownloadOptions.DownloadClipsBookmarks) + { + Serilog.Log.Information("Begin Downloading Clips and Bookmarks"); + if (await Task.Run(Step_DownloadClipsBookmarks)) + Serilog.Log.Information("Completed Downloading Clips and Bookmarks"); + else + { + Serilog.Log.Information("Failed to Download Clips and Bookmarks"); + return false; + } + } + + //Step 5 Serilog.Log.Information("Begin Step 4: Cleanup"); if (await Task.Run(Step_Cleanup)) Serilog.Log.Information("Completed Step 4: Cleanup"); diff --git a/Source/AaxDecrypter/AudiobookDownloadBase.cs b/Source/AaxDecrypter/AudiobookDownloadBase.cs index 89a94f2f..121520e7 100644 --- a/Source/AaxDecrypter/AudiobookDownloadBase.cs +++ b/Source/AaxDecrypter/AudiobookDownloadBase.cs @@ -48,6 +48,7 @@ namespace AaxDecrypter TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc"); DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions)); + DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed; // delete file after validation is complete FileUtility.SaferDelete(OutputFileName); @@ -132,14 +133,27 @@ namespace AaxDecrypter return success; } + protected async Task Step_DownloadClipsBookmarks() + { + if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks) + { + var recordsFile = await DownloadOptions.SaveClipsAndBookmarks(OutputFileName); + + if (File.Exists(recordsFile)) + OnFileCreated(recordsFile); + } + return !IsCanceled; + } + private NetworkFileStreamPersister OpenNetworkFileStream() { - if (!File.Exists(jsonDownloadState)) - return NewNetworkFilePersister(); - + NetworkFileStreamPersister nfsp = default; try { - var nfsp = new NetworkFileStreamPersister(jsonDownloadState); + if (!File.Exists(jsonDownloadState)) + return nfsp = NewNetworkFilePersister(); + + nfsp = new NetworkFileStreamPersister(jsonDownloadState); // If More than ~1 hour has elapsed since getting the download url, it will expire. // The new url will be to the same file. nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl)); @@ -149,7 +163,12 @@ namespace AaxDecrypter { FileUtility.SaferDelete(jsonDownloadState); FileUtility.SaferDelete(TempFilePath); - return NewNetworkFilePersister(); + return nfsp = NewNetworkFilePersister(); + } + finally + { + if (nfsp?.NetworkFileStream is not null) + nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; } } diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index 2e098af6..6972f067 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -1,9 +1,12 @@ using AAXClean; +using System; +using System.Threading.Tasks; namespace AaxDecrypter { public interface IDownloadOptions { + event EventHandler DownloadSpeedChanged; FileManager.ReplacementCharacters ReplacementCharacters { get; } string DownloadUrl { get; } string UserAgent { get; } @@ -14,6 +17,8 @@ namespace AaxDecrypter bool RetainEncryptedFile { get; } bool StripUnabridged { get; } bool CreateCueSheet { get; } + bool DownloadClipsBookmarks { get; } + long DownloadSpeedBps { get; } ChapterInfo ChapterInfo { get; } bool FixupFile { get; } NAudio.Lame.LameConfig LameConfig { get; } @@ -21,5 +26,6 @@ namespace AaxDecrypter bool MatchSourceBitrate { get; } string GetMultipartFileName(MultiConvertFileProperties props); string GetMultipartTitleName(MultiConvertFileProperties props); - } + Task SaveClipsAndBookmarks(string fileName); + } } diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index 093bd1ab..23b7b19a 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -41,9 +41,9 @@ namespace AaxDecrypter [JsonIgnore] public bool IsCancelled => _cancellationSource.IsCancellationRequested; - private static long _globalSpeedLimit = 0; + private long _speedLimit = 0; /// bytes per second - public static long GlobalSpeedLimit { get => _globalSpeedLimit; set => _globalSpeedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); } + public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); } #endregion @@ -70,7 +70,7 @@ namespace AaxDecrypter //Minimum throttle rate. The minimum amount of data that can be throttled //on each iteration of the download loop is DOWNLOAD_BUFF_SZ. - private const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY; + public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY; #endregion @@ -202,7 +202,7 @@ namespace AaxDecrypter bytesReadSinceThrottle += bytesRead; - if (GlobalSpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > GlobalSpeedLimit / THROTTLE_FREQUENCY) + if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY) { var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds; if (delayMS > 0) diff --git a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs index b9ac5c3b..e9c062f0 100644 --- a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs +++ b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs @@ -16,7 +16,7 @@ namespace AaxDecrypter { try { - Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat); + Serilog.Log.Information("Begin downloading unencrypted audiobook."); //Step 1 Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata"); @@ -39,6 +39,19 @@ namespace AaxDecrypter } //Step 3 + if (DownloadOptions.DownloadClipsBookmarks) + { + Serilog.Log.Information("Begin Downloading Clips and Bookmarks"); + if (await Task.Run(Step_DownloadClipsBookmarks)) + Serilog.Log.Information("Completed Downloading Clips and Bookmarks"); + else + { + Serilog.Log.Information("Failed to Download Clips and Bookmarks"); + return false; + } + } + + //Step 4 Serilog.Log.Information("Begin Step 3: Cleanup"); if (await Task.Run(Step_Cleanup)) Serilog.Log.Information("Completed Step 3: Cleanup"); @@ -58,7 +71,6 @@ namespace AaxDecrypter } } - public override Task CancelAsync() { IsCanceled = true; diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index a687dd2c..5c6af0df 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -29,6 +29,9 @@ namespace AppScaffolding public static class LibationScaffolding { + public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation"; + public const string WebsiteUrl = "ht" + "tps://getlibation.com"; + public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest"; public static ReleaseIdentifier ReleaseIdentifier { get; private set; } public static VarietyType Variety => ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic @@ -173,6 +176,12 @@ namespace AppScaffolding if (!config.Exists(nameof(config.DownloadCoverArt))) config.DownloadCoverArt = true; + + if (!config.Exists(nameof(config.DownloadClipsBookmarks))) + config.DownloadClipsBookmarks = false; + + if (!config.Exists(nameof(config.ClipsBookmarksFileFormat))) + config.ClipsBookmarksFileFormat = Configuration.ClipBookmarkFormat.CSV; if (!config.Exists(nameof(config.AutoDownloadEpisodes))) config.AutoDownloadEpisodes = false; @@ -229,7 +238,7 @@ namespace AppScaffolding { "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace { "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } }, }; - config.SetObject("Serilog", serilogObj); + config.SetNonString(serilogObj, "Serilog"); } // to restore original: Console.SetOut(origOut); @@ -372,7 +381,7 @@ namespace AppScaffolding zipUrl }); - return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease); + return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body); } private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout) { diff --git a/Source/AppScaffolding/UpgradeProperties.cs b/Source/AppScaffolding/UpgradeProperties.cs index f1ca062f..506255b2 100644 --- a/Source/AppScaffolding/UpgradeProperties.cs +++ b/Source/AppScaffolding/UpgradeProperties.cs @@ -1,6 +1,34 @@ using System; +using System.Text.RegularExpressions; namespace AppScaffolding { - public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease); + public record UpgradeProperties + { + private static readonly Regex linkstripper = new Regex(@"\[(.*)\]\(.*\)"); + public string ZipUrl { get; } + public string HtmlUrl { get; } + public string ZipName { get; } + public Version LatestRelease { get; } + public string Notes { get; } + + public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes) + { + ZipName = zipName; + HtmlUrl = htmlUrl; + ZipUrl = zipUrl; + LatestRelease = latestRelease; + Notes = stripMarkdownLinks(notes); + } + private string stripMarkdownLinks(string body) + { + body = body.Replace(@"\", ""); + var matches = linkstripper.Matches(body); + + foreach (Match match in matches) + body = body.Replace(match.Groups[0].Value, match.Groups[1].Value); + + return body; + } + } } diff --git a/Source/ApplicationServices/RecordExporter.cs b/Source/ApplicationServices/RecordExporter.cs new file mode 100644 index 00000000..44101738 --- /dev/null +++ b/Source/ApplicationServices/RecordExporter.cs @@ -0,0 +1,198 @@ +using AudibleApi.Common; +using CsvHelper; +using DataLayer; +using Newtonsoft.Json.Linq; +using NPOI.XSSF.UserModel; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ApplicationServices +{ + public static class RecordExporter + { + public static void ToXlsx(string saveFilePath, IEnumerable records) + { + if (!records.Any()) + return; + + using var workbook = new XSSFWorkbook(); + var sheet = workbook.CreateSheet("Records"); + + var detailSubtotalFont = workbook.CreateFont(); + detailSubtotalFont.IsBold = true; + + var detailSubtotalCellStyle = workbook.CreateCellStyle(); + detailSubtotalCellStyle.SetFont(detailSubtotalFont); + + // headers + var rowIndex = 0; + var row = sheet.CreateRow(rowIndex); + + var columns = new List + { + nameof(Type.Name), + nameof(IRecord.Created), + nameof(IRecord.Start) + "_ms", + }; + + if (records.OfType().Any()) + { + columns.Add(nameof(IAnnotation.AnnotationId)); + columns.Add(nameof(IAnnotation.LastModified)); + } + if (records.OfType().Any()) + { + columns.Add(nameof(IRangeAnnotation.End) + "_ms"); + columns.Add(nameof(IRangeAnnotation.Text)); + } + if (records.OfType().Any()) + columns.Add(nameof(Clip.Title)); + + var col = 0; + foreach (var c in columns) + { + var cell = row.CreateCell(col++); + cell.SetCellValue(c); + cell.CellStyle = detailSubtotalCellStyle; + } + + var dateFormat = workbook.CreateDataFormat(); + var dateStyle = workbook.CreateCellStyle(); + dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss"); + + // Add data rows + foreach (var record in records) + { + col = 0; + + row = sheet.CreateRow(++rowIndex); + + row.CreateCell(col++).SetCellValue(record.GetType().Name); + + var dateCreatedCell = row.CreateCell(col++); + dateCreatedCell.CellStyle = dateStyle; + dateCreatedCell.SetCellValue(record.Created.DateTime); + + row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds); + + if (record is IAnnotation annotation) + { + row.CreateCell(col++).SetCellValue(annotation.AnnotationId); + + var lastModifiedCell = row.CreateCell(col++); + lastModifiedCell.CellStyle = dateStyle; + lastModifiedCell.SetCellValue(annotation.LastModified.DateTime); + + if (annotation is IRangeAnnotation rangeAnnotation) + { + row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds); + row.CreateCell(col++).SetCellValue(rangeAnnotation.Text); + + if (rangeAnnotation is Clip clip) + row.CreateCell(col++).SetCellValue(clip.Title); + } + } + } + + using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); + workbook.Write(fileData); + } + + public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable records) + { + if (!records.Any()) + return; + + var recordsEx = extendRecords(records); + + var recordsObj = new JObject + { + { "title", libraryBook.Book.Title}, + { "asin", libraryBook.Book.AudibleProductId}, + { "exportTime", DateTime.Now}, + { "records", JArray.FromObject(recordsEx) } + }; + + System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented)); + } + + public static void ToCsv(string saveFilePath, IEnumerable records) + { + if (!records.Any()) + return; + + using var writer = new System.IO.StreamWriter(saveFilePath); + using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture); + + //Write headers for the present record type that has the most properties + if (records.OfType().Any()) + csv.WriteHeader(typeof(ClipEx)); + else if (records.OfType().Any()) + csv.WriteHeader(typeof(NoteEx)); + else if (records.OfType().Any()) + csv.WriteHeader(typeof(BookmarkEx)); + else + csv.WriteHeader(typeof(LastHeardEx)); + + var recordsEx = extendRecords(records); + + csv.NextRecord(); + csv.WriteRecords(recordsEx.OfType()); + csv.WriteRecords(recordsEx.OfType()); + csv.WriteRecords(recordsEx.OfType()); + csv.WriteRecords(recordsEx.OfType()); + } + + private static IEnumerable extendRecords(IEnumerable records) + => records + .Select( + r => r switch + { + Clip c => new ClipEx(nameof(Clip), c), + Note n => new NoteEx(nameof(Note), n), + Bookmark b => new BookmarkEx(nameof(Bookmark), b), + LastHeard l => new LastHeardEx(nameof(LastHeard), l), + _ => throw new InvalidOperationException(), + }); + + + private interface IRecordEx { string Type { get; } } + + private record LastHeardEx : LastHeard, IRecordEx + { + public string Type { get; } + public LastHeardEx(string type, LastHeard original) : base(original) + { + Type = type; + } + } + + private record BookmarkEx : Bookmark, IRecordEx + { + public string Type { get; } + public BookmarkEx(string type, Bookmark original) : base(original) + { + Type = type; + } + } + + private record NoteEx : Note, IRecordEx + { + public string Type { get; } + public NoteEx(string type, Note original) : base(original) + { + Type = type; + } + } + + private record ClipEx : Clip, IRecordEx + { + public string Type { get; } + public ClipEx(string type, Clip original) : base(original) + { + Type = type; + } + } + } +} diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index 3f7abea3..ea430c5d 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -104,7 +104,7 @@ namespace FileLiberator var api = await libraryBook.GetApiAsync(); var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId); - var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic); + using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic); var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower()); var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; @@ -133,9 +133,7 @@ namespace FileLiberator abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path); // REAL WORK DONE HERE - var success = await abDownloader.RunAsync(); - - return success; + return await abDownloader.RunAsync(); } private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic) @@ -168,6 +166,8 @@ namespace FileLiberator Downsample = config.AllowLibationFixup && config.LameDownsampleMono, MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate, CreateCueSheet = config.CreateCueSheet, + DownloadClipsBookmarks = config.DownloadClipsBookmarks, + DownloadSpeedBps = config.DownloadSpeedLimit, LameConfig = GetLameOptions(config), ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)), FixupFile = config.AllowLibationFixup diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 325ac18f..7520ed15 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -4,43 +4,88 @@ using Dinah.Core; using DataLayer; using LibationFileManager; using FileManager; +using System.Threading.Tasks; +using System; +using System.IO; +using ApplicationServices; namespace FileLiberator { - public class DownloadOptions : IDownloadOptions - { - public LibraryBookDto LibraryBookDto { get; } - public string DownloadUrl { get; } - public string UserAgent { get; } - public string AudibleKey { get; init; } - public string AudibleIV { get; init; } - public AaxDecrypter.OutputFormat OutputFormat { get; init; } - public bool TrimOutputToChapterLength { get; init; } - public bool RetainEncryptedFile { get; init; } - public bool StripUnabridged { get; init; } - public bool CreateCueSheet { get; init; } - public ChapterInfo ChapterInfo { get; init; } - public bool FixupFile { get; init; } - public NAudio.Lame.LameConfig LameConfig { get; init; } - public bool Downsample { get; init; } - public bool MatchSourceBitrate { get; init; } - public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters; + public class DownloadOptions : IDownloadOptions, IDisposable + { + public event EventHandler DownloadSpeedChanged; + public LibraryBook LibraryBook { get; } + public LibraryBookDto LibraryBookDto { get; } + public string DownloadUrl { get; } + public string UserAgent { get; } + public string AudibleKey { get; init; } + public string AudibleIV { get; init; } + public AaxDecrypter.OutputFormat OutputFormat { get; init; } + public bool TrimOutputToChapterLength { get; init; } + public bool RetainEncryptedFile { get; init; } + public bool StripUnabridged { get; init; } + public bool CreateCueSheet { get; init; } + public bool DownloadClipsBookmarks { get; init; } + public long DownloadSpeedBps { get; init; } + public ChapterInfo ChapterInfo { get; init; } + public bool FixupFile { get; init; } + public NAudio.Lame.LameConfig LameConfig { get; init; } + public bool Downsample { get; init; } + public bool MatchSourceBitrate { get; init; } + public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters; - public string GetMultipartFileName(MultiConvertFileProperties props) - => Templates.ChapterFile.GetFilename(LibraryBookDto, props); + public string GetMultipartFileName(MultiConvertFileProperties props) + => Templates.ChapterFile.GetFilename(LibraryBookDto, props); - public string GetMultipartTitleName(MultiConvertFileProperties props) - => Templates.ChapterTitle.GetTitle(LibraryBookDto, props); + public string GetMultipartTitleName(MultiConvertFileProperties props) + => Templates.ChapterTitle.GetTitle(LibraryBookDto, props); - public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent) - { - LibraryBookDto = ArgumentValidator - .EnsureNotNull(libraryBook, nameof(libraryBook)) - .ToDto(); - DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl)); - UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent)); + public async Task SaveClipsAndBookmarks(string fileName) + { + if (DownloadClipsBookmarks) + { + var format = Configuration.Instance.ClipsBookmarksFileFormat; - // no null/empty check for key/iv. unencrypted files do not have them - } - } + var formatExtension = format.ToString().ToLowerInvariant(); + var filePath = Path.ChangeExtension(fileName, formatExtension); + + var api = await LibraryBook.GetApiAsync(); + var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId); + + switch(format) + { + case Configuration.ClipBookmarkFormat.CSV: + RecordExporter.ToCsv(filePath, records); + break; + case Configuration.ClipBookmarkFormat.Xlsx: + RecordExporter.ToXlsx(filePath, records); + break; + case Configuration.ClipBookmarkFormat.Json: + RecordExporter.ToJson(filePath, LibraryBook, records); + break; + } + return filePath; + } + return string.Empty; + } + + private readonly IDisposable cancellation; + public void Dispose() => cancellation?.Dispose(); + + public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent) + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)); + DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl)); + UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent)); + // no null/empty check for key/iv. unencrypted files do not have them + + LibraryBookDto = LibraryBook.ToDto(); + + cancellation = + Configuration.Instance + .ObservePropertyChanged( + nameof(Configuration.DownloadSpeedLimit), + newVal => DownloadSpeedChanged?.Invoke(this, newVal)); + } + } } diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs index 47bb7687..315bcd36 100644 --- a/Source/FileManager/PersistentDictionary.cs +++ b/Source/FileManager/PersistentDictionary.cs @@ -49,10 +49,22 @@ namespace FileManager public T GetNonString(string propertyName) { var obj = GetObject(propertyName); + if (obj is null) return default; - if (obj is JValue jValue) return jValue.Value(); - if (obj is JObject jObject) return jObject.ToObject(); - return (T)obj; + if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj; + if (obj is JObject jObject) return jObject.ToObject(); + if (obj is JValue jValue) + { + if (jValue.Type == JTokenType.String && typeof(T).IsAssignableTo(typeof(Enum))) + { + return + Enum.TryParse(typeof(T), jValue.Value(), out var enumVal) + ? (T)enumVal + : Enum.GetValues(typeof(T)).Cast().First(); + } + return jValue.Value(); + } + throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}"); } public object GetObject(string propertyName) diff --git a/Source/FileManager/ReplacementCharacters.cs b/Source/FileManager/ReplacementCharacters.cs index 85b65317..4457e19f 100644 --- a/Source/FileManager/ReplacementCharacters.cs +++ b/Source/FileManager/ReplacementCharacters.cs @@ -7,7 +7,7 @@ using System.Linq; namespace FileManager { - public class Replacement : ICloneable + public record Replacement { public const int FIXED_COUNT = 6; @@ -30,8 +30,6 @@ namespace FileManager Mandatory = mandatory; } - public object Clone() => new Replacement(CharacterToReplace, ReplacementString, Description, Mandatory); - public void Update(char charToReplace, string replacementString, string description) { ReplacementString = replacementString; @@ -61,10 +59,20 @@ namespace FileManager [JsonConverter(typeof(ReplacementCharactersConverter))] public class ReplacementCharacters { - static ReplacementCharacters() + public override bool Equals(object obj) { + 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; + return true; + } + return false; } + public override int GetHashCode() => Replacements.GetHashCode(); + public static readonly ReplacementCharacters Default = IsWindows ? new() diff --git a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs index 4ec6d825..68660430 100644 --- a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs +++ b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs @@ -10,7 +10,7 @@ namespace LibationAvalonia.Controls { public static event EventHandler CellContextMenuStripNeeded; private static readonly ContextMenu ContextMenu = new(); - private static readonly AvaloniaList MenuItems = new(); + private static readonly AvaloniaList MenuItems = new(); private static readonly PropertyInfo OwningColumnProperty; static DataGridContextMenus() @@ -65,7 +65,7 @@ namespace LibationAvalonia.Controls public DataGridColumn Column { get; init; } public GridEntry GridEntry { get; init; } public ContextMenu ContextMenu { get; init; } - public AvaloniaList ContextMenuItems - => ContextMenu.Items as AvaloniaList; + public AvaloniaList ContextMenuItems + => ContextMenu.Items as AvaloniaList; } } diff --git a/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml b/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml new file mode 100644 index 00000000..3cb749aa --- /dev/null +++ b/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +