mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-23 22:17:52 -05:00
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:
@@ -15,6 +15,7 @@ namespace AaxDecrypter
|
||||
KeyPart2 = keyPart2;
|
||||
}
|
||||
|
||||
[Newtonsoft.Json.JsonConstructor]
|
||||
public KeyData(string keyPart1, string? keyPart2 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
|
||||
|
||||
@@ -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();
|
||||
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
66
Source/LibationCli/Options/GetLicenseOptions.cs
Normal file
66
Source/LibationCli/Options/GetLicenseOptions.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Source/LibationCli/Options/GetSettingOptions.cs
Normal file
140
Source/LibationCli/Options/GetSettingOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")]
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
277
Source/LibationCli/TextTableExtention.cs
Normal file
277
Source/LibationCli/TextTableExtention.cs
Normal 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');
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user