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
This commit is contained in:
MBucari
2025-11-17 22:49:30 -07:00
parent 65d24ce223
commit ce2b81036f
25 changed files with 956 additions and 105 deletions

View File

@@ -15,6 +15,7 @@ namespace AaxDecrypter
KeyPart2 = keyPart2;
}
[Newtonsoft.Json.JsonConstructor]
public KeyData(string keyPart1, string? keyPart2 = null)
{
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));

View File

@@ -79,9 +79,17 @@ namespace AppScaffolding
}
/// <summary>most migrations go in here</summary>
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();
//

View File

@@ -7,6 +7,7 @@ using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#nullable enable
namespace AppScaffolding
{
/// <summary>
@@ -20,21 +21,21 @@ namespace AppScaffolding
/// </summary>
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<string>() : null;
value = success ? val?.Value<string>() : null;
return success;
}
@@ -59,7 +60,10 @@ namespace AppScaffolding
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> 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<string>() : null;
value = success ? val?.Value<string>() : 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<JObject> 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
}

View File

@@ -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<LibraryBook> 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);
}
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)

View File

@@ -23,6 +23,11 @@ namespace FileLiberator
private CancellationTokenSource? cancellationTokenSource;
private AudiobookDownloadBase? abDownloader;
/// <summary>
/// Optional override to supply license info directly instead of querying the api based on Configuration options
/// </summary>
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)

View File

@@ -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
{
/// <summary>
/// Initiate an audiobook download from the audible api.
/// Requests a download license from the Api using the Configuration settings to choose the appropriate content.
/// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
public static async Task<LicenseInfo> 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<KeyData>? keys = null)
{
DrmType = license.DrmType;
@@ -159,7 +165,10 @@ public partial class DownloadOptions
}
}
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
/// <summary>
/// Builds DownloadOptions from the given LibraryBook, Configuration, and LicenseInfo.
/// </summary>
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
long chapterStartMs
= config.StripAudibleBrandAudio

View File

@@ -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);

View File

@@ -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<T>(obj);
return IJsonBackedDictionary.UpCast<T>(obj);
}
public object? GetObject(string propertyName)

View File

@@ -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);

View File

@@ -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;

View File

@@ -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));
}
}
}

View File

@@ -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<string>? 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<ConfigOption> 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<ConfigOption> 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<string>(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;
}
}

View File

@@ -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<DownloadPdf>())
: 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<DownloadOptions.LicenseInfo>(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<DownloadPdf>() : 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<DownloadPdf>();
//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<DownloadDecryptBook>(onDownloadDecryptBookCompleted);
downloadDecryptBook.LicenseInfo = licenseInfo;
return downloadDecryptBook;
}
}

View File

@@ -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.")]

View File

@@ -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<OptionOverride>? 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<VerbAttribute>().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<VerbAttribute>()?.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<PropertyInfo> 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<Replacement> 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];
}
}
}
}
}

View File

@@ -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<string> Asins { get; set; }
public IEnumerable<string>? Asins { get; set; }
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook>? 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<LibraryBook>? 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
{

View File

@@ -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<OptionsBase>(opt => opt.Run());
}
@@ -108,6 +102,7 @@ namespace LibationCli
private static void ConfigureParser(ParserSettings settings)
{
settings.AllowMultiInstance = true;
settings.AutoVersion = false;
settings.AutoHelp = false;
}

View File

@@ -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<VerbAttribute>() is not null)

View File

@@ -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<TestObject>([], 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<T>(string ColumnName, Func<T, string?> ValueGetter);
public static class TextTableExtention
{
/// <summary>
/// Draw a text-based table to the provided TextWriter.
/// </summary>
/// <typeparam name="T">Data row type</typeparam>
/// <param name="textWriter"></param>
/// <param name="rows">Data rows to be drawn</param>
/// <param name="options">Table drawing options</param>
/// <param name="columnSelectors">Data cell selector. Header name is based on member name</param>
public static void DrawTable<T>(this TextWriter textWriter, IEnumerable<T> rows, TextTableOptions options, params Expression<Func<T, string>>[] columnSelectors)
{
//Convert MemberExpression to ColumnDef<T>
var columnDefs = new ColumnDef<T>[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<T>(exp.Member.Name, columnSelectors[i].Compile());
}
textWriter.DrawTable(rows, options, columnDefs);
}
/// <summary>
/// Draw a text-based table to the provided TextWriter.
/// </summary>
/// <typeparam name="T">Data row type</typeparam>
/// <param name="textWriter"></param>
/// <param name="rows">Data rows to be drawn</param>
/// <param name="options">Table drawing options</param>
/// <param name="columnSelectors">Column header name and cell value selector.</param>
public static void DrawTable<T>(this TextWriter textWriter, IEnumerable<T> rows, TextTableOptions options, params ColumnDef<T>[] 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<string> 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);
}
}
/// <summary>
/// Determine the width of the string in console characters, accounting for wide unicode characters.
/// </summary>
private static int StrLen(string? str)
=> string.IsNullOrEmpty(str) ? 0 : str.Sum(c => CharIsWide(c) ? 2 : 1);
/// <summary>
/// Determines if the character is a unicode "Full Width" character which takes up two spaces in the console.
/// </summary>
static bool CharIsWide(char c)
=> (c >= '\uFF01' && c <= '\uFF61') || (c >= '\uFFE0' && c <= '\uFFE6');
}

View File

@@ -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<LongPath>(lp => lp.Path)
.Destructure.With<LogFileFilter>()
.CreateLogger();
LoggingEnabled = true;
SerilogInitialized = true;
}
[Description("The importance of a log event")]

View File

@@ -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")]

View File

@@ -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;

View File

@@ -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<string>() ?? defaultValue;
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
=> GetObject(propertyName) is object obj ? IPersistentDictionary.UpCast<T>(obj) : defaultValue;
=> GetObject(propertyName) is object obj ? IJsonBackedDictionary.UpCast<T>(obj) : defaultValue;
public object? GetObject(string propertyName)
=> JsonObject[propertyName]?.Value<object>();
public void SetString(string propertyName, string? newValue)

View File

@@ -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";
/// <summary>
/// Directory pointed to by appsettings.json
@@ -38,13 +39,25 @@ public class LibationFiles
/// <summary>
/// Found Location of appsettings.json. This file must exist or be able to be created for Libation to start.
/// </summary>
internal string AppsettingsJsonFile { get; }
internal string? AppsettingsJsonFile { get; }
/// <summary>
/// File path to Settings.json inside <see cref="Location"/>
/// </summary>
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
/// </summary>
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);

View File

@@ -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);
}