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