From 0f130c70f57d564b98423e0cef0d4fbbcc4376b9 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Fri, 10 Sep 2021 16:54:32 -0400 Subject: [PATCH] LibationCli and structural changes to support it incl: no LibationLauncher, just LibationWinForms. LibationWinForms builds to a different output dir so cli can be deployed easily. Versioning number is moved to scaffolding library shared by both apps --- {LibationLauncher => AppScaffolding}/.msbump | 0 AppScaffolding/AppScaffolding.csproj | 21 + AppScaffolding/LibationScaffolding.cs | 325 +++++++++++ .../UNSAFE_MigrationHelper.cs | 4 +- FileManager/Configuration.cs | 33 +- FileManager/FileManager.csproj | 1 - Libation.sln | 21 +- LibationCli/LibationCli.csproj | 33 ++ LibationCli/Options/ConvertOptions.cs | 14 + LibationCli/Options/ExportOptions.cs | 55 ++ LibationCli/Options/LiberateOptions.cs | 37 ++ LibationCli/Options/ScanOptions.cs | 79 +++ LibationCli/Options/_OptionsBase.cs | 31 ++ .../Options/_ProcessableOptionsBase.cs | 58 ++ LibationCli/Program.cs | 84 +++ LibationCli/Setup.cs | 63 +++ LibationLauncher/LibationLauncher.csproj | 36 -- LibationLauncher/Program.cs | 508 ------------------ LibationLauncher/libation.ico | Bin 102551 -> 0 bytes LibationWinForms/LibationWinForms.csproj | 9 +- LibationWinForms/Program.cs | 317 +++++++++++ _DB_NOTES.txt | 2 +- __README - COLLABORATORS.txt | 2 +- 23 files changed, 1161 insertions(+), 572 deletions(-) rename {LibationLauncher => AppScaffolding}/.msbump (100%) create mode 100644 AppScaffolding/AppScaffolding.csproj create mode 100644 AppScaffolding/LibationScaffolding.cs rename {LibationLauncher => AppScaffolding}/UNSAFE_MigrationHelper.cs (95%) create mode 100644 LibationCli/LibationCli.csproj create mode 100644 LibationCli/Options/ConvertOptions.cs create mode 100644 LibationCli/Options/ExportOptions.cs create mode 100644 LibationCli/Options/LiberateOptions.cs create mode 100644 LibationCli/Options/ScanOptions.cs create mode 100644 LibationCli/Options/_OptionsBase.cs create mode 100644 LibationCli/Options/_ProcessableOptionsBase.cs create mode 100644 LibationCli/Program.cs create mode 100644 LibationCli/Setup.cs delete mode 100644 LibationLauncher/LibationLauncher.csproj delete mode 100644 LibationLauncher/Program.cs delete mode 100644 LibationLauncher/libation.ico create mode 100644 LibationWinForms/Program.cs diff --git a/LibationLauncher/.msbump b/AppScaffolding/.msbump similarity index 100% rename from LibationLauncher/.msbump rename to AppScaffolding/.msbump diff --git a/AppScaffolding/AppScaffolding.csproj b/AppScaffolding/AppScaffolding.csproj new file mode 100644 index 00000000..d98fb16c --- /dev/null +++ b/AppScaffolding/AppScaffolding.csproj @@ -0,0 +1,21 @@ + + + + + net5.0 + 5.6.8.1 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/AppScaffolding/LibationScaffolding.cs b/AppScaffolding/LibationScaffolding.cs new file mode 100644 index 00000000..ecfcf307 --- /dev/null +++ b/AppScaffolding/LibationScaffolding.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Dinah.Core; +using Dinah.Core.IO; +using Dinah.Core.Logging; +using FileManager; +using InternalUtilities; +using Newtonsoft.Json.Linq; +using Serilog; + +namespace AppScaffolding +{ + public static class LibationScaffolding + { + // AppScaffolding + private static Assembly _executingAssembly; + private static Assembly ExecutingAssembly + => _executingAssembly ??= Assembly.GetExecutingAssembly(); + + // LibationWinForms or LibationCli + private static Assembly _entryAssembly; + private static Assembly EntryAssembly + => _entryAssembly ??= Assembly.GetEntryAssembly(); + + // previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; + private static Version _buildVersion; + public static Version BuildVersion + => _buildVersion + ??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() } + .Max(a => a.Version); + + /// Run migrations before loading Configuration for the first time. Then load and return Configuration + public static Configuration RunPreConfigMigrations() + { + // must occur before access to Configuration instance + Migrations.migrate_to_v5_2_0__pre_config(); + + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + return Configuration.Instance; + } + + public static void RunPostConfigMigrations() + { + AudibleApiStorage.EnsureAccountsSettingsFileExists(); + + var config = Configuration.Instance; + + // + // migrations go below here + // + + Migrations.migrate_to_v5_2_0__post_config(config); + } + + /// Initialize logging. Run after migration + public static void RunPostMigrationScaffolding() + { + var config = Configuration.Instance; + + ensureSerilogConfig(config); + configureLogging(config); + logStartupState(config); + } + + private static void ensureSerilogConfig(Configuration config) + { + if (config.GetObject("Serilog") != null) + return; + + // "Serilog": { + // "MinimumLevel": "Information" + // "WriteTo": [ + // { + // "Name": "Console" + // }, + // { + // "Name": "File", + // "Args": { + // "rollingInterval": "Day", + // "outputTemplate": ... + // } + // } + // ], + // "Using": [ "Dinah.Core" ], + // "Enrich": [ "WithCaller" ] + // } + var serilogObj = new JObject + { + { "MinimumLevel", "Information" }, + { "WriteTo", new JArray + { + new JObject { {"Name", "Console" } }, + new JObject + { + { "Name", "File" }, + { "Args", + new JObject + { + // for this sink to work, a path must be provided. we override this below + { "path", Path.Combine(config.LibationFiles, "_Log.log") }, + { "rollingInterval", "Month" }, + // Serilog template formatting examples + // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}" + // output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation + // - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}"; + // output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation + { "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" } + } + } + } + } + }, + { "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace + { "Enrich", new JArray{ "WithCaller" } }, + }; + config.SetObject("Serilog", serilogObj); + } + + // to restore original: Console.SetOut(origOut); + private static TextWriter origOut { get; } = Console.Out; + + private static void configureLogging(Configuration config) + { + config.ConfigureLogging(); + + // capture most Console.WriteLine() and write to serilog. See below tests for details. + // Some dependencies print helpful info via Console.WriteLine. We'd like to log it. + // + // Serilog also writes to Console so this might be asking for trouble. ie: infinite loops. + // SerilogTextWriter needs to be more robust and tested. Esp the Write() methods. + // Empirical testing so far has shown no issues. + Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter())); + + #region Console => Serilog tests + /* + // all below apply to "Console." and "Console.Out." + + // captured + Console.WriteLine("str"); + Console.WriteLine(new { a = "anon" }); + Console.WriteLine("{0}", "format"); + Console.WriteLine("{0}{1}", "zero|", "one"); + Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two"); + Console.WriteLine("{0}", new object[] { "arr" }); + + // not captured + Console.WriteLine(); + Console.WriteLine(true); + Console.WriteLine('0'); + Console.WriteLine(1); + Console.WriteLine(2m); + Console.WriteLine(3f); + Console.WriteLine(4d); + Console.WriteLine(5L); + Console.WriteLine((uint)6); + Console.WriteLine((ulong)7); + + Console.Write("str"); + Console.Write(true); + Console.Write('0'); + Console.Write(1); + Console.Write(2m); + Console.Write(3f); + Console.Write(4d); + Console.Write(5L); + Console.Write((uint)6); + Console.Write((ulong)7); + Console.Write(new { a = "anon" }); + Console.Write("{0}", "format"); + Console.Write("{0}{1}", "zero|", "one"); + Console.Write("{0}{1}{2}", "zero|", "one|", "two"); + Console.Write("{0}", new object[] { "arr" }); + */ + #endregion + + // .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive + //var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}"; + //Log.Logger.Here().Debug("Begin Libation. Debug with line numbers"); + } + + private static void logStartupState(Configuration config) + { + // begin logging session with a form feed + Log.Logger.Information("\r\n\f"); + Log.Logger.Information("Begin. {@DebugInfo}", new + { + AppName = EntryAssembly.GetName().Name, + Version = BuildVersion.ToString(), +#if DEBUG + Mode = "Debug", +#else + Mode = "Release", +#endif + + LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), + LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(), + LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(), + LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(), + LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(), + LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(), + + config.LibationFiles, + AudibleFileStorage.BooksDirectory, + + config.InProgress, + + DownloadsInProgressDir = AudibleFileStorage.DownloadsInProgress, + DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgress).Count(), + + DecryptInProgressDir = AudibleFileStorage.DecryptInProgress, + DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgress).Count(), + }); + } + + public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease() + { + (bool, string, string, string) isFalse = (false, null, null, null); + + // timed out + var latest = getLatestRelease(TimeSpan.FromSeconds(10)); + if (latest is null) + return isFalse; + + var latestVersionString = latest.TagName.Trim('v'); + if (!Version.TryParse(latestVersionString, out var latestRelease)) + return isFalse; + + // we're up to date + if (latestRelease <= BuildVersion) + return isFalse; + + // we have an update + var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip")); + var zipUrl = zip?.BrowserDownloadUrl; + + Log.Logger.Information("Update available: {@DebugInfo}", new + { + latestRelease = latestRelease.ToString(), + latest.HtmlUrl, + zipUrl + }); + + return (true, zipUrl, latest.HtmlUrl, zip.Name); + } + private static Octokit.Release getLatestRelease(TimeSpan timeout) + { + try + { + var task = System.Threading.Tasks.Task.Run(() => getLatestRelease()); + if (task.Wait(timeout)) + return task.Result; + + Log.Logger.Information("Timed out"); + } + catch (AggregateException aggEx) + { + Log.Logger.Error(aggEx, "Checking for new version too often"); + } + return null; + } + private static Octokit.Release getLatestRelease() + { + var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation")); + + // https://octokitnet.readthedocs.io/en/latest/releases/ + var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult(); + var latest = releases.First(r => !r.Draft && !r.Prerelease); + return latest; + } + } + + internal static class Migrations + { + #region migrate to v5.2.0 + // get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress + public static void migrate_to_v5_2_0__pre_config() + { + { + var settingsKey = "DownloadsInProgressEnum"; + if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value)) + { + UNSAFE_MigrationHelper.Settings_Delete(settingsKey); + UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value)); + } + } + + { + UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum"); + } + + { // appsettings.json + var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY; + if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value)) + UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value)); + } + } + + private static string translatePath(string path) + => path switch + { + "AppDir" => @".\LibationFiles", + "MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")), + "UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")), + "WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")), + _ => path + }; + + public static void migrate_to_v5_2_0__post_config(Configuration config) + { + if (!config.Exists(nameof(config.AllowLibationFixup))) + config.AllowLibationFixup = true; + + if (!config.Exists(nameof(config.DecryptToLossy))) + config.DecryptToLossy = false; + } + #endregion + } +} diff --git a/LibationLauncher/UNSAFE_MigrationHelper.cs b/AppScaffolding/UNSAFE_MigrationHelper.cs similarity index 95% rename from LibationLauncher/UNSAFE_MigrationHelper.cs rename to AppScaffolding/UNSAFE_MigrationHelper.cs index 8699e769..a0f937c8 100644 --- a/LibationLauncher/UNSAFE_MigrationHelper.cs +++ b/AppScaffolding/UNSAFE_MigrationHelper.cs @@ -6,7 +6,7 @@ using Dinah.Core; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace LibationLauncher +namespace AppScaffolding { /// /// @@ -20,7 +20,7 @@ namespace LibationLauncher internal static class UNSAFE_MigrationHelper { #region appsettings.json - private const string APPSETTINGS_JSON = "appsettings.json"; + private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json"); public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON); diff --git a/FileManager/Configuration.cs b/FileManager/Configuration.cs index 770ce1c4..3faebd06 100644 --- a/FileManager/Configuration.cs +++ b/FileManager/Configuration.cs @@ -51,9 +51,12 @@ namespace FileManager public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue); /// WILL ONLY set if already present. WILL NOT create new - /// Value was changed - public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false) - => persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging); + public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false) + { + var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging); + if (settingWasChanged) + configuration?.Reload(); + } public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json"); @@ -159,13 +162,9 @@ namespace FileManager #region logging private IConfigurationRoot configuration; + public void ConfigureLogging() { - //// with code. also persists to Settings.json - //SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true); - //// hack which achieves the same, in memory only - //configuration["Serilog:WriteTo:1:Args:path"] = logPath; - configuration = new ConfigurationBuilder() .AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true) .Build(); @@ -217,11 +216,11 @@ namespace FileManager #region singleton stuff public static Configuration Instance { get; } = new Configuration(); private Configuration() { } - #endregion + #endregion - #region LibationFiles + #region LibationFiles - private const string APPSETTINGS_JSON = "appsettings.json"; + private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json"); private const string LIBATION_FILES_KEY = "LibationFiles"; [Description("Location for storage of program-created files")] @@ -238,12 +237,16 @@ namespace FileManager // SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist persistentDictionary = new PersistentDictionary(SettingsFilePath); - // Config init in Program.ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run). + // Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run). // This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles var logPath = Path.Combine(LibationFiles, "Log.log"); - bool settingWasChanged = SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true); - if (settingWasChanged) - configuration?.Reload(); + + // BAD: Serilog.WriteTo[1].Args + // "[1]" assumes ordinal position + // GOOD: Serilog.WriteTo[?(@.Name=='File')].Args + var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args"; + + SetWithJsonPath(jsonpath, "path", logPath, true); return libationFilesPathCache; } diff --git a/FileManager/FileManager.csproj b/FileManager/FileManager.csproj index 718e4303..b08b6f10 100644 --- a/FileManager/FileManager.csproj +++ b/FileManager/FileManager.csproj @@ -7,7 +7,6 @@ - diff --git a/Libation.sln b/Libation.sln index 5c653a70..f7b23ffe 100644 --- a/Libation.sln +++ b/Libation.sln @@ -41,8 +41,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoIm EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationLauncher", "LibationLauncher\LibationLauncher.csproj", "{F3B04A3A-20C8-4582-A54A-715AF6A5D859}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities.Tests", "_Tests\InternalUtilities.Tests\InternalUtilities.Tests.csproj", "{8447C956-B03E-4F59-9DD4-877793B849D9}" @@ -51,6 +49,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,10 +95,6 @@ Global {B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = Debug|Any CPU {B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.ActiveCfg = Release|Any CPU {B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = Release|Any CPU - {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.Build.0 = Release|Any CPU {8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.Build.0 = Debug|Any CPU {8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -109,6 +107,14 @@ Global {D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU {D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU {D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU + {428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU + {428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU + {428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.Build.0 = Release|Any CPU + {595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.Build.0 = Debug|Any CPU + {595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.ActiveCfg = Release|Any CPU + {595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -123,10 +129,11 @@ Global {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14} {401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF} {B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF} - {F3B04A3A-20C8-4582-A54A-715AF6A5D859} = {8679CAC8-9164-4007-BDD2-F004810EDA14} {8447C956-B03E-4F59-9DD4-877793B849D9} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} {C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} {D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14} + {595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9} diff --git a/LibationCli/LibationCli.csproj b/LibationCli/LibationCli.csproj new file mode 100644 index 00000000..ce0975b5 --- /dev/null +++ b/LibationCli/LibationCli.csproj @@ -0,0 +1,33 @@ + + + + + Exe + net5.0 + + true + true + win-x64 + false + false + + + + ..\LibationWinForms\bin\Debug + + + + ..\LibationWinForms\bin\Release + + + + + + + + + + + + + diff --git a/LibationCli/Options/ConvertOptions.cs b/LibationCli/Options/ConvertOptions.cs new file mode 100644 index 00000000..fa3b744b --- /dev/null +++ b/LibationCli/Options/ConvertOptions.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommandLine; + +namespace LibationCli +{ + [Verb("convert", HelpText = "Convert mp4 to mp3.")] + public class ConvertOptions : ProcessableOptionsBase + { + protected override Task ProcessAsync() => RunAsync(CreateProcessable()); + } +} diff --git a/LibationCli/Options/ExportOptions.cs b/LibationCli/Options/ExportOptions.cs new file mode 100644 index 00000000..625663b6 --- /dev/null +++ b/LibationCli/Options/ExportOptions.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ApplicationServices; +using CommandLine; +using InternalUtilities; + +namespace LibationCli +{ + [Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json]")] + public class ExportOptions : OptionsBase + { + [Option(shortName: 'p', longName: "path", Required = true, HelpText = "Path to save file to.")] + public string FilePath { get; set; } + + #region explanation of mutually exclusive options + /* + giving these SetName values makes them mutually exclusive. they are in different sets. eg: + class Options + { + [Option("username", SetName = "auth")] + public string Username { get; set; } + [Option("password", SetName = "auth")] + public string Password { get; set; } + + [Option("guestaccess", SetName = "guest")] + public bool GuestAccess { get; set; } + } + */ + #endregion + [Option(shortName: 'x', longName: "xlsx", SetName = "xlsx", Required = true)] + public bool xlsx { get; set; } + + [Option(shortName: 'c', longName: "csv", SetName = "csv", Required = true)] + public bool csv { get; set; } + + [Option(shortName: 'j', longName: "json", SetName = "json", Required = true)] + public bool json { get; set; } + + protected override Task ProcessAsync() + { + if (xlsx) + LibraryExporter.ToXlsx(FilePath); + if (csv) + LibraryExporter.ToCsv(FilePath); + if (json) + LibraryExporter.ToJson(FilePath); + + Console.WriteLine($"Library exported to: {FilePath}"); + + return Task.CompletedTask; + } + } +} diff --git a/LibationCli/Options/LiberateOptions.cs b/LibationCli/Options/LiberateOptions.cs new file mode 100644 index 00000000..0115ae3a --- /dev/null +++ b/LibationCli/Options/LiberateOptions.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommandLine; +using DataLayer; +using FileLiberator; + +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.")] + 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() + => PdfOnly + ? RunAsync(CreateProcessable()) + : RunAsync(CreateBackupBook()); + + private static IProcessable CreateBackupBook() + { + var downloadPdf = CreateProcessable(); + + //Chain pdf download on DownloadDecryptBook.Completed + async void onDownloadDecryptBookCompleted(object sender, LibraryBook e) + { + await downloadPdf.TryProcessAsync(e); + } + + var downloadDecryptBook = CreateProcessable(onDownloadDecryptBookCompleted); + return downloadDecryptBook; + } + } +} diff --git a/LibationCli/Options/ScanOptions.cs b/LibationCli/Options/ScanOptions.cs new file mode 100644 index 00000000..9c9aa825 --- /dev/null +++ b/LibationCli/Options/ScanOptions.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ApplicationServices; +using CommandLine; +using InternalUtilities; + +namespace LibationCli +{ + [Verb("scan", HelpText = "Scan library. Default: scan all accounts. Optional: use 'account' flag to specify a single account.")] + public class ScanOptions : OptionsBase + { + [Value(0, MetaName = "Accounts", HelpText = "Optional: nicknames of accounts to scan.", Required = false)] + public IEnumerable AccountNicknames { get; set; } + + protected override async Task ProcessAsync() + { + var accounts = getAccounts(); + if (!accounts.Any()) + { + Console.WriteLine("No accounts. Exiting."); + Environment.ExitCode = (int)ExitCode.RunTimeError; + return; + } + + var _accounts = accounts.ToArray(); + + var intro + = (_accounts.Length == 1) + ? "Scanning Audible library. This may take a few minutes." + : $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account."; + Console.WriteLine(intro); + + var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync( + (account) => null, + _accounts); + + Console.WriteLine("Scan complete."); + Console.WriteLine($"Total processed: {TotalBooksProcessed}\r\nNew: {NewBooksAdded}"); + } + + private Account[] getAccounts() + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings.GetAll().ToArray(); + + if (!AccountNicknames.Any()) + return accounts; + + var found = accounts.Where(acct => AccountNicknames.Contains(acct.AccountName)).ToArray(); + var notFound = AccountNicknames.Except(found.Select(f => f.AccountName)).ToArray(); + + // no accounts found. do not continue + if (!found.Any()) + { + Console.WriteLine("Accounts not found:"); + foreach (var nf in notFound) + Console.WriteLine($"- {nf}"); + return found; + } + + // some accounts not found. continue after message + if (notFound.Any()) + { + Console.WriteLine("Accounts found:"); + foreach (var f in found) + Console.WriteLine($"- {f}"); + Console.WriteLine("Accounts not found:"); + foreach (var nf in notFound) + Console.WriteLine($"- {nf}"); + } + + // else: all accounts area found. silently continue + + return found; + } + } +} diff --git a/LibationCli/Options/_OptionsBase.cs b/LibationCli/Options/_OptionsBase.cs new file mode 100644 index 00000000..e1976adb --- /dev/null +++ b/LibationCli/Options/_OptionsBase.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommandLine; + +namespace LibationCli +{ + public abstract class OptionsBase + { + public async Task Run() + { + try + { + await ProcessAsync(); + } + catch (Exception ex) + { + Environment.ExitCode = (int)ExitCode.RunTimeError; + + Console.WriteLine("ERROR"); + Console.WriteLine("====="); + Console.WriteLine(ex.Message); + Console.WriteLine(); + Console.WriteLine(ex.StackTrace); + } + } + + protected abstract Task ProcessAsync(); + } +} diff --git a/LibationCli/Options/_ProcessableOptionsBase.cs b/LibationCli/Options/_ProcessableOptionsBase.cs new file mode 100644 index 00000000..e95e15d1 --- /dev/null +++ b/LibationCli/Options/_ProcessableOptionsBase.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ApplicationServices; +using CommandLine; +using DataLayer; +using FileLiberator; + +namespace LibationCli +{ + // streamlined, non-Forms copy of ProcessorAutomationController + public abstract class ProcessableOptionsBase : OptionsBase + { + protected static TProcessable CreateProcessable(EventHandler completedAction = null) + where TProcessable : IProcessable, new() + { + var strProc = new TProcessable(); + + strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}"); + strProc.Completed += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}"); + + strProc.Completed += completedAction; + + return strProc; + } + + protected static async Task RunAsync(IProcessable Processable) + { + foreach (var libraryBook in Processable.GetValidLibraryBooks(DbContexts.GetLibrary_Flat_NoTracking())) + await ProcessOneAsync(Processable, libraryBook, false); + + var done = "Done. All books have been processed"; + Console.WriteLine(done); + Serilog.Log.Logger.Information(done); + } + + private static async Task ProcessOneAsync(IProcessable Processable, LibraryBook libraryBook, bool validate) + { + try + { + var statusHandler = await Processable.ProcessSingleAsync(libraryBook, validate); + + if (statusHandler.IsSuccess) + return; + + foreach (var errorMessage in statusHandler.Errors) + Serilog.Log.Logger.Error(errorMessage); + } + catch (Exception ex) + { + var msg = "Error processing book. Skipping. This book will be tried again on next attempt. For options of skipping or marking as error, retry with main Libation app."; + Console.WriteLine(msg + ". See log for more details."); + Serilog.Log.Logger.Error(ex, $"{msg} {{@DebugInfo}}", new { Book = libraryBook.LogFriendly() }); + } + } + } +} diff --git a/LibationCli/Program.cs b/LibationCli/Program.cs new file mode 100644 index 00000000..6f85c5a6 --- /dev/null +++ b/LibationCli/Program.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommandLine; +using CommandLine.Text; +using Dinah.Core; +using Dinah.Core.Collections; +using Dinah.Core.Collections.Generic; + +namespace LibationCli +{ + public enum ExitCode + { + ProcessCompletedSuccessfully = 0, + NonRunNonError = 1, + ParseError = 2, + RunTimeError = 3 + } + class Program + { + static async Task Main(string[] args) + { + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + Setup.Initialize(); + Setup.SubscribeToDatabaseEvents(); + + var types = Setup.LoadVerbs(); + +#if DEBUG + string input = null; + + //input = " export --help"; + //input = " scan cupidneedsglasses"; + //input = " liberate "; + + + // note: this hack will fail for quoted file paths with spaces because it will break on those spaces + if (!string.IsNullOrWhiteSpace(input)) + args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var setBreakPointHere = args; +#endif + + var result = Parser.Default.ParseArguments(args, types); + + // if successfully parsed + // async: run parsed options + await result.WithParsedAsync(opt => opt.Run()); + + // if not successfully parsed + // sync: handle parse errors + result.WithNotParsed(errors => HandleErrors(result, errors)); + + return Environment.ExitCode; + } + + private static void HandleErrors(ParserResult result, IEnumerable errors) + { + var errorsList = errors.ToList(); + if (errorsList.Any(e => e.Tag.In(ErrorType.HelpRequestedError, ErrorType.VersionRequestedError, ErrorType.HelpVerbRequestedError))) + { + Environment.ExitCode = (int)ExitCode.NonRunNonError; + return; + } + + Environment.ExitCode = (int)ExitCode.ParseError; + + if (errorsList.Any(e => e.Tag.In(ErrorType.NoVerbSelectedError))) + { + Console.WriteLine("No verb selected"); + return; + } + + var helpText = HelpText.AutoBuild(result, + h => HelpText.DefaultParsingErrorsHandler(result, h), + e => e); + Console.WriteLine(helpText); + } + } +} diff --git a/LibationCli/Setup.cs b/LibationCli/Setup.cs new file mode 100644 index 00000000..30de7af2 --- /dev/null +++ b/LibationCli/Setup.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using AppScaffolding; +using CommandLine; +using CommandLine.Text; +using Dinah.Core; +using Dinah.Core.Collections; +using Dinah.Core.Collections.Generic; + +namespace LibationCli +{ + public static class Setup + { + public static void Initialize() + { + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + var config = LibationScaffolding.RunPreConfigMigrations(); + + + LibationScaffolding.RunPostConfigMigrations(); + LibationScaffolding.RunPostMigrationScaffolding(); + +#if !DEBUG + checkForUpdate(); +#endif + } + + private static void checkForUpdate() + { + var (hasUpgrade, zipUrl, htmlUrl, zipName) = LibationScaffolding.GetLatestRelease(); + if (!hasUpgrade) + return; + + var origColor = Console.ForegroundColor; + try + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"UPDATE AVAILABLE @ {zipUrl}"); + } + finally + { + Console.ForegroundColor = origColor; + } + } + + public static void SubscribeToDatabaseEvents() + { + DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book); + } + + public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => t.GetCustomAttribute() is not null) + .ToArray(); + } +} diff --git a/LibationLauncher/LibationLauncher.csproj b/LibationLauncher/LibationLauncher.csproj deleted file mode 100644 index f53b5e47..00000000 --- a/LibationLauncher/LibationLauncher.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - - WinExe - net5.0-windows - true - libation.ico - Libation - - true - true - win-x64 - false - false - - 5.6.7.4 - - - - TRACE;DEBUG - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - \ No newline at end of file diff --git a/LibationLauncher/Program.cs b/LibationLauncher/Program.cs deleted file mode 100644 index 616bdf81..00000000 --- a/LibationLauncher/Program.cs +++ /dev/null @@ -1,508 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Windows.Forms; -using AudibleApi.Authorization; -using DataLayer; -using Dinah.Core; -using Dinah.Core.IO; -using Dinah.Core.Logging; -using FileManager; -using InternalUtilities; -using LibationWinForms; -using LibationWinForms.Dialogs; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json.Linq; -using Serilog; - -namespace LibationLauncher -{ - static class Program - { - [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] - [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] - static extern bool AllocConsole(); - - [STAThread] - static void Main() - { - //// uncomment to see Console. MUST be called before anything writes to Console. Might only work from VS - //AllocConsole(); - - Application.SetHighDpiMode(HighDpiMode.SystemAware); - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - - // must occur before access to Configuration instance - migrate_to_v5_2_0__pre_config(); - - - //***********************************************// - // // - // do not use Configuration before this line // - // // - //***********************************************// - - - var config = Configuration.Instance; - - createSettings(config); - - AudibleApiStorage.EnsureAccountsSettingsFileExists(); - - migrate_to_v5_0_0(config); - migrate_to_v5_2_0__post_config(config); - migrate_to_v5_5_0(config); - - ensureSerilogConfig(config); - configureLogging(config); - logStartupState(config); - -#if !DEBUG - checkForUpdate(config); -#endif - Application.Run(new Form1()); - } - - private static void createSettings(Configuration config) - { - // all returns should be preceded by either: - // - if config.LibationSettingsAreValid - // - error message, Exit() - - static void CancelInstallation() - { - MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - Application.Exit(); - Environment.Exit(0); - } - - if (config.LibationSettingsAreValid) - return; - - var defaultLibationFilesDir = Configuration.UserProfile; - - // check for existing settigns in default location - var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); - if (Configuration.SettingsFileIsValid(defaultSettingsFile)) - config.SetLibationFiles(defaultLibationFilesDir); - - if (config.LibationSettingsAreValid) - return; - - var setupDialog = new SetupDialog(); - if (setupDialog.ShowDialog() != DialogResult.OK) - { - CancelInstallation(); - return; - } - - if (setupDialog.IsNewUser) - config.SetLibationFiles(defaultLibationFilesDir); - else if (setupDialog.IsReturningUser) - { - var libationFilesDialog = new LibationFilesDialog(); - - if (libationFilesDialog.ShowDialog() != DialogResult.OK) - { - CancelInstallation(); - return; - } - - config.SetLibationFiles(libationFilesDialog.SelectedDirectory); - if (config.LibationSettingsAreValid) - return; - - // path did not result in valid settings - var continueResult = MessageBox.Show( - $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", - "New install?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question); - - if (continueResult != DialogResult.Yes) - { - CancelInstallation(); - return; - } - } - - // if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog - config.Books ??= Path.Combine(defaultLibationFilesDir, "Books"); - config.InProgress ??= Configuration.WinTemp; - config.AllowLibationFixup = true; - config.DecryptToLossy = false; - - if (new SettingsDialog().ShowDialog() != DialogResult.OK) - { - CancelInstallation(); - return; - } - - if (config.LibationSettingsAreValid) - return; - - CancelInstallation(); - } - - #region migrate to v5.0.0 re-register device if device info not in settings - private static void migrate_to_v5_0_0(Configuration config) - { - if (!config.Exists(nameof(config.AllowLibationFixup))) - config.AllowLibationFixup = true; - - if (!File.Exists(AudibleApiStorage.AccountsSettingsFile)) - return; - - var accountsPersister = AudibleApiStorage.GetAccountsSettingsPersister(); - - var accounts = accountsPersister?.AccountsSettings?.Accounts; - if (accounts is null) - return; - - foreach (var account in accounts) - { - var identity = account?.IdentityTokens; - - if (identity is null) - continue; - - if (!string.IsNullOrWhiteSpace(identity.DeviceType) && - !string.IsNullOrWhiteSpace(identity.DeviceSerialNumber) && - !string.IsNullOrWhiteSpace(identity.AmazonAccountId)) - continue; - - var authorize = new Authorize(identity.Locale); - - try - { - authorize.DeregisterAsync(identity.ExistingAccessToken, identity.Cookies.ToKeyValuePair()).GetAwaiter().GetResult(); - identity.Invalidate(); - - var api = AudibleApiActions.GetApiAsync(new LibationWinForms.Login.WinformResponder(account), account).GetAwaiter().GetResult(); - } - catch - { - // Don't care if it fails - } - } - } - #endregion - - #region migrate to v5.2.0 - // get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress - private static void migrate_to_v5_2_0__pre_config() - { - { - var settingsKey = "DownloadsInProgressEnum"; - if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value)) - { - UNSAFE_MigrationHelper.Settings_Delete(settingsKey); - UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value)); - } - } - - { - UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum"); - } - - { // appsettings.json - var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY; - if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value)) - UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value)); - } - } - - private static string translatePath(string path) - => path switch - { - "AppDir" => @".\LibationFiles", - "MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")), - "UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")), - "WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")), - _ => path - }; - - private static void migrate_to_v5_2_0__post_config(Configuration config) - { - if (!config.Exists(nameof(config.AllowLibationFixup))) - config.AllowLibationFixup = true; - - if (!config.Exists(nameof(config.DecryptToLossy))) - config.DecryptToLossy = false; - } - #endregion - - #region migrate to v5.5.0. FilePaths.json => db. long running. fire and forget - private static void migrate_to_v5_5_0(Configuration config) - => new System.Threading.Thread(() => migrate_to_v5_5_0_thread(config)) { IsBackground = true }.Start(); - private static void migrate_to_v5_5_0_thread(Configuration config) - { - try - { - var filePaths = Path.Combine(config.LibationFiles, "FilePaths.json"); - if (!File.Exists(filePaths)) - return; - - var fileLocations = Path.Combine(config.LibationFiles, "FileLocations.json"); - if (!File.Exists(fileLocations)) - File.Copy(filePaths, fileLocations); - - // files to be deleted at the end - var libhackFilesToDelete = new List(); - // .libhack files => errors - var libhackFiles = Directory.EnumerateDirectories(config.Books, "*.libhack", SearchOption.AllDirectories); - - using var context = ApplicationServices.DbContexts.GetContext(); - context.Books.Load(); - - var jArr = JArray.Parse(File.ReadAllText(filePaths)); - - foreach (var jToken in jArr) - { - var asinToken = jToken["Id"]; - var fileTypeToken = jToken["FileType"]; - var pathToken = jToken["Path"]; - if (asinToken is null || fileTypeToken is null || pathToken is null || - asinToken.Type != JTokenType.String || fileTypeToken.Type != JTokenType.Integer || pathToken.Type != JTokenType.String) - continue; - - var asin = asinToken.Value(); - var fileType = (FileType)fileTypeToken.Value(); - var path = pathToken.Value(); - - if (fileType == FileType.Unknown || fileType == FileType.AAXC) - continue; - - var book = context.Books.Local.FirstOrDefault(b => b.AudibleProductId == asin); - if (book is null) - continue; - - // assign these strings and enums/ints unconditionally. EFCore will only update if changed - if (fileType == FileType.PDF) - book.UserDefinedItem.PdfStatus = LiberatedStatus.Liberated; - - if (fileType == FileType.Audio) - { - var lhack = libhackFiles.FirstOrDefault(f => f.ContainsInsensitive(asin)); - if (lhack is null) - book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated; - else - { - book.UserDefinedItem.BookStatus = LiberatedStatus.Error; - libhackFilesToDelete.Add(lhack); - } - } - } - - context.SaveChanges(); - - // only do this after save changes - foreach (var libhackFile in libhackFilesToDelete) - File.Delete(libhackFile); - - File.Delete(filePaths); - } - catch (Exception ex) - { - Log.Logger.Error(ex, "Error attempting to insert FilePaths into db"); - } - } - #endregion - - private static void ensureSerilogConfig(Configuration config) - { - if (config.GetObject("Serilog") != null) - return; - - // "Serilog": { - // "MinimumLevel": "Information" - // "WriteTo": [ - // { - // "Name": "Console" - // }, - // { - // "Name": "File", - // "Args": { - // "rollingInterval": "Day", - // "outputTemplate": ... - // } - // } - // ], - // "Using": [ "Dinah.Core" ], - // "Enrich": [ "WithCaller" ] - // } - var serilogObj = new JObject - { - { "MinimumLevel", "Information" }, - { "WriteTo", new JArray - { - new JObject { {"Name", "Console" } }, - new JObject - { - { "Name", "File" }, - { "Args", - new JObject - { - // for this sink to work, a path must be provided. we override this below - { "path", Path.Combine(config.LibationFiles, "_Log.log") }, - { "rollingInterval", "Month" }, - // Serilog template formatting examples - // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}" - // output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation - // - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}"; - // output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation - { "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" } - } - } - } - } - }, - { "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace - { "Enrich", new JArray{ "WithCaller" } }, - }; - config.SetObject("Serilog", serilogObj); - } - - // to restore original: Console.SetOut(origOut); - private static TextWriter origOut { get; } = Console.Out; - - private static void configureLogging(Configuration config) - { - config.ConfigureLogging(); - - // Fwd Console to serilog. - // Serilog also writes to Console (should probably change this) so it might be asking for trouble. - // SerilogTextWriter needs to be more robust and tested. Esp the Write() methods. - // Empirical testing so far has shown no issues. - Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter())); - - // .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive - //var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}"; - //Log.Logger.Here().Debug("Begin Libation. Debug with line numbers"); - } - - private static void logStartupState(Configuration config) - { - // begin logging session with a form feed - Log.Logger.Information("\r\n\f"); - Log.Logger.Information("Begin Libation. {@DebugInfo}", new - { - Version = BuildVersion.ToString(), -#if DEBUG - Mode = "Debug", -#else - Mode = "Release", -#endif - - LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), - LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(), - LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(), - LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(), - LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(), - LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(), - - config.LibationFiles, - AudibleFileStorage.BooksDirectory, - - config.InProgress, - - DownloadsInProgressDir = AudibleFileStorage.DownloadsInProgress, - DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgress).Count(), - - DecryptInProgressDir = AudibleFileStorage.DecryptInProgress, - DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgress).Count(), - }); - - MessageBoxVerboseLoggingWarning.ShowIfTrue(); - } - - private static void checkForUpdate(Configuration config) - { - string zipUrl; - string selectedPath; - - try - { - // timed out - var latest = getLatestRelease(TimeSpan.FromSeconds(10)); - if (latest is null) - return; - - var latestVersionString = latest.TagName.Trim('v'); - if (!Version.TryParse(latestVersionString, out var latestRelease)) - return; - - // we're up to date - if (latestRelease <= BuildVersion) - return; - - // we have an update - var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip")); - zipUrl = zip?.BrowserDownloadUrl; - - Log.Logger.Information("Update available: {@DebugInfo}", new { - latestRelease = latestRelease.ToString(), - latest.HtmlUrl, - zipUrl - }); - - if (zipUrl is null) - { - MessageBox.Show(latest.HtmlUrl, "New version available"); - return; - } - - var result = MessageBox.Show($"New version available @ {latest.HtmlUrl}\r\nDownload the zip file?", "New version available", MessageBoxButtons.YesNo, MessageBoxIcon.Question); - if (result != DialogResult.Yes) - return; - - using var fileSelector = new SaveFileDialog { FileName = zip.Name, Filter = "Zip Files (*.zip)|*.zip|All files (*.*)|*.*" }; - if (fileSelector.ShowDialog() != DialogResult.OK) - return; - selectedPath = fileSelector.FileName; - } - catch (AggregateException aggEx) - { - Log.Logger.Error(aggEx, "Checking for new version too often"); - return; - } - catch (Exception ex) - { - MessageBoxAlertAdmin.Show("Error checking for update", "Error checking for update", ex); - return; - } - - try - { - LibationWinForms.BookLiberation.ProcessorAutomationController.DownloadFile(zipUrl, selectedPath, true); - } - catch (Exception ex) - { - MessageBoxAlertAdmin.Show("Error downloading update", "Error downloading update", ex); - } - } - - private static Octokit.Release getLatestRelease(TimeSpan timeout) - { - var task = System.Threading.Tasks.Task.Run(() => getLatestRelease()); - if (task.Wait(timeout)) - return task.Result; - - Log.Logger.Information("Timed out"); - return null; - } - private static Octokit.Release getLatestRelease() - { - var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation")); - - // https://octokitnet.readthedocs.io/en/latest/releases/ - var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult(); - var latest = releases.First(r => !r.Draft && !r.Prerelease); - return latest; - } - - private static Version BuildVersion => System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; - } -} diff --git a/LibationLauncher/libation.ico b/LibationLauncher/libation.ico deleted file mode 100644 index d3e0044392a5ccbebff66f3775ee6c32511c0d58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102551 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 diff --git a/LibationWinForms/LibationWinForms.csproj b/LibationWinForms/LibationWinForms.csproj index dacc738d..c5271389 100644 --- a/LibationWinForms/LibationWinForms.csproj +++ b/LibationWinForms/LibationWinForms.csproj @@ -2,14 +2,20 @@ - Library + WinExe net5.0-windows true libation.ico + Libation true true win-x64 + false + false + + + @@ -18,6 +24,7 @@ + diff --git a/LibationWinForms/Program.cs b/LibationWinForms/Program.cs new file mode 100644 index 00000000..0f874104 --- /dev/null +++ b/LibationWinForms/Program.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using AudibleApi.Authorization; +using DataLayer; +using Dinah.Core; +using FileManager; +using InternalUtilities; +using LibationWinForms.Dialogs; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json.Linq; +using Serilog; + +namespace LibationWinForms +{ + static class Program + { + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + static extern bool AllocConsole(); + + [STAThread] + static void Main() + { + //// Uncomment to see Console. Must be called before anything writes to Console. + //// Only use while debugging. Acts erratically in the wild + //AllocConsole(); + + Application.SetHighDpiMode(HighDpiMode.SystemAware); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + // Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration + var config = AppScaffolding.LibationScaffolding.RunPreConfigMigrations(); + + RunInstaller(config); + + // most migrations go in here + AppScaffolding.LibationScaffolding.RunPostConfigMigrations(); + + // migrations which require Forms or are long-running + RunWindowsOnlyMigrations(config); + + MessageBoxVerboseLoggingWarning.ShowIfTrue(); + +#if !DEBUG + checkForUpdate(); +#endif + + AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(); + + Application.Run(new Form1()); + } + + private static void RunInstaller(Configuration config) + { + // all returns should be preceded by either: + // - if config.LibationSettingsAreValid + // - error message, Exit() + + if (config.LibationSettingsAreValid) + return; + + var defaultLibationFilesDir = Configuration.UserProfile; + + // check for existing settigns in default location + var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); + if (Configuration.SettingsFileIsValid(defaultSettingsFile)) + config.SetLibationFiles(defaultLibationFilesDir); + + if (config.LibationSettingsAreValid) + return; + + static void CancelInstallation() + { + MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + Application.Exit(); + Environment.Exit(0); + } + + var setupDialog = new SetupDialog(); + if (setupDialog.ShowDialog() != DialogResult.OK) + { + CancelInstallation(); + return; + } + + if (setupDialog.IsNewUser) + config.SetLibationFiles(defaultLibationFilesDir); + else if (setupDialog.IsReturningUser) + { + var libationFilesDialog = new LibationFilesDialog(); + + if (libationFilesDialog.ShowDialog() != DialogResult.OK) + { + CancelInstallation(); + return; + } + + config.SetLibationFiles(libationFilesDialog.SelectedDirectory); + if (config.LibationSettingsAreValid) + return; + + // path did not result in valid settings + var continueResult = MessageBox.Show( + $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", + "New install?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (continueResult != DialogResult.Yes) + { + CancelInstallation(); + return; + } + } + + // if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog + config.Books ??= Path.Combine(defaultLibationFilesDir, "Books"); + config.InProgress ??= Configuration.WinTemp; + config.AllowLibationFixup = true; + config.DecryptToLossy = false; + + if (new SettingsDialog().ShowDialog() != DialogResult.OK) + { + CancelInstallation(); + return; + } + + if (config.LibationSettingsAreValid) + return; + + CancelInstallation(); + } + + private static void RunWindowsOnlyMigrations(Configuration config) + { + // only supported in winforms. don't move to app scaffolding + migrate_to_v5_0_0(config); + + // long running. won't get a chance to finish in cli. don't move to app scaffolding + migrate_to_v5_5_0(config); + } + + #region migrate to v5.0.0 re-register device if device info not in settings + private static void migrate_to_v5_0_0(Configuration config) + { + if (!config.Exists(nameof(config.AllowLibationFixup))) + config.AllowLibationFixup = true; + + if (!File.Exists(AudibleApiStorage.AccountsSettingsFile)) + return; + + var accountsPersister = AudibleApiStorage.GetAccountsSettingsPersister(); + + var accounts = accountsPersister?.AccountsSettings?.Accounts; + if (accounts is null) + return; + + foreach (var account in accounts) + { + var identity = account?.IdentityTokens; + + if (identity is null) + continue; + + if (!string.IsNullOrWhiteSpace(identity.DeviceType) && + !string.IsNullOrWhiteSpace(identity.DeviceSerialNumber) && + !string.IsNullOrWhiteSpace(identity.AmazonAccountId)) + continue; + + var authorize = new Authorize(identity.Locale); + + try + { + authorize.DeregisterAsync(identity.ExistingAccessToken, identity.Cookies.ToKeyValuePair()).GetAwaiter().GetResult(); + identity.Invalidate(); + + var api = AudibleApiActions.GetApiAsync(new LibationWinForms.Login.WinformResponder(account), account).GetAwaiter().GetResult(); + } + catch + { + // Don't care if it fails + } + } + } + #endregion + + #region migrate to v5.5.0. FilePaths.json => db. long running. fire and forget + private static void migrate_to_v5_5_0(Configuration config) + => new System.Threading.Thread(() => migrate_to_v5_5_0_thread(config)) { IsBackground = true }.Start(); + private static void migrate_to_v5_5_0_thread(Configuration config) + { + try + { + var filePaths = Path.Combine(config.LibationFiles, "FilePaths.json"); + if (!File.Exists(filePaths)) + return; + + var fileLocations = Path.Combine(config.LibationFiles, "FileLocations.json"); + if (!File.Exists(fileLocations)) + File.Copy(filePaths, fileLocations); + + // files to be deleted at the end + var libhackFilesToDelete = new List(); + // .libhack files => errors + var libhackFiles = Directory.EnumerateDirectories(config.Books, "*.libhack", SearchOption.AllDirectories); + + using var context = ApplicationServices.DbContexts.GetContext(); + context.Books.Load(); + + var jArr = JArray.Parse(File.ReadAllText(filePaths)); + + foreach (var jToken in jArr) + { + var asinToken = jToken["Id"]; + var fileTypeToken = jToken["FileType"]; + var pathToken = jToken["Path"]; + if (asinToken is null || fileTypeToken is null || pathToken is null || + asinToken.Type != JTokenType.String || fileTypeToken.Type != JTokenType.Integer || pathToken.Type != JTokenType.String) + continue; + + var asin = asinToken.Value(); + var fileType = (FileType)fileTypeToken.Value(); + var path = pathToken.Value(); + + if (fileType == FileType.Unknown || fileType == FileType.AAXC) + continue; + + var book = context.Books.Local.FirstOrDefault(b => b.AudibleProductId == asin); + if (book is null) + continue; + + // assign these strings and enums/ints unconditionally. EFCore will only update if changed + if (fileType == FileType.PDF) + book.UserDefinedItem.PdfStatus = LiberatedStatus.Liberated; + + if (fileType == FileType.Audio) + { + var lhack = libhackFiles.FirstOrDefault(f => f.ContainsInsensitive(asin)); + if (lhack is null) + book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated; + else + { + book.UserDefinedItem.BookStatus = LiberatedStatus.Error; + libhackFilesToDelete.Add(lhack); + } + } + } + + context.SaveChanges(); + + // only do this after save changes + foreach (var libhackFile in libhackFilesToDelete) + File.Delete(libhackFile); + + File.Delete(filePaths); + } + catch (Exception ex) + { + Log.Logger.Error(ex, "Error attempting to insert FilePaths into db"); + } + } + #endregion + + private static void checkForUpdate() + { + string zipUrl; + string htmlUrl; + string zipName; + try + { + bool hasUpgrade; + (hasUpgrade, zipUrl, htmlUrl, zipName) = AppScaffolding.LibationScaffolding.GetLatestRelease(); + + if (!hasUpgrade) + return; + } + catch (Exception ex) + { + MessageBoxAlertAdmin.Show("Error checking for update", "Error checking for update", ex); + return; + } + + if (zipUrl is null) + { + MessageBox.Show(htmlUrl, "New version available"); + return; + } + + var result = MessageBox.Show($"New version available @ {htmlUrl}\r\nDownload the zip file?", "New version available", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (result != DialogResult.Yes) + return; + + using var fileSelector = new SaveFileDialog { FileName = zipName, Filter = "Zip Files (*.zip)|*.zip|All files (*.*)|*.*" }; + if (fileSelector.ShowDialog() != DialogResult.OK) + return; + var selectedPath = fileSelector.FileName; + + try + { + LibationWinForms.BookLiberation.ProcessorAutomationController.DownloadFile(zipUrl, selectedPath, true); + } + catch (Exception ex) + { + MessageBoxAlertAdmin.Show("Error downloading update", "Error downloading update", ex); + } + } + } +} diff --git a/_DB_NOTES.txt b/_DB_NOTES.txt index 1f53b695..3490ede4 100644 --- a/_DB_NOTES.txt +++ b/_DB_NOTES.txt @@ -6,7 +6,7 @@ Startup project: DataLayer since we have mult contexts, must use -context: Add-Migration MyComment -context LibationContext Update-Database -context LibationContext -Startup project: reset to prev. eg: LibationLauncher +Startup project: reset to prev. eg: LibationWinForms Migrations, detailed diff --git a/__README - COLLABORATORS.txt b/__README - COLLABORATORS.txt index 1d4e9316..1d4204d6 100644 --- a/__README - COLLABORATORS.txt +++ b/__README - COLLABORATORS.txt @@ -30,7 +30,7 @@ STRUCTURE * 5 Domain Utilities (db aware) This is often where database, domain-ignorant util.s, and non-database capabilities come together to provide the domain-conscious access points * 6 Application - UI and application launcher + GUI, CLI, and shared scaffolding CODING GUIDELINES