From ce2b81036fe6d4a3aaf3c06ba2ae7ac2b4816e34 Mon Sep 17 00:00:00 2001 From: MBucari Date: Mon, 17 Nov 2025 22:49:30 -0700 Subject: [PATCH] Add license and settings overrides to LibationCli - Add `LIBATION_FILES_DIR` environment variable to specify LibationFiles directory instead of appsettings.json - OptionsBase supports overriding setting - Added `EphemeralSettings` which are loaded from Settings.json once and can be modified with the `--override` command parameter - Added `get-setting` command - Prints (editable) settings and their values. Prints specified settings, or all settings if none specified - `--listEnumValues` option will list all names for a speficied enum-type settings. If no setting names are specified, prints all enum values for all enum settings. - Prints in a text-based table or bare with `-b` switch - Added `get-license` command which requests a content license and prints it as a json to stdout - Improved `liberate` command - Added `-force` option to force liberation without validation. - Added support to download with a license file supplied to stdin - Improve startup performance when downloading explicit ASIN(s) - Fix long-standing bug where cover art was not being downloading --- Source/AaxDecrypter/IDownloadOptions.cs | 1 + Source/AppScaffolding/LibationScaffolding.cs | 12 +- .../AppScaffolding/UNSAFE_MigrationHelper.cs | 45 +-- .../QueryObjects/LibraryBookQueries.cs | 15 +- Source/FileLiberator/DownloadDecryptBook.cs | 9 +- .../FileLiberator/DownloadOptions.Factory.cs | 27 +- ...Dictionary.cs => IJsonBackedDictionary.cs} | 2 +- Source/FileManager/PersistentDictionary.cs | 4 +- Source/LibationAvalonia/Program.cs | 2 +- Source/LibationCli/Options/CopyDbOptions.cs | 10 +- .../LibationCli/Options/GetLicenseOptions.cs | 66 +++++ .../LibationCli/Options/GetSettingOptions.cs | 140 +++++++++ Source/LibationCli/Options/LiberateOptions.cs | 84 +++++- .../Options/SetDownloadStatusOptions.cs | 2 +- Source/LibationCli/Options/_OptionsBase.cs | 205 ++++++++++++- .../Options/_ProcessableOptionsBase.cs | 65 +++- Source/LibationCli/Program.cs | 15 +- Source/LibationCli/Setup.cs | 22 +- Source/LibationCli/TextTableExtention.cs | 277 ++++++++++++++++++ .../Configuration.Logging.cs | 4 +- .../Configuration.PersistentSettings.cs | 10 +- Source/LibationFileManager/Configuration.cs | 2 +- ...ntDictionary.cs => EphemeralDictionary.cs} | 15 +- Source/LibationFileManager/LibationFiles.cs | 23 +- Source/LibationWinForms/Program.cs | 4 +- 25 files changed, 956 insertions(+), 105 deletions(-) rename Source/FileManager/{IPersistentDictionary.cs => IJsonBackedDictionary.cs} (97%) create mode 100644 Source/LibationCli/Options/GetLicenseOptions.cs create mode 100644 Source/LibationCli/Options/GetSettingOptions.cs create mode 100644 Source/LibationCli/TextTableExtention.cs rename Source/LibationFileManager/{MockPersistentDictionary.cs => EphemeralDictionary.cs} (80%) diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index 11e79db8..7f1199c3 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -15,6 +15,7 @@ namespace AaxDecrypter KeyPart2 = keyPart2; } + [Newtonsoft.Json.JsonConstructor] public KeyData(string keyPart1, string? keyPart2 = null) { ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1)); diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 4514869c..7ab25d22 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -79,9 +79,17 @@ namespace AppScaffolding } /// most migrations go in here - public static void RunPostConfigMigrations(Configuration config) + public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false) { - config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath); + if (ephemeralSettings) + { + var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath)); + config.LoadEphemeralSettings(settings); + } + else + { + config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath); + } AudibleApiStorage.EnsureAccountsSettingsFileExists(); // diff --git a/Source/AppScaffolding/UNSAFE_MigrationHelper.cs b/Source/AppScaffolding/UNSAFE_MigrationHelper.cs index 89597ab5..1df02c43 100644 --- a/Source/AppScaffolding/UNSAFE_MigrationHelper.cs +++ b/Source/AppScaffolding/UNSAFE_MigrationHelper.cs @@ -7,6 +7,7 @@ using LibationFileManager; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +#nullable enable namespace AppScaffolding { /// @@ -20,21 +21,21 @@ namespace AppScaffolding /// public static class UNSAFE_MigrationHelper { - public static string SettingsDirectory + public static string? SettingsDirectory => !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null ? null : value; #region appsettings.json - public static bool APPSETTINGS_TryGet(string key, out string value) + public static bool APPSETTINGS_TryGet(string key, out string? value) { bool success = false; - JToken val = null; + JToken? val = null; process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false); - value = success ? val.Value() : null; + value = success ? val?.Value() : null; return success; } @@ -59,7 +60,10 @@ namespace AppScaffolding /// True: save if contents changed. False: no not attempt save private static void process_APPSETTINGS_Json(Action action, bool save = true) { - var startingContents = File.ReadAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile); + if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile) + return; + + var startingContents = File.ReadAllText(appSettingsFile); JObject jObj; try @@ -88,32 +92,31 @@ namespace AppScaffolding #endregion #region Settings.json - public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON); - public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath); + public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON); - public static bool Settings_TryGet(string key, out string value) + public static bool Settings_TryGet(string key, out string? value) { bool success = false; - JToken val = null; + JToken? val = null; process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false); - value = success ? val.Value() : null; + value = success ? val?.Value() : null; return success; } public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType) { - JToken val = null; + JToken? val = null; process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false); return val?.Type == jTokenType; } - public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value) + public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value) { - JToken val = null; + JToken? val = null; process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false); @@ -155,10 +158,10 @@ namespace AppScaffolding if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array)) return false; - JArray array = null; - process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath)); + JArray? array = null; + process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray); - length = array.Count; + length = array?.Count ?? 0; return true; } @@ -169,8 +172,7 @@ namespace AppScaffolding process_SettingsJson(jObj => { - var array = (JArray)jObj.SelectToken(jsonPath); - array.Add(newValue); + (jObj.SelectToken(jsonPath) as JArray)?.Add(newValue); }); } @@ -198,8 +200,7 @@ namespace AppScaffolding process_SettingsJson(jObj => { - var array = (JArray)jObj.SelectToken(jsonPath); - if (position < array.Count) + if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count) array.RemoveAt(position); }); } @@ -226,7 +227,7 @@ namespace AppScaffolding private static void process_SettingsJson(Action action, bool save = true) { // only insert if not exists - if (!SettingsJson_Exists) + if (!File.Exists(SettingsJsonPath)) return; var startingContents = File.ReadAllText(SettingsJsonPath); @@ -258,7 +259,7 @@ namespace AppScaffolding #endregion #region LibationContext.db public const string LIBATION_CONTEXT = "LibationContext.db"; - public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT); + public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT); public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile); #endregion } diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 4a3e60e1..5a2275ab 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -25,16 +25,17 @@ namespace DataLayer .Where(c => !c.Book.IsEpisodeParent() || includeParents) .ToList(); - public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) - => context + public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId, bool caseSensative = true) + { + var libraryQuery + = context .LibraryBooks .AsNoTrackingWithIdentityResolution() - .GetLibraryBook(productId); + .GetLibrary(); - public static LibraryBook? GetLibraryBook(this IQueryable library, string productId) - => library - .GetLibrary() - .SingleOrDefault(lb => lb.Book.AudibleProductId == productId); + return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId) + : libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId); + } /// This is still IQueryable. YOU MUST CALL ToList() YOURSELF public static IQueryable GetLibrary(this IQueryable library) diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index de4d35d1..eeaf06c0 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -23,6 +23,11 @@ namespace FileLiberator private CancellationTokenSource? cancellationTokenSource; private AudiobookDownloadBase? abDownloader; + /// + /// Optional override to supply license info directly instead of querying the api based on Configuration options + /// + public DownloadOptions.LicenseInfo? LicenseInfo { get; set; } + public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); public override async Task CancelAsync() { @@ -44,7 +49,9 @@ namespace FileLiberator DownloadValidation(libraryBook); var api = await libraryBook.GetApiAsync(); - using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken); + + LicenseInfo ??= await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration.Instance, cancellationToken); + using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration.Instance, LicenseInfo); var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile) diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 9d4215b6..4c1a8eb4 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -4,9 +4,9 @@ using AudibleApi.Common; using AudibleUtilities.Widevine; using DataLayer; using Dinah.Core; -using DocumentFormat.OpenXml.Wordprocessing; using LibationFileManager; using NAudio.Lame; +using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; @@ -21,9 +21,9 @@ namespace FileLiberator; public partial class DownloadOptions { /// - /// Initiate an audiobook download from the audible api. + /// Requests a download license from the Api using the Configuration settings to choose the appropriate content. /// - public static async Task InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token) + public static async Task GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token) { var license = await ChooseContent(api, libraryBook, config, token); Serilog.Log.Logger.Debug("Content License {@License}", new @@ -65,14 +65,20 @@ public partial class DownloadOptions license.ContentMetadata.ChapterInfo = metadata.ChapterInfo; token.ThrowIfCancellationRequested(); - return BuildDownloadOptions(libraryBook, config, license); + return license; } - private class LicenseInfo + public class LicenseInfo { - public DrmType DrmType { get; } - public ContentMetadata ContentMetadata { get; } - public KeyData[]? DecryptionKeys { get; } + public DrmType DrmType { get; set; } + public ContentMetadata ContentMetadata { get; set; } + public KeyData[]? DecryptionKeys { get; set; } + + [JsonConstructor] + private LicenseInfo() + { + ContentMetadata = null!; + } public LicenseInfo(ContentLicense license, IEnumerable? keys = null) { DrmType = license.DrmType; @@ -159,7 +165,10 @@ public partial class DownloadOptions } } - private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) + /// + /// Builds DownloadOptions from the given LibraryBook, Configuration, and LicenseInfo. + /// + public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { long chapterStartMs = config.StripAudibleBrandAudio diff --git a/Source/FileManager/IPersistentDictionary.cs b/Source/FileManager/IJsonBackedDictionary.cs similarity index 97% rename from Source/FileManager/IPersistentDictionary.cs rename to Source/FileManager/IJsonBackedDictionary.cs index 865733d1..ad508588 100644 --- a/Source/FileManager/IPersistentDictionary.cs +++ b/Source/FileManager/IJsonBackedDictionary.cs @@ -5,7 +5,7 @@ using System.Linq; #nullable enable namespace FileManager; -public interface IPersistentDictionary +public interface IJsonBackedDictionary { bool Exists(string propertyName); string? GetString(string propertyName, string? defaultValue = null); diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs index 9bc86746..94f8012b 100644 --- a/Source/FileManager/PersistentDictionary.cs +++ b/Source/FileManager/PersistentDictionary.cs @@ -8,7 +8,7 @@ using Newtonsoft.Json.Linq; #nullable enable namespace FileManager { - public class PersistentDictionary : IPersistentDictionary + public class PersistentDictionary : IJsonBackedDictionary { public string Filepath { get; } public bool IsReadOnly { get; } @@ -59,7 +59,7 @@ namespace FileManager objectCache[propertyName] = defaultValue; return defaultValue; } - return IPersistentDictionary.UpCast(obj); + return IJsonBackedDictionary.UpCast(obj); } public object? GetObject(string propertyName) diff --git a/Source/LibationAvalonia/Program.cs b/Source/LibationAvalonia/Program.cs index d0c7a3ec..d2179412 100644 --- a/Source/LibationAvalonia/Program.cs +++ b/Source/LibationAvalonia/Program.cs @@ -105,7 +105,7 @@ namespace LibationAvalonia try { //Try to log the error message before displaying the crash dialog - if (Configuration.Instance.LoggingEnabled) + if (Configuration.Instance.SerilogInitialized) Serilog.Log.Logger.Error(exception, "CRASH"); else LogErrorWithoutSerilog(exception); diff --git a/Source/LibationCli/Options/CopyDbOptions.cs b/Source/LibationCli/Options/CopyDbOptions.cs index 3dc728ee..dd7c5fdb 100644 --- a/Source/LibationCli/Options/CopyDbOptions.cs +++ b/Source/LibationCli/Options/CopyDbOptions.cs @@ -6,15 +6,15 @@ using System; using System.Linq; using System.Threading.Tasks; +#nullable enable namespace LibationCli { [Verb("copydb", HelpText = "Copy the local sqlite database to postgres.")] public class CopyDbOptions : OptionsBase - { - [Option(shortName: 'c', longName: "connectionString")] - public string PostgresConnectionString { get; set; } - - protected override async Task ProcessAsync() + { + [Option(shortName: 'c', longName: "connectionString", HelpText = "Postgres Database connection string")] + public string? PostgresConnectionString { get; set; } + protected override async Task ProcessAsync() { var srcConnectionString = SqliteStorage.ConnectionString; var destConnectionString = PostgresConnectionString ?? Configuration.Instance.PostgresqlConnectionString; diff --git a/Source/LibationCli/Options/GetLicenseOptions.cs b/Source/LibationCli/Options/GetLicenseOptions.cs new file mode 100644 index 00000000..ea8296c8 --- /dev/null +++ b/Source/LibationCli/Options/GetLicenseOptions.cs @@ -0,0 +1,66 @@ +using ApplicationServices; +using CommandLine; +using DataLayer; +using FileLiberator; +using LibationFileManager; +using Newtonsoft.Json; +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.")] +internal class GetLicenseOptions : OptionsBase +{ + + [Value(0, MetaName = "[asin]", HelpText = "Product ID of book to request license for.", Required = true)] + public string? Asin { get; set; } + protected override async Task ProcessAsync() + { + if (string.IsNullOrWhiteSpace(Asin)) + { + Console.Error.WriteLine("ASIN is required."); + return; + } + + using var dbContext = DbContexts.GetContext(); + if (dbContext.GetLibraryBook_Flat_NoTracking(Asin) is not LibraryBook libraryBook) + { + Console.Error.WriteLine($"Book not found with asin={Asin}"); + return; + } + + var api = await libraryBook.GetApiAsync(); + var license = await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration.Instance, default); + + var jsonSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + Converters = [new StringEnumConverter(), new ByteArrayHexConverter()] + }; + + var licenseJson = JsonConvert.SerializeObject(license, Formatting.Indented, jsonSettings); + Console.WriteLine(licenseJson); + } +} + +class ByteArrayHexConverter : JsonConverter +{ + public override bool CanConvert(Type objectType) => objectType == typeof(byte[]); + + public override bool CanRead => false; + public override bool CanWrite => true; + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + => throw new NotSupportedException(); + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value is byte[] array) + { + writer.WriteValue(Convert.ToHexStringLower(array)); + } + } +} diff --git a/Source/LibationCli/Options/GetSettingOptions.cs b/Source/LibationCli/Options/GetSettingOptions.cs new file mode 100644 index 00000000..b9de10ff --- /dev/null +++ b/Source/LibationCli/Options/GetSettingOptions.cs @@ -0,0 +1,140 @@ +using CommandLine; +using Dinah.Core; +using FileManager; +using LibationFileManager; +using System; +using System.Collections.Generic; +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.")] +internal class GetSettingOptions : OptionsBase +{ + [Option('l', "listEnumValues", HelpText = "List all value possibilities of enum types")] + public bool ListEnumValues { get; set; } + + [Option('b', "bare", HelpText = "Print bare list without table decoration")] + public bool Bare { get; set; } + + [Value(0, MetaName = "[setting names]", HelpText = "Optional names of settings to get.")] + public IEnumerable? SettingNames { get; set; } + + protected override Task ProcessAsync() + { + var configs = GetConfigOptions(); + if (SettingNames?.Any() is true) + { + //Operate over listed settings + foreach (var item in SettingNames.ExceptBy(configs.Select(c => c.Name), c => c, StringComparer.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($"Unknown Setting Name: {item}"); + } + + var validSettings = configs.IntersectBy(SettingNames, a => a.Name, StringComparer.OrdinalIgnoreCase); + if (ListEnumValues) + { + foreach (var item in validSettings.Where(s => !s.SettingType.IsEnum)) + { + Console.Error.WriteLine($"Setting '{item.Name}' is not an enum type"); + } + + PrintEnumValues(validSettings.Where(s => s.SettingType.IsEnum)); + } + else + { + PrintConfigOption(validSettings); + } + } + else + { + //Operate over all settings + if (ListEnumValues) + { + PrintEnumValues(configs); + } + else + { + PrintConfigOption(configs); + } + } + return Task.CompletedTask; + } + + private void PrintConfigOption(IEnumerable options) + { + if (Bare) + { + foreach (var option in options) + { + Console.WriteLine($"{option.Name}={option.Value}"); + } + } + else + { + Console.Out.DrawTable(options, new(), o => o.Name, o => o.Value, o => o.Type); + } + } + + private void PrintEnumValues(IEnumerable options) + { + foreach (var item in options.Where(s => s.SettingType.IsEnum)) + { + var enumValues = Enum.GetNames(item.SettingType); + if (Bare) + { + Console.WriteLine(string.Join(Environment.NewLine, enumValues.Select(e => $"{item.Name}.{e}"))); + } + else + { + Console.Out.DrawTable(enumValues, new TextTableOptions(), new ColumnDef(item.Name, t => t)); + } + } + } + + private ConfigOption[] GetConfigOptions() + { + var configs = GetConfigurationProperties().Where(o=> o.PropertyType != typeof(ReplacementCharacters)).Select(p => new ConfigOption(p)); + var replacements = GetConfigurationProperties().SingleOrDefault(o => o.PropertyType == typeof(ReplacementCharacters))?.GetValue(Configuration.Instance) as ReplacementCharacters; + + if (replacements is not null) + { + //Don't reorder after concat to keep replacements grouped together at the bottom + configs = configs.Concat(replacements.Replacements.Select(r => new ConfigOption(r))); + } + + return configs.ToArray(); + } + + private record EnumOption(string EnumOptionValue); + private record ConfigOption + { + public string Name { get; } + public string Type { get; } + public Type SettingType { get; } + public string Value { get; } + public ConfigOption(PropertyInfo propertyInfo) + { + Name = propertyInfo.Name; + SettingType = propertyInfo.PropertyType; + Type = GetTypeString(SettingType); + Value = propertyInfo.GetValue(Configuration.Instance)?.ToString() is not string value ? "[null]" + : SettingType == typeof(string) || SettingType == typeof(LongPath) ? value.SurroundWithQuotes() + : value; + } + + public ConfigOption(Replacement replacement) + { + Name = GetReplacementName(replacement); + SettingType = typeof(string); + Type = GetTypeString(SettingType); + Value = replacement.ReplacementString.SurroundWithQuotes(); + } + + private static string GetTypeString(Type type) + => type.IsEnum ? $"{type.Name} (enum)": type.Name; + } +} diff --git a/Source/LibationCli/Options/LiberateOptions.cs b/Source/LibationCli/Options/LiberateOptions.cs index 9f8fdf63..0ff87aaa 100644 --- a/Source/LibationCli/Options/LiberateOptions.cs +++ b/Source/LibationCli/Options/LiberateOptions.cs @@ -1,44 +1,108 @@ -using CommandLine; +using ApplicationServices; +using CommandLine; using DataLayer; using FileLiberator; +using LibationCli.Options; using LibationFileManager; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; 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. " - + "Optional: use 'pdf' flag to only download pdfs.")] + [Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs.\n" + + "Optional: specify asin(s) of book(s) to liberate.\n" + + "Optional: reads a license file from standard input.")] public class LiberateOptions : ProcessableOptionsBase { [Option(shortName: 'p', longName: "pdf", Required = false, Default = false, HelpText = "Flag to only download pdfs")] public bool PdfOnly { get; set; } - protected override Task ProcessAsync() + [Option(shortName: 'f', longName: "force", Required = false, Default = false, HelpText = "Force the book to re-download")] + public bool Force { get; set; } + + protected override async Task ProcessAsync() { if (AudibleFileStorage.BooksDirectory is null) { Console.Error.WriteLine("Error: Books directory is not set. Please configure the 'Books' setting in Settings.json."); - return Task.CompletedTask; + return; } - return PdfOnly - ? RunAsync(CreateProcessable()) - : RunAsync(CreateBackupBook()); + if (Console.IsInputRedirected) + { + Console.WriteLine("Reading license file from standard input."); + using var reader = new StreamReader(Console.OpenStandardInput()); + var stdIn = await reader.ReadToEndAsync(); + try + { + + var jsonSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + Converters = [new StringEnumConverter(), new ByteArrayHexConverter()] + }; + var licenseInfo = JsonConvert.DeserializeObject(stdIn, jsonSettings); + + if (licenseInfo?.ContentMetadata?.ContentReference?.Asin is not string asin) + { + Console.Error.WriteLine("Error: License file is missing ASIN information."); + return; + } + + LibraryBook libraryBook; + using (var dbContext = DbContexts.GetContext()) + { + if (dbContext.GetLibraryBook_Flat_NoTracking(asin) is not LibraryBook lb) + { + Console.Error.WriteLine($"Book not found with asin={asin}"); + return; + } + libraryBook = lb; + } + + SetDownloadedStatus(libraryBook); + await ProcessOneAsync(GetProcessable(licenseInfo), libraryBook, true); + } + catch + { + Console.Error.WriteLine("Error: Failed to read license file from standard input. Please ensure the input is a valid license file in JSON format."); + } + } + else + { + await RunAsync(GetProcessable(), SetDownloadedStatus); + } } - private static Processable CreateBackupBook() + private Processable GetProcessable(DownloadOptions.LicenseInfo? licenseInfo = null) + => PdfOnly ? CreateProcessable() : CreateBackupBook(licenseInfo); + + private void SetDownloadedStatus(LibraryBook lb) + { + if (Force) + { + lb.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; + lb.Book.UserDefinedItem.SetPdfStatus(LiberatedStatus.NotLiberated); + } + } + + private static Processable CreateBackupBook(DownloadOptions.LicenseInfo? licenseInfo) { var downloadPdf = CreateProcessable(); //Chain pdf download on DownloadDecryptBook.Completed - void onDownloadDecryptBookCompleted(object sender, LibraryBook e) + void onDownloadDecryptBookCompleted(object? sender, LibraryBook e) { // this is fast anyway. run as sync for easy exception catching downloadPdf.TryProcessAsync(e).GetAwaiter().GetResult(); } var downloadDecryptBook = CreateProcessable(onDownloadDecryptBookCompleted); + downloadDecryptBook.LicenseInfo = licenseInfo; return downloadDecryptBook; } } diff --git a/Source/LibationCli/Options/SetDownloadStatusOptions.cs b/Source/LibationCli/Options/SetDownloadStatusOptions.cs index 4205542a..b563c152 100644 --- a/Source/LibationCli/Options/SetDownloadStatusOptions.cs +++ b/Source/LibationCli/Options/SetDownloadStatusOptions.cs @@ -21,7 +21,7 @@ namespace LibationCli [Option(shortName: 'n', longName: "not-downloaded", Group = "Download Status", HelpText = "set download status to 'Not Downloaded'")] public bool SetNotDownloaded { get; set; } - [Option("force", HelpText = "Set the download status regardless of whether the book's audio file can be found. Only one download status option may be used with this option.")] + [Option('f', "force", HelpText = "Set the download status regardless of whether the book's audio file can be found. Only one download status option may be used with this option.")] public bool Force { get; set; } [Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books on which to set download status.")] diff --git a/Source/LibationCli/Options/_OptionsBase.cs b/Source/LibationCli/Options/_OptionsBase.cs index cce3f0ee..5aecac9f 100644 --- a/Source/LibationCli/Options/_OptionsBase.cs +++ b/Source/LibationCli/Options/_OptionsBase.cs @@ -1,15 +1,44 @@ using CommandLine; +using CsvHelper.TypeConversion; +using Dinah.Core; +using FileManager; +using LibationFileManager; using System; +using System.Collections.Generic; +using System.ComponentModel; using System.IO; +using System.Linq; using System.Reflection; using System.Threading.Tasks; +#nullable enable namespace LibationCli { public abstract class OptionsBase { + [Option(longName: "libationFiles", HelpText = "Path to Libation Files directory")] + public DirectoryInfo? LibationFiles { get; set; } + + [Option('o', "override", HelpText = "Configuration setting override", MetaValue = "[SettingName]=\"Setting_Value\"")] + public IEnumerable? SettingOverrides { get; set; } + public async Task Run() { + if (LibationFiles?.Exists is true) + { + Environment.SetEnvironmentVariable(LibationFileManager.LibationFiles.LIBATION_FILES_DIR, LibationFiles.FullName); + } + + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + Setup.Initialize(); + + if (SettingOverrides is not null) + ProcessSettingsOverrides(); + try { await ProcessAsync(); @@ -17,20 +46,39 @@ namespace LibationCli catch (Exception ex) { Environment.ExitCode = (int)ExitCode.RunTimeError; - PrintVerbUsage(new string[] - { + PrintVerbUsage( "ERROR", "=====", ex.Message, "", - ex.StackTrace - }); + ex.StackTrace); } } - protected void PrintVerbUsage(params string[] linesBeforeUsage) + private static bool TryParseEnum(Type enumType, string? value, out object? result) { - var verb = GetType().GetCustomAttribute().Name; + var values = Enum.GetNames(enumType); + + if (values.Select(n => n.ToLowerInvariant()).Distinct().Count() != values.Length) + { + //Enum names must be case sensitive. + return Enum.TryParse(enumType, value, out result); + } + + for (int i = 0; i < values.Length; i++) + { + if (values[i].Equals(value, StringComparison.OrdinalIgnoreCase)) + { + return Enum.TryParse(enumType, values[i], out result); + } + } + result = null; + return false; + } + + protected void PrintVerbUsage(params string?[] linesBeforeUsage) + { + var verb = GetType().GetCustomAttribute()?.Name; var helpText = new HelpVerb { HelpType = verb }.GetHelpText(); helpText.AddPreOptionsLines(linesBeforeUsage); helpText.AddPreOptionsLine(""); @@ -46,5 +94,150 @@ namespace LibationCli } protected abstract Task ProcessAsync(); + + protected IOrderedEnumerable GetConfigurationProperties() + => typeof(Configuration).GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.CustomAttributes.Any(a => a.AttributeType == typeof(DescriptionAttribute))) + .Where(p => !p.Name.In(ExcludedSettings)) + .OrderBy(p => p.PropertyType.IsEnum) + .ThenBy(p => p.PropertyType.Name) + .ThenBy(p => p.Name); + + private readonly string[] ExcludedSettings = [ + nameof(Configuration.LibationFiles), + nameof(Configuration.GridScaleFactor), + nameof(Configuration.GridFontScaleFactor), + nameof(Configuration.GridColumnsVisibilities), + nameof(Configuration.GridColumnsDisplayIndices), + nameof(Configuration.GridColumnsWidths)]; + + private void ProcessSettingsOverrides() + { + var configProperties = GetConfigurationProperties().ToArray(); + foreach (var option in SettingOverrides?.Where(p => p.Property is not null && p.Value is not null) ?? []) + { + if (option.Property?.StartsWithInsensitive(ReplacePrefix) is true) + { + OverrideReplacement(option); + } + else if (configProperties.FirstOrDefault(p => p.Name.EqualsInsensitive(option.Property)) is not PropertyInfo property) + { + Console.Error.WriteLine($"Unknown configuration property '{option.Property}'"); + } + else if (property.PropertyType == typeof(string)) + { + property.SetValue(Configuration.Instance, option.Value?.Trim()); + } + else if (property.PropertyType == typeof(bool) && bool.TryParse(option.Value?.Trim(), out var bVal)) + { + property.SetValue(Configuration.Instance, bVal); + } + else if (property.PropertyType == typeof(int) && int.TryParse(option.Value?.Trim(), out var intVal)) + { + property.SetValue(Configuration.Instance, intVal); + } + else if (property.PropertyType == typeof(long) && long.TryParse(option.Value?.Trim(), out var longVal)) + { + property.SetValue(Configuration.Instance, longVal); + } + else if (property.PropertyType == typeof(LongPath)) + { + var value = option.Value is null ? null : (LongPath)option.Value.Trim(); + property.SetValue(Configuration.Instance, value); + } + else if (property.PropertyType.IsEnum && TryParseEnum(property.PropertyType, option.Value?.Trim(), out var enumVal)) + { + property.SetValue(Configuration.Instance, enumVal); + } + else + { + Console.Error.WriteLine($"Cannot set configuration property '{property.Name}' of type '{property.PropertyType}' with value '{option.Value}'"); + } + } + } + + private static void OverrideReplacement(OptionOverride option) + { + List newReplacements = []; + + bool addedToList = false; + foreach (var r in Configuration.Instance.ReplacementCharacters.Replacements) + { + if (GetReplacementName(r).EqualsInsensitive(option.Property)) + { + var newReplacement = new Replacement(r.CharacterToReplace, option.Value ?? string.Empty, r.Description) + { + Mandatory = r.Mandatory + }; + newReplacements.Add(newReplacement); + addedToList = true; + } + else + { + newReplacements.Add(r); + } + } + + if (!addedToList) + { + var charToReplace = option.Property!.Substring(ReplacePrefix.Length); + if (charToReplace.Length != 1) + { + Console.Error.WriteLine($"Invalid character to replace: '{charToReplace}'"); + } + else + { + newReplacements.Add(new(charToReplace[0], option.Value ?? string.Empty, "")); + } + } + Configuration.Instance.ReplacementCharacters = new ReplacementCharacters { Replacements = newReplacements }; + } + + const string ReplacePrefix = "Replace_"; + protected static string GetReplacementName(Replacement r) + => !r.Mandatory ? ReplacePrefix + r.CharacterToReplace + : r.CharacterToReplace == '\0' ? ReplacePrefix + "OtherInvalid" + : r.CharacterToReplace == '/' ? ReplacePrefix + "Slash" + : r.CharacterToReplace == '\\' ? ReplacePrefix + "BackSlash" + : r.Description == "Open Quote" ? ReplacePrefix + "OpenQuote" + : r.Description == "Close Quote" ? ReplacePrefix + "CloseQuote" + : r.Description == "Other Quote" ? ReplacePrefix + "OtherQuote" + : ReplacePrefix + r.Description.Replace(" ", ""); + + public class OptionOverride + { + public string? Property { get; } + public string? Value { get; } + + public OptionOverride(string value) + { + if (value is null) + return; + + //Special case of Replace_= settings + var start + = value.StartsWithInsensitive(ReplacePrefix + "=") + ? value.IndexOf('=', ReplacePrefix.Length + 1) + : value.IndexOf('='); + + if (start < 1) + return; + Property = value[..start]; + + //Don't trim here. Trim before parsing the value if needed, otherwise + //preserve for settings which utilize white space (e.g. Replacements) + Value = value[(start + 1)..]; + + if (Value.StartsWith('"') && Value.EndsWith('"')) + { + Value = Value[1..]; + } + + if (Value.EndsWith('"')) + { + Value = Value[..^1]; + } + } + } } } diff --git a/Source/LibationCli/Options/_ProcessableOptionsBase.cs b/Source/LibationCli/Options/_ProcessableOptionsBase.cs index 6ad35f7e..6450d20e 100644 --- a/Source/LibationCli/Options/_ProcessableOptionsBase.cs +++ b/Source/LibationCli/Options/_ProcessableOptionsBase.cs @@ -1,28 +1,34 @@ using ApplicationServices; using CommandLine; using DataLayer; -using Dinah.Core; using FileLiberator; +using LibationFileManager; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +#nullable enable namespace LibationCli { public abstract class ProcessableOptionsBase : OptionsBase { [Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books to process.")] - public IEnumerable Asins { get; set; } + public IEnumerable? Asins { get; set; } - protected static TProcessable CreateProcessable(EventHandler completedAction = null) + protected static TProcessable CreateProcessable(EventHandler? completedAction = null) where TProcessable : Processable, new() { var progressBar = new ConsoleProgressBar(Console.Out); var strProc = new TProcessable(); + LibraryBook? currentLibraryBook = null; - strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}"); + strProc.Begin += (o, e) => + { + currentLibraryBook = e; + Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}"); + }; strProc.Completed += (o, e) => { @@ -46,24 +52,57 @@ namespace LibationCli strProc.StreamingTimeRemaining += (_, e) => progressBar.RemainingTime = e; strProc.StreamingProgressChanged += (_, e) => progressBar.Progress = e.ProgressPercentage; + if (strProc is AudioDecodable audDec) + { + audDec.RequestCoverArt += (_,_) => + { + 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); + + return PictureStorage.GetPictureSynchronously(quality); + }; + } + return strProc; } - protected async Task RunAsync(Processable Processable) + protected async Task RunAsync(Processable Processable, Action? config = null) { - var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking(); - - if (Asins.Any()) + if (Asins?.Any() is true) { - var asinsLower = Asins.Select(a => a.TrimStart('[').TrimEnd(']').ToLower()).ToArray(); - - foreach (var lb in libraryBooks.Where(lb => lb.Book.AudibleProductId.ToLower().In(asinsLower))) - await ProcessOneAsync(Processable, lb, true); + foreach (var asin in Asins.Select(a => a.TrimStart('[').TrimEnd(']'))) + { + LibraryBook? lb = null; + using (var dbContext = DbContexts.GetContext()) + { + lb = dbContext.GetLibraryBook_Flat_NoTracking(asin, caseSensative: false); + } + if (lb is not null) + { + config?.Invoke(lb); + await ProcessOneAsync(Processable, lb, true); + } + else + { + var msg = $"Book with ASIN '{asin}' not found in library. Skipping."; + Console.Error.WriteLine(msg); + Serilog.Log.Logger.Error(msg); + } + } } else { + var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking(); foreach (var lb in Processable.GetValidLibraryBooks(libraryBooks)) + { + config?.Invoke(lb); await ProcessOneAsync(Processable, lb, false); + } } var done = "Done. All books have been processed"; @@ -71,7 +110,7 @@ namespace LibationCli Serilog.Log.Logger.Information(done); } - private static async Task ProcessOneAsync(Processable Processable, LibraryBook libraryBook, bool validate) + protected async Task ProcessOneAsync(Processable Processable, LibraryBook libraryBook, bool validate) { try { diff --git a/Source/LibationCli/Program.cs b/Source/LibationCli/Program.cs index a6880c7d..efc4636e 100644 --- a/Source/LibationCli/Program.cs +++ b/Source/LibationCli/Program.cs @@ -19,7 +19,7 @@ namespace LibationCli public readonly static Type[] VerbTypes = Setup.LoadVerbs(); static async Task Main(string[] args) { - + Console.OutputEncoding = Console.InputEncoding = System.Text.Encoding.UTF8; #if DEBUG string input = ""; @@ -32,6 +32,9 @@ namespace LibationCli //input = " scan rmcrackan"; //input = " help set-status"; //input = " liberate "; + //input = "get-setting -o Replace_OpenQuote=[ "; + //input = "get-setting "; + //input = "liberate B017V4NOZ0 --force -o Books=\"./Books\""; // note: this hack will fail for quoted file paths with spaces because it will break on those spaces if (!string.IsNullOrWhiteSpace(input)) @@ -56,15 +59,6 @@ namespace LibationCli else { //Everything parsed correctly, so execute the command - - //***********************************************// - // // - // do not use Configuration before this line // - // // - //***********************************************// - Setup.Initialize(); - - // if successfully parsed // async: run parsed options await result.WithParsedAsync(opt => opt.Run()); } @@ -108,6 +102,7 @@ namespace LibationCli private static void ConfigureParser(ParserSettings settings) { + settings.AllowMultiInstance = true; settings.AutoVersion = false; settings.AutoHelp = false; } diff --git a/Source/LibationCli/Setup.cs b/Source/LibationCli/Setup.cs index 985bea37..59ff5afe 100644 --- a/Source/LibationCli/Setup.cs +++ b/Source/LibationCli/Setup.cs @@ -1,6 +1,8 @@ using AppScaffolding; using CommandLine; +using LibationFileManager; using System; +using System.IO; using System.Linq; using System.Reflection; @@ -17,7 +19,19 @@ namespace LibationCli //***********************************************// var config = LibationScaffolding.RunPreConfigMigrations(); - LibationScaffolding.RunPostConfigMigrations(config); + if (!Directory.Exists(config.LibationFiles.Location)) + { + Console.Error.WriteLine($"Cannot find LibationFiles at {config.LibationFiles.Location}"); + PrintLibationFilestipAndExit(); + } + + if (!File.Exists(config.LibationFiles.SettingsFilePath)) + { + Console.Error.WriteLine($"Cannot find settings files at {config.LibationFiles.SettingsFilePath}"); + PrintLibationFilestipAndExit(); + } + + LibationScaffolding.RunPostConfigMigrations(config, ephemeralSettings: true); #if classic LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config); @@ -26,6 +40,12 @@ namespace LibationCli #endif } + static void PrintLibationFilestipAndExit() + { + Console.Error.WriteLine($"Override LibationFiles directory location with '--libationFiles' option or '{LibationFiles.LIBATION_FILES_DIR}' environment variable."); + Environment.Exit(-1); + } + public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly() .GetTypes() .Where(t => t.GetCustomAttribute() is not null) diff --git a/Source/LibationCli/TextTableExtention.cs b/Source/LibationCli/TextTableExtention.cs new file mode 100644 index 00000000..ff86db8f --- /dev/null +++ b/Source/LibationCli/TextTableExtention.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Linq.Expressions; + +#nullable enable +namespace LibationCli; + +public enum Justify +{ + Left, + Right, + Center +} + +public class TextTableOptions +{ + public Justify Justify { get; set; } + public Justify CenterTiebreak { get; set; } + public char PaddingCharacter { get; set; } = ' '; + public int SideBorderPadding { get; set; } = 1; + public int IntercellPadding { get; set; } = 1; + public bool DrawBorder { get; set; } = true; + public bool DrawHeader { get; set; } = true; + public BorderDefinition Border { get; set; } = BorderDefinition.LightRounded; +} + +public record BorderDefinition +{ + public char Vertical { get; set; } + public char Horizontal { get; set; } + public char VerticalSeparator { get; set; } + public char HorizontalSeparator { get; set; } + public char CornerTopLeft { get; set; } + public char CornerTopRight { get; set; } + public char CornerBottomLeft { get; set; } + public char CornerBottomRight { get; set; } + public char Tee { get; set; } + public char TeeTop { get; set; } + public char TeeBottom { get; set; } + public char TeeLeft { get; set; } + public char TeeRight { get; set; } + + public BorderDefinition( + char vertical, + char horizontal, + char verticalSeparator, + char horizontalSeparator, + char cornerTopLef, + char cornerTopRight, + char cornerBottomLeft, + char cornerBottomRight, + char tee, + char teeTop, + char teeBottom, + char teeLeft, + char teeRight) + { + Vertical = vertical; + Horizontal = horizontal; + VerticalSeparator = verticalSeparator; + HorizontalSeparator = horizontalSeparator; + CornerTopLeft = cornerTopLef; + CornerTopRight = cornerTopRight; + CornerBottomLeft = cornerBottomLeft; + CornerBottomRight = cornerBottomRight; + Tee = tee; + TeeTop = teeTop; + TeeBottom = teeBottom; + TeeLeft = teeLeft; + TeeRight = teeRight; + } + + public void TestPrint(TextWriter writer) + => writer.DrawTable([], new TextTableOptions { Border = this }, t => t.ColA, t => t.ColB, t => t.ColC); + + public static BorderDefinition Ascii => new BorderDefinition('|', '-', '|', '-', '-', '-', '-', '-', '|', '-', '-', '|', '|'); + public static BorderDefinition Light => new BorderDefinition('│', '─', '│', '─', '┌', '┐', '└', '┘', '┼', '┬', '┴', '├', '┤'); + public static BorderDefinition Heavy => new BorderDefinition('┃', '━', '┃', '━', '┏', '┓', '┗', '┛', '╋', '┳', '┻', '┣', '┫'); + public static BorderDefinition Double => new BorderDefinition('║', '═', '║', '═', '╔', '╗', '╚', '╝', '╬', '╦', '╩', '╠', '╣'); + public static BorderDefinition LightRounded => Light with { CornerTopLeft = '╭', CornerTopRight = '╮', CornerBottomLeft = '╰', CornerBottomRight = '╯' }; + public static BorderDefinition DoubleHorizontal => Light with { HorizontalSeparator = '═', Tee = '╪', TeeLeft = '╞', TeeRight = '╡' }; + public static BorderDefinition DoubleVertical => Light with { VerticalSeparator = '║', Tee = '╫', TeeTop = '╥', TeeBottom = '╨' }; + public static BorderDefinition DoubleOuter => Double with { VerticalSeparator = '│', HorizontalSeparator = '─', TeeLeft = '╟', TeeRight = '╢', Tee = '┼', TeeTop = '╤', TeeBottom = '╧' }; + public static BorderDefinition DoubleInner => Light with { VerticalSeparator = '║', HorizontalSeparator = '═', TeeLeft = '╞', TeeRight = '╡', Tee = '╬', TeeTop = '╥', TeeBottom = '╨' }; + + private record TestObject(string ColA, string ColB, string ColC); +} + +public record ColumnDef(string ColumnName, Func ValueGetter); + +public static class TextTableExtention +{ + /// + /// Draw a text-based table to the provided TextWriter. + /// + /// Data row type + /// + /// Data rows to be drawn + /// Table drawing options + /// Data cell selector. Header name is based on member name + public static void DrawTable(this TextWriter textWriter, IEnumerable rows, TextTableOptions options, params Expression>[] columnSelectors) + { + //Convert MemberExpression to ColumnDef + var columnDefs = new ColumnDef[columnSelectors.Length]; + for (int i = 0; i < columnDefs.Length; i++) + { + var exp = columnSelectors[i].Body as MemberExpression + ?? throw new ArgumentException($"Expression at index {i} is not a member access expression", nameof(columnSelectors)); + + columnDefs[i] = new ColumnDef(exp.Member.Name, columnSelectors[i].Compile()); + } + + textWriter.DrawTable(rows, options, columnDefs); + } + + /// + /// Draw a text-based table to the provided TextWriter. + /// + /// Data row type + /// + /// Data rows to be drawn + /// Table drawing options + /// Column header name and cell value selector. + public static void DrawTable(this TextWriter textWriter, IEnumerable rows, TextTableOptions options, params ColumnDef[] columnSelectors) + { + var rowsArray = rows.ToArray(); + var colNames = columnSelectors.Select(c => c.ColumnName).ToArray(); + + var colWidths = new int[columnSelectors.Length]; + for (int i = 0; i < columnSelectors.Length; i++) + { + var nameWidth = options.DrawHeader ? StrLen(colNames[i]) : 0; + var maxValueWidth = rowsArray.Length == 0 ? 0 : rows.Max(o => StrLen(columnSelectors[i].ValueGetter(o))); + colWidths[i] = Math.Max(nameWidth, maxValueWidth); + } + + textWriter.DrawTop(colWidths, options); + textWriter.DrawHeader(colNames, colWidths, options); + foreach (var row in rowsArray) + { + textWriter.DrawLeft(options, options.Border.Vertical, options.PaddingCharacter); + + var cellValues = columnSelectors.Select((def, j) => def.ValueGetter(row).PadText(colWidths[j], options)); + textWriter.DrawRow(options, options.Border.VerticalSeparator, options.PaddingCharacter, cellValues); + + textWriter.DrawRight(options, options.Border.Vertical, options.PaddingCharacter); + } + textWriter.DrawBottom(colWidths, options); + } + + private static void DrawHeader(this TextWriter textWriter, string[] colNames, int[] colWidths, TextTableOptions options) + { + if (!options.DrawHeader) + return; + //Draw column header names + textWriter.DrawLeft(options, options.Border.Vertical, options.PaddingCharacter); + + var cellValues = colNames.Select((n, i) => n.PadText(colWidths[i], options)); + textWriter.DrawRow(options, options.Border.VerticalSeparator, options.PaddingCharacter, cellValues); + + textWriter.DrawRight(options, options.Border.Vertical, options.PaddingCharacter); + + //Draw header separator + textWriter.DrawLeft(options, options.Border.TeeLeft, options.Border.HorizontalSeparator); + + cellValues = colWidths.Select(w => new string(options.Border.HorizontalSeparator, w)); + textWriter.DrawRow(options, options.Border.Tee, options.Border.HorizontalSeparator, cellValues); + + textWriter.DrawRight(options, options.Border.TeeRight, options.Border.HorizontalSeparator); + } + + private static void DrawTop(this TextWriter textWriter, int[] colWidths, TextTableOptions options) + { + if (!options.DrawBorder) + return; + textWriter.DrawLeft(options, options.Border.CornerTopLeft, options.Border.Horizontal); + + var cellValues = colWidths.Select(w => new string(options.Border.Horizontal, w)); + textWriter.DrawRow(options, options.Border.TeeTop, options.Border.Horizontal, cellValues); + + textWriter.DrawRight(options, options.Border.CornerTopRight, options.Border.Horizontal); + } + + private static void DrawBottom(this TextWriter textWriter, int[] colWidths, TextTableOptions options) + { + if (!options.DrawBorder) + return; + textWriter.DrawLeft(options, options.Border.CornerBottomLeft, options.Border.Horizontal); + + var cellValues = colWidths.Select(w => new string(options.Border.Horizontal, w)); + textWriter.DrawRow(options, options.Border.TeeBottom, options.Border.Horizontal, cellValues); + + textWriter.DrawRight(options, options.Border.CornerBottomRight, options.Border.Horizontal); + } + + private static void DrawLeft(this TextWriter textWriter, TextTableOptions options, char borderChar, char cellPadChar) + { + if (!options.DrawBorder) + return; + textWriter.Write(borderChar); + textWriter.Write(new string(cellPadChar, options.SideBorderPadding)); + } + + private static void DrawRight(this TextWriter textWriter, TextTableOptions options, char borderChar, char cellPadChar) + { + if (options.DrawBorder) + { + textWriter.Write(new string(cellPadChar, options.SideBorderPadding)); + textWriter.WriteLine(borderChar); + } + else + { + textWriter.WriteLine(); + } + } + + private static void DrawRow(this TextWriter textWriter, TextTableOptions options, char colSeparator, char cellPadChar, IEnumerable cellValues) + { + var cellPadding = new string(cellPadChar, options.IntercellPadding); + var separator = cellPadding + colSeparator + cellPadding; + textWriter.Write(string.Join(separator, cellValues)); + } + + private static string PadText(this string? text, int totalWidth, TextTableOptions options) + { + if (string.IsNullOrEmpty(text)) + return new string(options.PaddingCharacter, totalWidth); + else if (StrLen(text) >= totalWidth) + return text; + + return options.Justify switch + { + Justify.Right => PadLeft(text), + Justify.Center => PadCenter(text), + _ or Justify.Left => PadRight(text), + }; + + string PadCenter(string text) + { + var half = (totalWidth - StrLen(text)) / 2; + + text = options.CenterTiebreak == Justify.Right + ? new string(options.PaddingCharacter, half) + text + : text + new string(options.PaddingCharacter, half); + + return options.CenterTiebreak == Justify.Right + ? text.PadRight(totalWidth, options.PaddingCharacter) + : text.PadLeft(totalWidth, options.PaddingCharacter); + } + + string PadLeft(string text) + { + var padSize = totalWidth - StrLen(text); + return new string(options.PaddingCharacter, padSize) + text; + } + + string PadRight(string text) + { + var padSize = totalWidth - StrLen(text); + return text + new string(options.PaddingCharacter, padSize); + } + } + + /// + /// Determine the width of the string in console characters, accounting for wide unicode characters. + /// + private static int StrLen(string? str) + => string.IsNullOrEmpty(str) ? 0 : str.Sum(c => CharIsWide(c) ? 2 : 1); + + /// + /// Determines if the character is a unicode "Full Width" character which takes up two spaces in the console. + /// + static bool CharIsWide(char c) + => (c >= '\uFF01' && c <= '\uFF61') || (c >= '\uFFE0' && c <= '\uFFE6'); +} diff --git a/Source/LibationFileManager/Configuration.Logging.cs b/Source/LibationFileManager/Configuration.Logging.cs index 999d6e83..ab9593e9 100644 --- a/Source/LibationFileManager/Configuration.Logging.cs +++ b/Source/LibationFileManager/Configuration.Logging.cs @@ -15,7 +15,7 @@ namespace LibationFileManager { private IConfigurationRoot? configuration; - public bool LoggingEnabled { get; private set; } + public bool SerilogInitialized { get; private set; } public void ConfigureLogging() { @@ -42,7 +42,7 @@ namespace LibationFileManager .Destructure.ByTransforming(lp => lp.Path) .Destructure.With() .CreateLogger(); - LoggingEnabled = true; + SerilogInitialized = true; } [Description("The importance of a log event")] diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index a3d2d98b..39f38391 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using FileManager; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; #nullable enable namespace LibationFileManager @@ -18,12 +19,15 @@ namespace LibationFileManager // 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 IPersistentDictionary? persistentDictionary; - private IPersistentDictionary Settings => persistentDictionary + 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) - => persistentDictionary = new PersistentDictionary(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")] diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index bae0f0a3..e0f879b8 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -21,7 +21,7 @@ namespace LibationFileManager throw new InvalidOperationException($"Can only mock {nameof(Configuration)} in Debug mode or in test assemblies."); #endif - var mockInstance = new Configuration() { persistentDictionary = new MockPersistentDictionary() }; + var mockInstance = new Configuration() { JsonBackedDictionary = new EphemeralDictionary() }; mockInstance.SetString("Light", "ThemeVariant"); Instance = mockInstance; return mockInstance; diff --git a/Source/LibationFileManager/MockPersistentDictionary.cs b/Source/LibationFileManager/EphemeralDictionary.cs similarity index 80% rename from Source/LibationFileManager/MockPersistentDictionary.cs rename to Source/LibationFileManager/EphemeralDictionary.cs index 60bf245f..8e7fbb69 100644 --- a/Source/LibationFileManager/MockPersistentDictionary.cs +++ b/Source/LibationFileManager/EphemeralDictionary.cs @@ -4,16 +4,25 @@ using Newtonsoft.Json.Linq; #nullable enable namespace LibationFileManager; -internal class MockPersistentDictionary : IPersistentDictionary +internal class EphemeralDictionary : IJsonBackedDictionary { - private JObject JsonObject { get; } = new(); + private JObject JsonObject { get; } + + public EphemeralDictionary() + { + JsonObject = new(); + } + public EphemeralDictionary(JObject dataStore) + { + JsonObject = dataStore; + } public bool Exists(string propertyName) => JsonObject.ContainsKey(propertyName); public string? GetString(string propertyName, string? defaultValue = null) => JsonObject[propertyName]?.Value() ?? defaultValue; public T? GetNonString(string propertyName, T? defaultValue = default) - => GetObject(propertyName) is object obj ? IPersistentDictionary.UpCast(obj) : defaultValue; + => GetObject(propertyName) is object obj ? IJsonBackedDictionary.UpCast(obj) : defaultValue; public object? GetObject(string propertyName) => JsonObject[propertyName]?.Value(); public void SetString(string propertyName, string? newValue) diff --git a/Source/LibationFileManager/LibationFiles.cs b/Source/LibationFileManager/LibationFiles.cs index 28d4bad0..7e1a249e 100644 --- a/Source/LibationFileManager/LibationFiles.cs +++ b/Source/LibationFileManager/LibationFiles.cs @@ -25,6 +25,7 @@ public class LibationFiles public const string LIBATION_FILES_KEY = "LibationFiles"; public const string SETTINGS_JSON = "Settings.json"; + public const string LIBATION_FILES_DIR = "LIBATION_FILES_DIR"; /// /// Directory pointed to by appsettings.json @@ -38,13 +39,25 @@ public class LibationFiles /// /// Found Location of appsettings.json. This file must exist or be able to be created for Libation to start. /// - internal string AppsettingsJsonFile { get; } + internal string? AppsettingsJsonFile { get; } /// /// File path to Settings.json inside /// public string SettingsFilePath => Path.Combine(Location, SETTINGS_JSON); - internal LibationFiles() : this(GetOrCreateAppsettingsFile()) { } + internal LibationFiles() + { + var libationFilesDir = Environment.GetEnvironmentVariable(LIBATION_FILES_DIR); + if (Directory.Exists(libationFilesDir)) + { + Location = libationFilesDir; + } + else + { + AppsettingsJsonFile = GetOrCreateAppsettingsFile(); + Location = GetLibationFilesFromAppsettings(AppsettingsJsonFile); + } + } internal LibationFiles(string appSettingsFile) { @@ -57,6 +70,12 @@ public class LibationFiles /// public void SetLibationFiles(LongPath libationFilesDirectory) { + if (AppsettingsJsonFile is null) + { + Environment.SetEnvironmentVariable(LIBATION_FILES_DIR, libationFilesDirectory); + return; + } + var startingContents = File.ReadAllText(AppsettingsJsonFile); var jObj = JObject.Parse(startingContents); diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index a3e8abd1..8c123f33 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -1,13 +1,11 @@ using ApplicationServices; using AppScaffolding; using DataLayer; -using Dinah.Core.WindowsDesktop.Processes; using LibationFileManager; using LibationUiBase; using LibationWinForms.Dialogs; using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; @@ -90,7 +88,7 @@ namespace LibationWinForms // global exception handling (ShowAdminAlert) attempts to use logging. only call it after logging has been init'd postLoggingGlobalExceptionHandling(); - var form1 = new Form1(); + form1 = new Form1(); form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask); Application.Run(form1); }