diff --git a/Solutions/Stacker.Cli/BufferClient.cs b/Solutions/Stacker.Cli/BufferClient.cs index 4f10cab..51753a8 100644 --- a/Solutions/Stacker.Cli/BufferClient.cs +++ b/Solutions/Stacker.Cli/BufferClient.cs @@ -7,9 +7,10 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Text.Json; using System.Threading.Tasks; -using Newtonsoft.Json; +using Spectre.Console; using Stacker.Cli.Configuration; using Stacker.Cli.Contracts.Buffer; @@ -44,35 +45,36 @@ public IEnumerable> ConvertToPayload(string content return postData; } - public async Task UploadAsync(IEnumerable content, string profileId) + public async Task UploadAsync(IEnumerable content, string profileId, bool whatIf) { - using (HttpClient client = this.httpClientFactory.CreateClient()) - { - client.BaseAddress = new(BaseUri); - client.DefaultRequestHeaders.Accept.Clear(); - client.DefaultRequestHeaders.Accept.Add(new("application/x-www-form-urlencoded")); + using HttpClient client = this.httpClientFactory.CreateClient(); + client.BaseAddress = new Uri(BaseUri); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-www-form-urlencoded")); + + StackerSettings settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); + string updateOperationUrl = $"{UpdateOperation}?access_token={settings.BufferAccessToken}"; - StackerSettings settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); - string updateOperationUrl = $"{UpdateOperation}?access_token={settings.BufferAccessToken}"; + foreach (string item in content) + { + AnsiConsole.MarkupLineInterpolated($"[chartreuse3_1]Buffering:[/] {item}"); - foreach (string item in content) + if (whatIf) { - HttpContent payload = new FormUrlEncodedContent(this.ConvertToPayload(item, new string[] { profileId })); + continue; + } - Console.WriteLine($"Buffering: {item}"); + HttpContent payload = new FormUrlEncodedContent(this.ConvertToPayload(item, new string[] { profileId })); - HttpResponseMessage response = await client.PostAsync(updateOperationUrl, payload).ConfigureAwait(false); + HttpResponseMessage response = await client.PostAsync(updateOperationUrl, payload).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - string errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - BufferError error = JsonConvert.DeserializeObject(errorContent); + if (!response.IsSuccessStatusCode) + { + string errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + BufferError error = JsonSerializer.Deserialize(errorContent); - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Buffering Failed: {error.Message}"); - Console.WriteLine(); - Console.ResetColor(); - } + AnsiConsole.MarkupLineInterpolated($"[red]Buffering Failed:[/] {error.Message}"); + AnsiConsole.WriteLine(); } } } diff --git a/Solutions/Stacker.Cli/BufferError.cs b/Solutions/Stacker.Cli/BufferError.cs index da08e4e..8d0b39a 100644 --- a/Solutions/Stacker.Cli/BufferError.cs +++ b/Solutions/Stacker.Cli/BufferError.cs @@ -3,21 +3,21 @@ // using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Stacker.Cli; public class BufferError { - [JsonProperty("success")] + [JsonPropertyName("success")] public bool Success { get; set; } - [JsonProperty("message")] + [JsonPropertyName("message")] public string Message { get; set; } - [JsonProperty("code")] + [JsonPropertyName("code")] public int Code { get; set; } - [JsonProperty("errored_profiles")] + [JsonPropertyName("errored_profiles")] public IEnumerable ErroredProfiles { get; set; } } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Cleaners/ContentItemAttachmentPathCleaner.cs b/Solutions/Stacker.Cli/Cleaners/ContentItemAttachmentPathCleaner.cs index 9feccab..3e3b88a 100644 --- a/Solutions/Stacker.Cli/Cleaners/ContentItemAttachmentPathCleaner.cs +++ b/Solutions/Stacker.Cli/Cleaners/ContentItemAttachmentPathCleaner.cs @@ -4,7 +4,9 @@ using System; using System.Text.RegularExpressions; + using Flurl; + using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Cleaners; diff --git a/Solutions/Stacker.Cli/Cleaners/ContentItemCleaner.cs b/Solutions/Stacker.Cli/Cleaners/ContentItemCleaner.cs index 90ecd73..be02f62 100644 --- a/Solutions/Stacker.Cli/Cleaners/ContentItemCleaner.cs +++ b/Solutions/Stacker.Cli/Cleaners/ContentItemCleaner.cs @@ -5,7 +5,9 @@ using System; using System.Collections.Generic; using System.Linq; + using Microsoft.Extensions.DependencyInjection; + using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Cleaners; diff --git a/Solutions/Stacker.Cli/Cleaners/EnsureEndjinHttpsInBody.cs b/Solutions/Stacker.Cli/Cleaners/EnsureEndjinHttpsInBody.cs index 262effa..cef8f60 100644 --- a/Solutions/Stacker.Cli/Cleaners/EnsureEndjinHttpsInBody.cs +++ b/Solutions/Stacker.Cli/Cleaners/EnsureEndjinHttpsInBody.cs @@ -4,6 +4,7 @@ using System; using System.Text.RegularExpressions; + using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Cleaners; diff --git a/Solutions/Stacker.Cli/Cleaners/RemoveHostNamesFromBody.cs b/Solutions/Stacker.Cli/Cleaners/RemoveHostNamesFromBody.cs index 2fb09fa..8417770 100644 --- a/Solutions/Stacker.Cli/Cleaners/RemoveHostNamesFromBody.cs +++ b/Solutions/Stacker.Cli/Cleaners/RemoveHostNamesFromBody.cs @@ -4,6 +4,7 @@ using System; using System.Text.RegularExpressions; + using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Cleaners; diff --git a/Solutions/Stacker.Cli/Cleaners/WordPressImageResizerCleaner.cs b/Solutions/Stacker.Cli/Cleaners/WordPressImageResizerCleaner.cs index 5220e11..42a3e3b 100644 --- a/Solutions/Stacker.Cli/Cleaners/WordPressImageResizerCleaner.cs +++ b/Solutions/Stacker.Cli/Cleaners/WordPressImageResizerCleaner.cs @@ -3,6 +3,7 @@ // using System.Text.RegularExpressions; + using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Cleaners; diff --git a/Solutions/Stacker.Cli/Commands/EnvironmentCommandFactory.cs b/Solutions/Stacker.Cli/Commands/EnvironmentCommandFactory.cs deleted file mode 100644 index 85bdeef..0000000 --- a/Solutions/Stacker.Cli/Commands/EnvironmentCommandFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System.CommandLine; -using Stacker.Cli.Contracts.Commands; - -namespace Stacker.Cli.Commands; - -public class EnvironmentCommandFactory : ICommandFactory -{ - private readonly ICommandFactory environmentResetCommandFactory; - - public EnvironmentCommandFactory(ICommandFactory environmentResetCommandFactory) - { - this.environmentResetCommandFactory = environmentResetCommandFactory; - } - - public Command Create() - { - var cmd = new Command("environment", "Manipulate the stacker environment."); - - cmd.AddCommand(this.environmentResetCommandFactory.Create()); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/EnvironmentInitCommand.cs b/Solutions/Stacker.Cli/Commands/EnvironmentInitCommand.cs new file mode 100644 index 0000000..377cb82 --- /dev/null +++ b/Solutions/Stacker.Cli/Commands/EnvironmentInitCommand.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +#nullable enable annotations + +using System.Collections.Generic; + +using Spectre.Console; +using Spectre.Console.Cli; + +using Stacker.Cli.Configuration; +using Stacker.Cli.Contracts.Configuration; + +namespace Stacker.Cli.Commands; + +public class EnvironmentInitCommand : Command +{ + private readonly IAppEnvironment appEnvironment; + private readonly IStackerSettingsManager settingsManager; + + public EnvironmentInitCommand(IAppEnvironment appEnvironment, IStackerSettingsManager settingsManager) + { + this.appEnvironment = appEnvironment; + this.settingsManager = settingsManager; + } + + public override int Execute(CommandContext context) + { + this.appEnvironment.Initialize(); + this.settingsManager.SaveSettings( + new StackerSettings + { + BufferAccessToken = "", + BufferProfiles = new Dictionary + { + { "facebook|", "" }, + { "linkedin|", "" }, + { "twitter|", "" }, + }, + Users = new List + { + new() + { + Email = string.Empty, + IsActive = true, + }, + }, + }, + nameof(StackerSettings)); + + AnsiConsole.WriteLine($"Environment Initialized {this.appEnvironment.AppPath}"); + + return 0; + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/EnvironmentInitCommandFactory.cs b/Solutions/Stacker.Cli/Commands/EnvironmentInitCommandFactory.cs deleted file mode 100644 index c333cdc..0000000 --- a/Solutions/Stacker.Cli/Commands/EnvironmentInitCommandFactory.cs +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System; -using System.Collections.Generic; -using System.CommandLine; -using System.CommandLine.Invocation; -using Stacker.Cli.Configuration; -using Stacker.Cli.Contracts.Commands; -using Stacker.Cli.Contracts.Configuration; - -namespace Stacker.Cli.Commands; - -public class EnvironmentInitCommandFactory : ICommandFactory -{ - private readonly IAppEnvironment appEnvironment; - private readonly IStackerSettingsManager settingsManager; - - public EnvironmentInitCommandFactory(IAppEnvironment appEnvironment, IStackerSettingsManager settingsManager) - { - this.appEnvironment = appEnvironment; - this.settingsManager = settingsManager; - } - - public Command Create() - { - return new("init", "Initializes the stacker environment.") - { - Handler = CommandHandler.Create(() => - { - this.appEnvironment.Initialize(); - this.settingsManager.SaveSettings( - new() - { - BufferAccessToken = "", - BufferProfiles = new() - { - { "facebook|", "" }, - { "linkedin|", "" }, - { "twitter|", "" }, - }, - Users = new() - { - new() - { - Email = string.Empty, - IsActive = true, - }, - }, - }, - nameof(StackerSettings)); - - Console.WriteLine($"Environment Initialized {this.appEnvironment.AppPath}"); - }), - }; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/FacebookBufferCommand.cs b/Solutions/Stacker.Cli/Commands/FacebookBufferCommand.cs new file mode 100644 index 0000000..56afe4c --- /dev/null +++ b/Solutions/Stacker.Cli/Commands/FacebookBufferCommand.cs @@ -0,0 +1,87 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +using Spectre.Console.Cli; +using Spectre.IO; + +using Stacker.Cli.Contracts.Tasks; +using Stacker.Cli.Domain.Publication; +using Stacker.Cli.Formatters; + +namespace Stacker.Cli.Commands; + +public class FacebookBufferCommand : AsyncCommand +{ + private readonly IContentTasks contentTasks; + private readonly string profilePrefix = "facebook|"; + + public FacebookBufferCommand(IContentTasks contentTasks) + { + this.contentTasks = contentTasks; + } + + /// + public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] Settings settings) + { + await this.contentTasks.BufferContentItemsAsync( + settings.ContentFilePath, + this.profilePrefix, + settings.ProfileName, + settings.PublicationPeriod, + settings.FromDate, + settings.ToDate, + settings.ItemCount, + settings.FilterByTag, + settings.WhatIf).ConfigureAwait(false); + + return 0; + } + + /// + /// The settings for the command. + /// + public class Settings : CommandSettings + { +#nullable disable annotations + + [CommandOption("-c|--content-file-path ")] + [Description("Content file path.")] + public FilePath ContentFilePath { get; init; } + + [CommandOption("-n|--profile-name ")] + [Description("Facebook profile to Buffer.")] + public string ProfileName { get; init; } + + [CommandOption("-g|--filter-by-tag ")] + [Description("Tag to filter the content items by.")] + public string FilterByTag { get; init; } + + [CommandOption("-i|--item-count ")] + [Description("Number of content items to buffer. If omitted all content is buffered.")] + public int ItemCount { get; init; } + + [CommandOption("-p|--publication-period ")] + [Description("Publication period to filter content items by. If specified --from-date and --to-date are ignored.")] + public PublicationPeriod PublicationPeriod { get; init; } + + [CommandOption("-f|--from-date ")] + [Description("Include content items published on, or after this date. Use YYYY/MM/DD Format. If omitted DateTime.MinValue is used.")] + public DateTime FromDate { get; init; } + + [CommandOption("-t|--to-date ")] + [Description("Include content items published on, or before this date. Use YYYY/MM/DD Format. If omitted DateTime.MaxValue is used.")] + public DateTime ToDate { get; init; } + + [CommandOption("-w|--what-if ")] + [Description("See what the command would do without submitting the content to Buffer.")] + public bool WhatIf { get; set; } + +#nullable enable annotations + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/FacebookBufferCommandFactory.cs b/Solutions/Stacker.Cli/Commands/FacebookBufferCommandFactory.cs deleted file mode 100644 index 25d9833..0000000 --- a/Solutions/Stacker.Cli/Commands/FacebookBufferCommandFactory.cs +++ /dev/null @@ -1,42 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System; -using System.CommandLine; -using System.CommandLine.Invocation; -using Stacker.Cli.Contracts.Commands; -using Stacker.Cli.Contracts.Tasks; -using Stacker.Cli.Domain.Publication; -using Stacker.Cli.Formatters; - -namespace Stacker.Cli.Commands; - -public class FacebookBufferCommandFactory : ICommandFactory -{ - private readonly IContentTasks contentTasks; - - public FacebookBufferCommandFactory(IContentTasks contentTasks) - { - this.contentTasks = contentTasks; - } - - public Command Create() - { - var cmd = new Command("buffer", "Uploads content to Buffer to be published via Facebook") - { - Handler = CommandHandler.Create(async (string contentFilePath, string profileName, int itemCount, DateTime fromDate, DateTime toDate, PublicationPeriod publicationPeriod) => - await this.contentTasks.BufferContentItemsAsync(contentFilePath, $"facebook|", profileName, publicationPeriod, fromDate, toDate, itemCount).ConfigureAwait(false)), - }; - - cmd.Add(new Argument("content-file-path") { Description = "Content file path." }); - cmd.Add(new Argument("profile-name") { Description = "Facebook profile to Buffer." }); - - cmd.AddOption(new("--item-count", "Number of content items to buffer. If omitted all content is buffered.") { Argument = new Argument() }); - cmd.AddOption(new("--publication-period", "Publication period to filter content items by. If specified --from-date and --to-date are ignored.") { Argument = new Argument() }); - cmd.AddOption(new("--from-date", "Include content items published on, or after this date. Use YYYY/MM/DD Format. If omitted DateTime.MinValue is used.") { Argument = new Argument() }); - cmd.AddOption(new("--to-date", "Include content items published on, or before this date. Use YYYY/MM/DD Format. If omitted DateTime.MaxValue is used.") { Argument = new Argument() }); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/FacebookCommandFactory.cs b/Solutions/Stacker.Cli/Commands/FacebookCommandFactory.cs deleted file mode 100644 index 3742c48..0000000 --- a/Solutions/Stacker.Cli/Commands/FacebookCommandFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System.CommandLine; -using Stacker.Cli.Contracts.Commands; - -namespace Stacker.Cli.Commands; - -public class FacebookCommandFactory : ICommandFactory -{ - private readonly ICommandFactory facebookBufferCommandFactory; - - public FacebookCommandFactory(ICommandFactory facebookBufferCommandFactory) - { - this.facebookBufferCommandFactory = facebookBufferCommandFactory; - } - - public Command Create() - { - var cmd = new Command("facebook", "Facebook functionality."); - - cmd.AddCommand(this.facebookBufferCommandFactory.Create()); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/LinkedInBufferCommand.cs b/Solutions/Stacker.Cli/Commands/LinkedInBufferCommand.cs new file mode 100644 index 0000000..99f4dea --- /dev/null +++ b/Solutions/Stacker.Cli/Commands/LinkedInBufferCommand.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +#nullable enable annotations +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +using Spectre.Console.Cli; +using Spectre.IO; + +using Stacker.Cli.Contracts.Tasks; +using Stacker.Cli.Domain.Publication; +using Stacker.Cli.Formatters; + +namespace Stacker.Cli.Commands; + +public class LinkedInBufferCommand : AsyncCommand +{ + private readonly IContentTasks contentTasks; + private readonly string profilePrefix = "linkedin|"; + + public LinkedInBufferCommand(IContentTasks contentTasks) + { + this.contentTasks = contentTasks; + } + + /// + public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] Settings settings) + { + await this.contentTasks.BufferContentItemsAsync( + settings.ContentFilePath, + this.profilePrefix, + settings.ProfileName, + settings.PublicationPeriod, + settings.FromDate, + settings.ToDate, + settings.ItemCount, + settings.FilterByTag, + settings.WhatIf).ConfigureAwait(false); + + return 0; + } + + /// + /// The settings for the command. + /// + public class Settings : CommandSettings + { +#nullable disable annotations + + [CommandOption("-c|--content-file-path ")] + [Description("Content file path.")] + public FilePath ContentFilePath { get; init; } + + [CommandOption("-n|--profile-name ")] + [Description("LinkedIn profile to Buffer.")] + public string ProfileName { get; init; } + + [CommandOption("-g|--filter-by-tag ")] + [Description("Tag to filter the content items by.")] + public string FilterByTag { get; init; } + + [CommandOption("-i|--item-count ")] + [Description("Number of content items to buffer. If omitted all content is buffered.")] + public int ItemCount { get; init; } + + [CommandOption("-p|--publication-period ")] + [Description("Publication period to filter content items by. If specified --from-date and --to-date are ignored.")] + public PublicationPeriod PublicationPeriod { get; init; } + + [CommandOption("-f|--from-date ")] + [Description("Include content items published on, or after this date. Use YYYY/MM/DD Format. If omitted DateTime.MinValue is used.")] + public DateTime FromDate { get; init; } + + [CommandOption("-t|--to-date ")] + [Description("Include content items published on, or before this date. Use YYYY/MM/DD Format. If omitted DateTime.MaxValue is used.")] + public DateTime ToDate { get; init; } + + [CommandOption("-w|--what-if ")] + [Description("See what the command would do without submitting the content to Buffer.")] + public bool WhatIf { get; set; } + +#nullable enable annotations + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/LinkedInBufferCommandFactory.cs b/Solutions/Stacker.Cli/Commands/LinkedInBufferCommandFactory.cs deleted file mode 100644 index 35bc29c..0000000 --- a/Solutions/Stacker.Cli/Commands/LinkedInBufferCommandFactory.cs +++ /dev/null @@ -1,44 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System; -using System.CommandLine; -using System.CommandLine.Invocation; -using Stacker.Cli.Contracts.Commands; -using Stacker.Cli.Contracts.Tasks; -using Stacker.Cli.Domain.Publication; -using Stacker.Cli.Formatters; - -namespace Stacker.Cli.Commands; - -public class LinkedInBufferCommandFactory : ICommandFactory -{ - private readonly IContentTasks contentTasks; - - public LinkedInBufferCommandFactory(IContentTasks contentTasks) - { - this.contentTasks = contentTasks; - } - - public Command Create() - { - var cmd = new Command("buffer", "Uploads content to Buffer to be published via Twitter") - { - Handler = CommandHandler.Create(async (string contentFilePath, string profileName, int itemCount, DateTime fromDate, DateTime toDate, PublicationPeriod publicationPeriod) => - { - await this.contentTasks.BufferContentItemsAsync(contentFilePath, $"linkedin|", profileName, publicationPeriod, fromDate, toDate, itemCount).ConfigureAwait(false); - }), - }; - - cmd.Add(new Argument("content-file-path") { Description = "Content file path." }); - cmd.Add(new Argument("profile-name") { Description = "LinkedIn profile to Buffer." }); - - cmd.AddOption(new("--item-count", "Number of content items to buffer. If omitted all content is buffered.") { Argument = new Argument() }); - cmd.AddOption(new("--publication-period", "Publication period to filter content items by. If specified --from-date and --to-date are ignored.") { Argument = new Argument() }); - cmd.AddOption(new("--from-date", "Include content items published on, or after this date. Use YYYY/MM/DD Format. If omitted DateTime.MinValue is used.") { Argument = new Argument() }); - cmd.AddOption(new("--to-date", "Include content items published on, or before this date. Use YYYY/MM/DD Format. If omitted DateTime.MaxValue is used.") { Argument = new Argument() }); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/LinkedInCommandFactory.cs b/Solutions/Stacker.Cli/Commands/LinkedInCommandFactory.cs deleted file mode 100644 index 20165e0..0000000 --- a/Solutions/Stacker.Cli/Commands/LinkedInCommandFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System.CommandLine; -using Stacker.Cli.Contracts.Commands; - -namespace Stacker.Cli.Commands; - -public class LinkedInCommandFactory : ICommandFactory -{ - private readonly ICommandFactory twitterBufferCommandFactory; - - public LinkedInCommandFactory(ICommandFactory twitterBufferCommandFactory) - { - this.twitterBufferCommandFactory = twitterBufferCommandFactory; - } - - public Command Create() - { - var cmd = new Command("linkedin", "LinkedIn functionality."); - - cmd.AddCommand(this.twitterBufferCommandFactory.Create()); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/TwitterBufferCommand.cs b/Solutions/Stacker.Cli/Commands/TwitterBufferCommand.cs new file mode 100644 index 0000000..b53e3c9 --- /dev/null +++ b/Solutions/Stacker.Cli/Commands/TwitterBufferCommand.cs @@ -0,0 +1,88 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +#nullable enable annotations +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +using Spectre.Console.Cli; +using Spectre.IO; + +using Stacker.Cli.Contracts.Tasks; +using Stacker.Cli.Domain.Publication; +using Stacker.Cli.Formatters; + +namespace Stacker.Cli.Commands; + +public class TwitterBufferCommand : AsyncCommand +{ + private readonly IContentTasks contentTasks; + private readonly string profilePrefix = "twitter|"; + + public TwitterBufferCommand(IContentTasks contentTasks) + { + this.contentTasks = contentTasks; + } + + /// + public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] Settings settings) + { + await this.contentTasks.BufferContentItemsAsync( + settings.ContentFilePath, + this.profilePrefix, + settings.ProfileName, + settings.PublicationPeriod, + settings.FromDate, + settings.ToDate, + settings.ItemCount, + settings.FilterByTag, + settings.WhatIf).ConfigureAwait(false); + + return 0; + } + + /// + /// The settings for the command. + /// + public class Settings : CommandSettings + { +#nullable disable annotations + + [CommandOption("-c|--content-file-path ")] + [Description("Content file path.")] + public FilePath ContentFilePath { get; init; } + + [CommandOption("-n|--profile-name ")] + [Description("Twitter profile to Buffer.")] + public string ProfileName { get; init; } + + [CommandOption("-g|--filter-by-tag ")] + [Description("Tag to filter the content items by.")] + public string FilterByTag { get; init; } + + [CommandOption("-i|--item-count ")] + [Description("Number of content items to buffer. If omitted all content is buffered.")] + public int ItemCount { get; init; } + + [CommandOption("-p|--publication-period ")] + [Description("Publication period to filter content items by. If specified --from-date and --to-date are ignored.")] + public PublicationPeriod PublicationPeriod { get; init; } + + [CommandOption("-f|--from-date ")] + [Description("Include content items published on, or after this date. Use YYYY/MM/DD Format. If omitted DateTime.MinValue is used.")] + public DateTime FromDate { get; init; } + + [CommandOption("-t|--to-date ")] + [Description("Include content items published on, or before this date. Use YYYY/MM/DD Format. If omitted DateTime.MaxValue is used.")] + public DateTime ToDate { get; init; } + + [CommandOption("-w|--what-if ")] + [Description("See what the command would do without submitting the content to Buffer.")] + public bool WhatIf { get; set; } + +#nullable enable annotations + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/TwitterBufferCommandFactory.cs b/Solutions/Stacker.Cli/Commands/TwitterBufferCommandFactory.cs deleted file mode 100644 index 5c59146..0000000 --- a/Solutions/Stacker.Cli/Commands/TwitterBufferCommandFactory.cs +++ /dev/null @@ -1,44 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System; -using System.CommandLine; -using System.CommandLine.Invocation; -using Stacker.Cli.Contracts.Commands; -using Stacker.Cli.Contracts.Tasks; -using Stacker.Cli.Domain.Publication; -using Stacker.Cli.Formatters; - -namespace Stacker.Cli.Commands; - -public class TwitterBufferCommandFactory : ICommandFactory -{ - private readonly IContentTasks contentTasks; - - public TwitterBufferCommandFactory(IContentTasks contentTasks) - { - this.contentTasks = contentTasks; - } - - public Command Create() - { - var cmd = new Command("buffer", "Uploads content to Buffer to be published via Twitter") - { - Handler = CommandHandler.Create(async (string contentFilePath, string profileName, int itemCount, DateTime fromDate, DateTime toDate, PublicationPeriod publicationPeriod) => - { - await this.contentTasks.BufferContentItemsAsync(contentFilePath, $"twitter|", profileName, publicationPeriod, fromDate, toDate, itemCount).ConfigureAwait(false); - }), - }; - - cmd.Add(new Argument("content-file-path") { Description = "Content file path." }); - cmd.Add(new Argument("profile-name") { Description = "Twitter profile to Buffer." }); - - cmd.AddOption(new("--item-count", "Number of content items to buffer. If omitted all content is buffered.") { Argument = new Argument() }); - cmd.AddOption(new("--publication-period", "Publication period to filter content items by. If specified --from-date and --to-date are ignored.") { Argument = new Argument() }); - cmd.AddOption(new("--from-date", "Include content items published on, or after this date. Use YYYY/MM/DD Format. If omitted DateTime.MinValue is used.") { Argument = new Argument() }); - cmd.AddOption(new("--to-date", "Include content items published on, or before this date. Use YYYY/MM/DD Format. If omitted DateTime.MaxValue is used.") { Argument = new Argument() }); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/TwitterCommandFactory.cs b/Solutions/Stacker.Cli/Commands/TwitterCommandFactory.cs deleted file mode 100644 index 1a98160..0000000 --- a/Solutions/Stacker.Cli/Commands/TwitterCommandFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System.CommandLine; -using Stacker.Cli.Contracts.Commands; - -namespace Stacker.Cli.Commands; - -public class TwitterCommandFactory : ICommandFactory -{ - private readonly ICommandFactory twitterBufferCommandFactory; - - public TwitterCommandFactory(ICommandFactory twitterBufferCommandFactory) - { - this.twitterBufferCommandFactory = twitterBufferCommandFactory; - } - - public Command Create() - { - var cmd = new Command("twitter", "Twitter functionality."); - - cmd.AddCommand(this.twitterBufferCommandFactory.Create()); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/WordPressCommandFactory.cs b/Solutions/Stacker.Cli/Commands/WordPressCommandFactory.cs deleted file mode 100644 index bb64105..0000000 --- a/Solutions/Stacker.Cli/Commands/WordPressCommandFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System.CommandLine; -using Stacker.Cli.Contracts.Commands; - -namespace Stacker.Cli.Commands; - -public class WordPressCommandFactory : ICommandFactory -{ - private readonly ICommandFactory wordpressExportCommandFactory; - - public WordPressCommandFactory(ICommandFactory wordpressExportCommandFactory) - { - this.wordpressExportCommandFactory = wordpressExportCommandFactory; - } - - public Command Create() - { - var cmd = new Command("wordpress", "WordPress functionality."); - - cmd.AddCommand(this.wordpressExportCommandFactory.Create()); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/WordPressExportCommandFactory.cs b/Solutions/Stacker.Cli/Commands/WordPressExportCommandFactory.cs deleted file mode 100644 index df0f29b..0000000 --- a/Solutions/Stacker.Cli/Commands/WordPressExportCommandFactory.cs +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System.CommandLine; -using Stacker.Cli.Contracts.Commands; - -namespace Stacker.Cli.Commands; - -public class WordPressExportCommandFactory : ICommandFactory -{ - private readonly ICommandFactory universalExportCommandFactory; - private readonly ICommandFactory markdownExportCommandFactory; - - public WordPressExportCommandFactory( - ICommandFactory universalExportCommandFactory, - ICommandFactory markdownExportCommandFactory) - { - this.universalExportCommandFactory = universalExportCommandFactory; - this.markdownExportCommandFactory = markdownExportCommandFactory; - } - - public Command Create() - { - var cmd = new Command("export", "Perform operations on WordPress export files."); - cmd.AddCommand(this.universalExportCommandFactory.Create()); - cmd.AddCommand(this.markdownExportCommandFactory.Create()); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/WordPressExportMarkDownCommandFactory.cs b/Solutions/Stacker.Cli/Commands/WordPressExportMarkDownCommandFactory.cs deleted file mode 100644 index 5d3bda6..0000000 --- a/Solutions/Stacker.Cli/Commands/WordPressExportMarkDownCommandFactory.cs +++ /dev/null @@ -1,320 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System; -using System.Collections.Generic; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using System.Xml.Linq; -using Stacker.Cli.Cleaners; -using Stacker.Cli.Configuration; -using Stacker.Cli.Contracts.Commands; -using Stacker.Cli.Contracts.Configuration; -using Stacker.Cli.Domain.Universal; -using Stacker.Cli.Domain.WordPress; -using Stacker.Cli.Serialization; -using Stacker.Cli.Tasks; -using YamlDotNet.Serialization; - -namespace Stacker.Cli.Commands; - -public class WordPressExportMarkDownCommandFactory : ICommandFactory -{ - private readonly IDownloadTasks downloadTasks; - private readonly IStackerSettingsManager settingsManager; - private readonly ContentItemCleaner cleanerManager; - private readonly IYamlSerializerFactory serializerFactory; - private ISerializer serializer; - private StackerSettings settings; - - public WordPressExportMarkDownCommandFactory(IStackerSettingsManager settingsManager, IDownloadTasks downloadTasks, ContentItemCleaner cleanerManager, IYamlSerializerFactory serializerFactory) - { - this.settingsManager = settingsManager; - this.downloadTasks = downloadTasks; - this.cleanerManager = cleanerManager; - this.serializerFactory = serializerFactory; - } - - public Command Create() - { - var cmd = new Command("markdown", "Convert WordPress export files into a markdown format.") - { - Handler = CommandHandler.Create(async (string wpexportFilePath, string exportFilePath) => - { - this.settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); - - if (!File.Exists(wpexportFilePath)) - { - Console.WriteLine($"File not found {wpexportFilePath}"); - - return; - } - - this.serializer = this.serializerFactory.GetSerializer(); - - BlogSite blogSite = await this.LoadWordPressExportAsync(wpexportFilePath).ConfigureAwait(false); - - List feed = this.LoadFeed(blogSite); - - var sb = new StringBuilder(); - FileInfo fi = new(exportFilePath); - DirectoryInfo tempHtmlFolder = new(Path.Join(Path.GetTempPath(), "stacker", "html")); - DirectoryInfo tempMarkdownFolder = new(Path.Join(Path.GetTempPath(), "stacker", "md")); - string inputTempHtmlFilePath; - string outputTempMarkdownFilePath; - string outputFilePath; - - if (!fi.Directory.Exists) - { - fi.Directory.Create(); - } - - if (!tempHtmlFolder.Exists) - { - tempHtmlFolder.Create(); - } - - if (!tempMarkdownFolder.Exists) - { - tempMarkdownFolder.Create(); - } - - // await this.downloadTasks.DownloadAsync(feed, exportFilePath).ConfigureAwait(false); - foreach (ContentItem ci in feed) - { - ContentItem contentItem = this.cleanerManager.PostDownload(ci); - - sb.AppendLine("---"); - sb.Append(this.CreateYamlHeader(contentItem)); - sb.Append("---"); - sb.Append(Environment.NewLine); - sb.Append(Environment.NewLine); - - await using (StreamWriter writer = File.CreateText(Path.Combine(tempHtmlFolder.FullName, contentItem.UniqueId + ".html"))) - { - await writer.WriteAsync(contentItem.Content.Body).ConfigureAwait(false); - } - - inputTempHtmlFilePath = Path.Combine(tempHtmlFolder.FullName, contentItem.UniqueId + ".html"); - outputTempMarkdownFilePath = Path.Combine(tempMarkdownFolder.FullName, contentItem.UniqueId + ".md"); - outputFilePath = Path.Combine(exportFilePath, contentItem.Author.Username.ToLowerInvariant(), contentItem.UniqueId + ".md"); - - FileInfo outputFile = new(outputFilePath); - - if (!outputFile.Directory.Exists) - { - outputFile.Directory.Create(); - } - - if (this.ExecutePandoc(inputTempHtmlFilePath, outputTempMarkdownFilePath)) - { - sb.Append(await File.ReadAllTextAsync(outputTempMarkdownFilePath).ConfigureAwait(false)); - - string content = sb.ToString(); - - Console.WriteLine(outputFilePath); - - try - { - content = this.cleanerManager.PostConvert(content); - } - catch (Exception exception) - { - Console.WriteLine(exception.Message); - } - - await using (StreamWriter writer = File.CreateText(outputFilePath)) - { - await writer.WriteAsync(content).ConfigureAwait(false); - } - } - - // Remote the temporary html file. - File.Delete(inputTempHtmlFilePath); - - sb.Clear(); - } - }), - }; - - cmd.AddArgument(new Argument("wp-export-file-path") { Description = "WordPress Export file path." }); - cmd.AddArgument(new Argument("export-file-path") { Description = "File path for the exported files." }); - - return cmd; - } - - private string CreateYamlHeader(ContentItem contentItem) - { - if (string.IsNullOrEmpty(contentItem.Slug)) - { - contentItem.Slug = new(Regex.Replace(contentItem.Content.Title.ToLowerInvariant().Replace(" ", "-"), @"\-+", "-").Where(ch => !Path.GetInvalidFileNameChars().Contains(ch)).ToArray()); - } - - var frontmatter = new - { - Title = contentItem.Content.Title.Replace("“", "\"").Replace("”", "\"").Replace("’", "'").Replace("‘", "'"), - Date = contentItem.PublishedOn.ToString("O"), - Author = contentItem.Author.Username, - Category = contentItem.Categories, - Tags = contentItem.Tags, - Slug = contentItem.Slug, - Status = contentItem.Status, - HeaderImageUrl = this.GetHeaderImage(contentItem.Content.Attachments.Select(x => x.Path).Distinct().ToList()), - Excerpt = contentItem.Content.Excerpt.Replace("\n", string.Empty).Replace("“", "\"").Replace("”", "\"").Replace("’", "'").Replace("‘", "'").Trim(), - Attachments = contentItem.Content.Attachments.Select(x => x.Path).Distinct(), - }; - - return this.serializer.Serialize(frontmatter); - } - - private List LoadFeed(BlogSite blogSite) - { - Console.WriteLine($"Processing..."); - - var feed = new List(); - StackerSettings settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); - var posts = blogSite.GetAllPostsInAllPublicationStates().ToList(); - - Console.WriteLine($"Total Posts: {posts.Count}"); - - // var attachments = posts.Where(x => x.Attachments.Any()); - foreach (Post post in posts) - { - User user = settings.Users.Find(u => string.Equals(u.Email, post.Author.Email, StringComparison.InvariantCultureIgnoreCase)); - - if (user == null) - { - throw new NotImplementedException($"User {post.Author.Email} has not been configured. Update the settings file."); - } - - var ci = new ContentItem - { - Author = new() - { - DisplayName = post.Author.DisplayName, - Email = post.Author.Email, - TwitterHandle = user.Twitter, - Username = post.Author.Username, - }, - Categories = post.Categories.Select(c => c.Name).Where(x => !this.IsCategoryExcluded(x)), - Content = new() - { - Attachments = post.Attachments.Select(x => new ContentAttachment { Path = x.Path, Url = x.Url }).ToList(), - Body = post.Body, - Excerpt = post.Excerpt, - Link = post.Link, - Title = post.Title, - }, - Id = post.Id, - PublishedOn = post.PublishedAtUtc, - Promote = post.Promote, - PromoteUntil = post.PromoteUntil, - Slug = post.Slug, - Status = post.Status, - Tags = post.Tags.Where(t => t != null).Select(t => t.Name), - }; - - // Search the body for any missing images. - MatchCollection matches = Regex.Matches(post.Body, "", RegexOptions.IgnoreCase | RegexOptions.Multiline); - - if (matches.Count > 0) - { - foreach (Match match in matches) - { - if (!ci.Content.Attachments.Any(x => string.Equals(x.Url, match.Groups[1].Value, StringComparison.InvariantCultureIgnoreCase)) && this.IsRelevantHost(match.Groups[1].Value)) - { - ci.Content.Attachments.Add(new() { Path = match.Groups[1].Value, Url = match.Groups[1].Value }); - } - } - } - - ci = this.cleanerManager.PreDownload(ci); - - feed.Add(ci); - } - - return feed; - } - - private async Task LoadWordPressExportAsync(string wpexportFilePath) - { - BlogSite blogSite; - - Console.WriteLine($"Reading {wpexportFilePath}"); - - using (StreamReader reader = File.OpenText(wpexportFilePath)) - { - XDocument document = await XDocument.LoadAsync(reader, LoadOptions.None, CancellationToken.None).ConfigureAwait(false); - blogSite = new(document); - } - - return blogSite; - } - - private bool ExecutePandoc(string inputTempHtmlFilePath, string outputTempMarkdownFilePath) - { - bool success = false; - - string arguments = $"-f html+raw_html --to=markdown_github-raw_html --wrap=preserve -o \"{outputTempMarkdownFilePath}\" \"{inputTempHtmlFilePath}\" "; - - var psi = new ProcessStartInfo - { - FileName = "pandoc", - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardInput = true, - }; - - var process = new System.Diagnostics.Process { StartInfo = psi }; - process.Start(); - process.WaitForExit(); - - if (process.ExitCode != 0) - { - Console.WriteLine("Failed to convert " + outputTempMarkdownFilePath); - Console.WriteLine(process.StandardError.ReadToEnd()); - } - else - { - success = true; - } - - return success; - } - - private bool IsCategoryExcluded(string category) - { - return this.settings.WordPressToMarkdown.TagsToRemove.Contains(category); - } - - private string GetHeaderImage(List attachments) - { - if (attachments.Count == 1) - { - return attachments[0]; - } - - string header = attachments.Find(x => x.Contains("header-", StringComparison.InvariantCultureIgnoreCase) || x.Contains("1024px", StringComparison.InvariantCultureIgnoreCase)); - - if (!string.IsNullOrEmpty(header)) - { - return header.Trim(); - } - - return string.Empty; - } - - private bool IsRelevantHost(string url) - { - return this.settings.WordPressToMarkdown.Hosts.Any(x => url.Contains(x, StringComparison.InvariantCultureIgnoreCase)); - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/WordPressExportMarkdownCommand.cs b/Solutions/Stacker.Cli/Commands/WordPressExportMarkdownCommand.cs new file mode 100644 index 0000000..f5ae3cb --- /dev/null +++ b/Solutions/Stacker.Cli/Commands/WordPressExportMarkdownCommand.cs @@ -0,0 +1,324 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; + +using Spectre.Console; +using Spectre.Console.Cli; +using Spectre.IO; + +using Stacker.Cli.Cleaners; +using Stacker.Cli.Configuration; +using Stacker.Cli.Contracts.Configuration; +using Stacker.Cli.Domain.Universal; +using Stacker.Cli.Domain.WordPress; +using Stacker.Cli.Serialization; +using Stacker.Cli.Tasks; + +using YamlDotNet.Serialization; + +using Environment = System.Environment; +using Path = System.IO.Path; + +namespace Stacker.Cli.Commands; + +public class WordPressExportMarkdownCommand : AsyncCommand +{ + private readonly IDownloadTasks downloadTasks; + private readonly IStackerSettingsManager settingsManager; + private readonly ContentItemCleaner cleanerManager; + private readonly IYamlSerializerFactory serializerFactory; + private ISerializer serializer; + private StackerSettings stackerSettings; + + public WordPressExportMarkdownCommand(IDownloadTasks downloadTasks, IStackerSettingsManager settingsManager, ContentItemCleaner cleanerManager, IYamlSerializerFactory serializerFactory) + { + this.downloadTasks = downloadTasks; + this.settingsManager = settingsManager; + this.cleanerManager = cleanerManager; + this.serializerFactory = serializerFactory; + } + + /// + public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] Settings settings) + { + this.stackerSettings = this.settingsManager.LoadSettings(nameof(StackerSettings)); + + if (!File.Exists(settings.WordPressExportFilePath.FullPath)) + { + AnsiConsole.WriteLine($"File not found {settings.WordPressExportFilePath.FullPath}"); + + return 1; + } + + this.serializer = this.serializerFactory.GetSerializer(); + + BlogSite blogSite = await this.LoadWordPressExportAsync(settings.WordPressExportFilePath.FullPath).ConfigureAwait(false); + + List feed = this.LoadFeed(blogSite); + + StringBuilder sb = new(); + FileInfo fi = new(settings.OutputDirectoryPath.FullPath); + DirectoryInfo tempHtmlFolder = new(Path.Join(Path.GetTempPath(), "stacker", "html")); + DirectoryInfo tempMarkdownFolder = new(Path.Join(Path.GetTempPath(), "stacker", "md")); + + string inputTempHtmlFilePath; + string outputTempMarkdownFilePath; + string outputFilePath; + + if (!fi.Directory.Exists) + { + fi.Directory.Create(); + } + + if (!tempHtmlFolder.Exists) + { + tempHtmlFolder.Create(); + } + + if (!tempMarkdownFolder.Exists) + { + tempMarkdownFolder.Create(); + } + + // await this.downloadTasks.DownloadAsync(feed, exportFilePath).ConfigureAwait(false); + foreach (ContentItem ci in feed) + { + ContentItem contentItem = this.cleanerManager.PostDownload(ci); + + sb.AppendLine("---"); + sb.Append(this.CreateYamlHeader(contentItem)); + sb.Append("---"); + sb.Append(Environment.NewLine); + sb.Append(Environment.NewLine); + + await using (StreamWriter writer = File.CreateText(Path.Combine(tempHtmlFolder.FullName, contentItem.UniqueId + ".html"))) + { + await writer.WriteAsync(contentItem.Content.Body).ConfigureAwait(false); + } + + inputTempHtmlFilePath = Path.Combine(tempHtmlFolder.FullName, contentItem.UniqueId + ".html"); + outputTempMarkdownFilePath = Path.Combine(tempMarkdownFolder.FullName, contentItem.UniqueId + ".md"); + outputFilePath = Path.Combine(settings.OutputDirectoryPath.FullPath, contentItem.Author.Username.ToLowerInvariant(), contentItem.UniqueId + ".md"); + + FileInfo outputFile = new(outputFilePath); + + if (!outputFile.Directory.Exists) + { + outputFile.Directory.Create(); + } + + if (this.ExecutePandoc(inputTempHtmlFilePath, outputTempMarkdownFilePath)) + { + sb.Append(await File.ReadAllTextAsync(outputTempMarkdownFilePath).ConfigureAwait(false)); + + string content = sb.ToString(); + + AnsiConsole.WriteLine(outputFilePath); + + try + { + content = this.cleanerManager.PostConvert(content); + } + catch (Exception exception) + { + AnsiConsole.WriteLine(exception.Message); + } + + await using StreamWriter writer = File.CreateText(outputFilePath); + await writer.WriteAsync(content).ConfigureAwait(false); + } + + // Remote the temporary html file. + File.Delete(inputTempHtmlFilePath); + + sb.Clear(); + } + + return 0; + } + + private string CreateYamlHeader(ContentItem contentItem) + { + if (string.IsNullOrEmpty(contentItem.Slug)) + { + contentItem.Slug = new string(Regex.Replace(contentItem.Content.Title.ToLowerInvariant().Replace(" ", "-"), @"\-+", "-").Where(ch => !Path.GetInvalidFileNameChars().Contains(ch)).ToArray()); + } + + var frontMatter = new + { + Title = contentItem.Content.Title.Replace("“", "\"").Replace("”", "\"").Replace("’", "'").Replace("‘", "'"), + Date = contentItem.PublishedOn.ToString("O"), + Author = contentItem.Author.Username, + Category = contentItem.Categories, + Tags = contentItem.Tags, + Slug = contentItem.Slug, + Status = contentItem.Status, + HeaderImageUrl = this.GetHeaderImage(contentItem.Content.Attachments.Select(x => x.Path).Distinct().ToList()), + Excerpt = contentItem.Content.Excerpt.Replace("\n", string.Empty).Replace("“", "\"").Replace("”", "\"").Replace("’", "'").Replace("‘", "'").Trim(), + Attachments = contentItem.Content.Attachments.Select(x => x.Path).Distinct(), + }; + + return this.serializer.Serialize(frontMatter); + } + + private List LoadFeed(BlogSite blogSite) + { + AnsiConsole.WriteLine("Processing..."); + + List feed = new(); + StackerSettings settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); + List posts = blogSite.GetAllPostsInAllPublicationStates().ToList(); + + AnsiConsole.WriteLine($"Total Posts: {posts.Count}"); + + // var attachments = posts.Where(x => x.Attachments.Any()); + foreach (Post post in posts) + { + User user = settings.Users.Find(u => string.Equals(u.Email, post.Author.Email, StringComparison.InvariantCultureIgnoreCase)); + + if (user is null) + { + throw new NotImplementedException($"User {post.Author.Email} has not been configured. Update the settings file."); + } + + ContentItem ci = new() + { + Author = new AuthorDetails + { + DisplayName = post.Author.DisplayName, + Email = post.Author.Email, + TwitterHandle = user.Twitter, + Username = post.Author.Username, + }, + Categories = post.Categories.Select(c => c.Name).Where(x => !this.IsCategoryExcluded(x)), + Content = new ContentDetails + { + Attachments = post.Attachments.Select(x => new ContentAttachment { Path = x.Path, Url = x.Url }).ToList(), + Body = post.Body, + Excerpt = post.Excerpt, + Link = post.Link, + Title = post.Title, + }, + Id = post.Id, + PublishedOn = post.PublishedAtUtc, + Promote = post.Promote, + PromoteUntil = post.PromoteUntil, + Slug = post.Slug, + Status = post.Status, + Tags = post.Tags.Where(t => t != null).Select(t => t.Name).ToList(), + }; + + // Search the body for any missing images. + MatchCollection matches = Regex.Matches(post.Body, "", RegexOptions.IgnoreCase | RegexOptions.Multiline); + + if (matches.Count > 0) + { + foreach (Match match in matches) + { + if (!ci.Content.Attachments.Any(x => string.Equals(x.Url, match.Groups[1].Value, StringComparison.InvariantCultureIgnoreCase)) && this.IsRelevantHost(match.Groups[1].Value)) + { + ci.Content.Attachments.Add(new ContentAttachment { Path = match.Groups[1].Value, Url = match.Groups[1].Value }); + } + } + } + + ci = this.cleanerManager.PreDownload(ci); + + feed.Add(ci); + } + + return feed; + } + + private async Task LoadWordPressExportAsync(string exportFilePath) + { + AnsiConsole.WriteLine($"Reading {exportFilePath}"); + + using StreamReader reader = File.OpenText(exportFilePath); + XDocument document = await XDocument.LoadAsync(reader, LoadOptions.None, CancellationToken.None).ConfigureAwait(false); + BlogSite blogSite = new(document); + + return blogSite; + } + + private bool ExecutePandoc(string inputTempHtmlFilePath, string outputTempMarkdownFilePath) + { + bool success = false; + + string arguments = $"-f html+raw_html --to=markdown_github-raw_html --wrap=preserve -o \"{outputTempMarkdownFilePath}\" \"{inputTempHtmlFilePath}\" "; + + ProcessStartInfo psi = new() + { + FileName = "pandoc", + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardInput = true, + }; + + Process process = new() { StartInfo = psi }; + process.Start(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + AnsiConsole.WriteLine("Failed to convert " + outputTempMarkdownFilePath); + AnsiConsole.WriteLine(process.StandardError.ReadToEnd()); + } + else + { + success = true; + } + + return success; + } + + private bool IsCategoryExcluded(string category) + { + return this.stackerSettings.WordPressToMarkdown.TagsToRemove.Contains(category); + } + + private string GetHeaderImage(List attachments) + { + if (attachments.Count == 1) + { + return attachments[0]; + } + + string header = attachments.Find(x => x.Contains("header-", StringComparison.InvariantCultureIgnoreCase) || x.Contains("1024px", StringComparison.InvariantCultureIgnoreCase)); + + return !string.IsNullOrEmpty(header) ? header.Trim() : string.Empty; + } + + private bool IsRelevantHost(string url) + { + return this.stackerSettings.WordPressToMarkdown.Hosts.Any(x => url.Contains(x, StringComparison.InvariantCultureIgnoreCase)); + } + + /// + /// The settings for the command. + /// + public class Settings : CommandSettings + { + [CommandOption("-w|--wp-export-file-path ")] + [Description("WordPress Export file path.")] + public FilePath WordPressExportFilePath { get; init; } + + [CommandOption("-o|--output-directory-path ")] + [Description("Directory path for the exported files.")] + public DirectoryPath OutputDirectoryPath { get; init; } + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/WordPressExportUniversalCommand.cs b/Solutions/Stacker.Cli/Commands/WordPressExportUniversalCommand.cs new file mode 100644 index 0000000..7a1e428 --- /dev/null +++ b/Solutions/Stacker.Cli/Commands/WordPressExportUniversalCommand.cs @@ -0,0 +1,122 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; + +using Spectre.Console; +using Spectre.Console.Cli; +using Spectre.IO; + +using Stacker.Cli.Configuration; +using Stacker.Cli.Contracts.Configuration; +using Stacker.Cli.Converters; +using Stacker.Cli.Domain.Universal; +using Stacker.Cli.Domain.WordPress; + +namespace Stacker.Cli.Commands; + +public class WordPressExportUniversalCommand : AsyncCommand +{ + private readonly IStackerSettingsManager settingsManager; + + public WordPressExportUniversalCommand(IStackerSettingsManager settingsManager) + { + this.settingsManager = settingsManager; + } + + /// + public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] Settings settings) + { + if (!File.Exists(settings.WordPressExportFilePath.FullPath)) + { + AnsiConsole.WriteLine($"File not found {settings.WordPressExportFilePath.FullPath}"); + + return 1; + } + + BlogSite blogSite; + + AnsiConsole.WriteLine($"Reading {settings.WordPressExportFilePath.FullPath}"); + + using (StreamReader reader = File.OpenText(settings.WordPressExportFilePath.FullPath)) + { + XDocument document = await XDocument.LoadAsync(reader, LoadOptions.None, CancellationToken.None).ConfigureAwait(false); + blogSite = new BlogSite(document); + } + + AnsiConsole.WriteLine($"Processing..."); + + StackerSettings stackerSettings = this.settingsManager.LoadSettings(nameof(StackerSettings)); + List posts = blogSite.GetAllPosts().ToList(); + List validPosts = posts.FilterByValid(stackerSettings).ToList(); + List promotablePosts = validPosts.FilterByPromotable().ToList(); + TagToHashTagConverter hashTagConverter = new(); + List feed = new(); + + AnsiConsole.WriteLine($"Total Posts: {posts.Count()}"); + AnsiConsole.WriteLine($"Valid Posts: {validPosts.Count()}"); + AnsiConsole.WriteLine($"Promotable Posts: {promotablePosts.Count()}"); + + foreach (Post post in promotablePosts) + { + User user = stackerSettings.Users.Find(u => string.Equals(u.Email, post.Author.Email, StringComparison.InvariantCultureIgnoreCase)); + + feed.Add(new ContentItem + { + Author = new AuthorDetails + { + DisplayName = post.Author.DisplayName, + Email = post.Author.Email, + TwitterHandle = user.Twitter, + }, + Content = new ContentDetails + { + Body = post.Body, + Excerpt = post.Excerpt, + Link = post.Link, + Title = post.Title, + }, + PublishedOn = post.PublishedAtUtc, + Promote = post.Promote, + PromoteUntil = post.PromoteUntil, + Status = post.Status, + Slug = post.Slug, + Tags = post.Tags.Where(t => t != null).Select(t => t.Name).ToList(), + }); + } + + await using (StreamWriter writer = File.CreateText(settings.UniversalFilePath.FullPath)) + { + JsonSerializerOptions options = new() { WriteIndented = true }; + await writer.WriteAsync(JsonSerializer.Serialize(feed, options)).ConfigureAwait(false); + } + + AnsiConsole.WriteLine($"Content written to {settings.UniversalFilePath.FullPath}"); + + return 0; + } + + /// + /// The settings for the command. + /// + public class Settings : CommandSettings + { + [CommandOption("-w|--wp-export-file-path ")] + [Description("WordPress Export file path.")] + public FilePath WordPressExportFilePath { get; init; } + + [CommandOption("-o|--universal-file-path ")] + [Description("Universal file path.")] + public FilePath UniversalFilePath { get; init; } + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Commands/WordPressExportUniversalCommandFactory.cs b/Solutions/Stacker.Cli/Commands/WordPressExportUniversalCommandFactory.cs deleted file mode 100644 index ba9db3b..0000000 --- a/Solutions/Stacker.Cli/Commands/WordPressExportUniversalCommandFactory.cs +++ /dev/null @@ -1,110 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System; -using System.Collections.Generic; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; -using System.Linq; -using System.Threading; -using System.Xml.Linq; -using Newtonsoft.Json; -using Stacker.Cli.Configuration; -using Stacker.Cli.Contracts.Commands; -using Stacker.Cli.Contracts.Configuration; -using Stacker.Cli.Converters; -using Stacker.Cli.Domain.Universal; -using Stacker.Cli.Domain.WordPress; - -namespace Stacker.Cli.Commands; - -public class WordPressExportUniversalCommandFactory : ICommandFactory -{ - private readonly IStackerSettingsManager settingsManager; - - public WordPressExportUniversalCommandFactory(IStackerSettingsManager settingsManager) - { - this.settingsManager = settingsManager; - } - - public Command Create() - { - var cmd = new Command("universal", "Convert WordPress export files into a universal format.") - { - Handler = CommandHandler.Create(async (string wpexportFilePath, string universalFilePath) => - { - if (!File.Exists(wpexportFilePath)) - { - Console.WriteLine($"File not found {wpexportFilePath}"); - - return; - } - - BlogSite blogSite; - - Console.WriteLine($"Reading {wpexportFilePath}"); - - using (StreamReader reader = File.OpenText(wpexportFilePath)) - { - XDocument document = await XDocument.LoadAsync(reader, LoadOptions.None, CancellationToken.None).ConfigureAwait(false); - blogSite = new(document); - } - - Console.WriteLine($"Processing..."); - - StackerSettings settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); - var posts = blogSite.GetAllPosts().ToList(); - var validPosts = posts.FilterByValid(settings).ToList(); - var promotablePosts = validPosts.FilterByPromotable().ToList(); - var hashTagConverter = new WordPressTagToHashTagConverter(); - var feed = new List(); - - Console.WriteLine($"Total Posts: {posts.Count()}"); - Console.WriteLine($"Valid Posts: {validPosts.Count()}"); - Console.WriteLine($"Promotable Posts: {promotablePosts.Count()}"); - - foreach (Post post in promotablePosts) - { - User user = settings.Users.Find(u => string.Equals(u.Email, post.Author.Email, StringComparison.InvariantCultureIgnoreCase)); - - feed.Add(new() - { - Author = new() - { - DisplayName = post.Author.DisplayName, - Email = post.Author.Email, - TwitterHandle = user.Twitter, - }, - Content = new() - { - Body = post.Body, - Excerpt = post.Excerpt, - Link = post.Link, - Title = post.Title, - }, - PublishedOn = post.PublishedAtUtc, - Promote = post.Promote, - PromoteUntil = post.PromoteUntil, - Status = post.Status, - Slug = post.Slug, - Tags = post.Tags.Where(t => t != null).Select(t => t.Name), - }); - } - - await using (StreamWriter writer = File.CreateText(universalFilePath)) - { - await writer.WriteAsync(JsonConvert.SerializeObject(feed, Formatting.Indented)).ConfigureAwait(false); - } - - Console.WriteLine($"Content written to {universalFilePath}"); - }), - }; - - cmd.AddArgument(new Argument("wp-export-file-path") { Description = "WordPress Export file path." }); - cmd.AddArgument(new Argument("universal-file-path") { Description = "Universal file path." }); - - return cmd; - } -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Configuration/FileSystemLocalProfileAppEnvironment.cs b/Solutions/Stacker.Cli/Configuration/FileSystemLocalProfileAppEnvironment.cs index 8d51ebf..b02e779 100644 --- a/Solutions/Stacker.Cli/Configuration/FileSystemLocalProfileAppEnvironment.cs +++ b/Solutions/Stacker.Cli/Configuration/FileSystemLocalProfileAppEnvironment.cs @@ -4,6 +4,7 @@ using System; using System.IO; + using Stacker.Cli.Contracts.Configuration; namespace Stacker.Cli.Configuration; diff --git a/Solutions/Stacker.Cli/Configuration/SettingsManager{T}.cs b/Solutions/Stacker.Cli/Configuration/SettingsManager{T}.cs index 3a19f87..1157950 100644 --- a/Solutions/Stacker.Cli/Configuration/SettingsManager{T}.cs +++ b/Solutions/Stacker.Cli/Configuration/SettingsManager{T}.cs @@ -3,7 +3,8 @@ // using System.IO; -using Newtonsoft.Json; +using System.Text.Json; + using Stacker.Cli.Contracts.Configuration; namespace Stacker.Cli.Configuration; @@ -22,13 +23,13 @@ public T LoadSettings(string fileName) { string filePath = $"{this.GetLocalFilePath(fileName)}.json"; - return File.Exists(filePath) ? JsonConvert.DeserializeObject(File.ReadAllText(filePath)) : null; + return File.Exists(filePath) ? JsonSerializer.Deserialize(File.ReadAllText(filePath)) : null; } public void SaveSettings(T settings, string fileName) { string filePath = this.GetLocalFilePath(fileName); - string json = JsonConvert.SerializeObject(settings); + string json = JsonSerializer.Serialize(settings); File.WriteAllText($"{filePath}.json", json); } diff --git a/Solutions/Stacker.Cli/Configuration/StackerSettings.cs b/Solutions/Stacker.Cli/Configuration/StackerSettings.cs index a05baf3..08e33bb 100644 --- a/Solutions/Stacker.Cli/Configuration/StackerSettings.cs +++ b/Solutions/Stacker.Cli/Configuration/StackerSettings.cs @@ -17,5 +17,11 @@ public class StackerSettings public Dictionary BufferProfiles { get; set; } + public List ExcludedTags { get; set; } + + public List TagAliases { get; set; } + + public List PriorityTags { get; set; } + public WordPressToMarkdown WordPressToMarkdown { get; set; } } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Configuration/TagAliases.cs b/Solutions/Stacker.Cli/Configuration/TagAliases.cs new file mode 100644 index 0000000..a057548 --- /dev/null +++ b/Solutions/Stacker.Cli/Configuration/TagAliases.cs @@ -0,0 +1,14 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System.Collections.Generic; + +namespace Stacker.Cli.Configuration; + +public class TagAliases +{ + public string Tag { get; set; } + + public List Aliases { get; set; } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Contracts/Buffer/IBufferClient.cs b/Solutions/Stacker.Cli/Contracts/Buffer/IBufferClient.cs index 1493cb4..19c2ed2 100644 --- a/Solutions/Stacker.Cli/Contracts/Buffer/IBufferClient.cs +++ b/Solutions/Stacker.Cli/Contracts/Buffer/IBufferClient.cs @@ -9,5 +9,5 @@ namespace Stacker.Cli.Contracts.Buffer; public interface IBufferClient { - Task UploadAsync(IEnumerable content, string profileId); + Task UploadAsync(IEnumerable content, string profileId, bool whatIf); } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Contracts/Commands/ICommandFactory.cs b/Solutions/Stacker.Cli/Contracts/Commands/ICommandFactory.cs deleted file mode 100644 index a60dff5..0000000 --- a/Solutions/Stacker.Cli/Contracts/Commands/ICommandFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -// -// Copyright (c) Endjin Limited. All rights reserved. -// - -using System.CommandLine; - -namespace Stacker.Cli.Contracts.Commands; - -public interface ICommandFactory -{ - Command Create(); -} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Contracts/Formatters/IContentFormatter.cs b/Solutions/Stacker.Cli/Contracts/Formatters/IContentFormatter.cs index e52cb0a..72ea1c1 100644 --- a/Solutions/Stacker.Cli/Contracts/Formatters/IContentFormatter.cs +++ b/Solutions/Stacker.Cli/Contracts/Formatters/IContentFormatter.cs @@ -3,11 +3,12 @@ // using System.Collections.Generic; +using Stacker.Cli.Configuration; using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Contracts.Formatters; public interface IContentFormatter { - IEnumerable Format(string campaignMedium, string campaignName, IEnumerable feedItems); + IEnumerable Format(string campaignMedium, string campaignName, IEnumerable feedItems, StackerSettings settings); } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Contracts/Tasks/IContentTasks.cs b/Solutions/Stacker.Cli/Contracts/Tasks/IContentTasks.cs index 5273cdd..bf3d553 100644 --- a/Solutions/Stacker.Cli/Contracts/Tasks/IContentTasks.cs +++ b/Solutions/Stacker.Cli/Contracts/Tasks/IContentTasks.cs @@ -5,6 +5,9 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; + +using Spectre.IO; + using Stacker.Cli.Contracts.Formatters; using Stacker.Cli.Domain.Publication; using Stacker.Cli.Domain.Universal; @@ -13,8 +16,23 @@ namespace Stacker.Cli.Contracts.Tasks; public interface IContentTasks { - Task BufferContentItemsAsync(string contentFilePath, string profilePrefix, string profileName, PublicationPeriod publicationPeriod, DateTime fromDate, DateTime toDate, int itemCount) + Task BufferContentItemsAsync( + FilePath contentFilePath, + string profilePrefix, + string profileName, + PublicationPeriod publicationPeriod, + DateTime fromDate, + DateTime toDate, + int itemCount, + string filterByTag, + bool whatIf) where TContentFormatter : class, IContentFormatter, new(); - Task> LoadContentItemsAsync(string contentFilePath, PublicationPeriod publicationPeriod, DateTime fromDate, DateTime toDate, int itemCount); + Task> LoadContentItemsAsync( + FilePath contentFilePath, + PublicationPeriod publicationPeriod, + DateTime fromDate, + DateTime toDate, + int itemCount, + string filterByTag); } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Converters/PublicationPeriodConverter.cs b/Solutions/Stacker.Cli/Converters/PublicationPeriodConverter.cs index 975cba6..a5f9099 100644 --- a/Solutions/Stacker.Cli/Converters/PublicationPeriodConverter.cs +++ b/Solutions/Stacker.Cli/Converters/PublicationPeriodConverter.cs @@ -3,8 +3,10 @@ // using System; + using NodaTime; using NodaTime.Calendars; + using Stacker.Cli.Domain.Publication; namespace Stacker.Cli.Converters; @@ -21,24 +23,24 @@ public DateInterval Convert(PublicationPeriod publicationPeriod) { case PublicationPeriod.ThisWeek: LocalDate startOfThisWeek = LocalDate.FromWeekYearWeekAndDay(today.Year, weekNumber, IsoDayOfWeek.Monday); - return new(startOfThisWeek, LocalDate.FromDateTime(DateTime.Today)); + return new DateInterval(startOfThisWeek, LocalDate.FromDateTime(DateTime.Today)); case PublicationPeriod.LastWeek: LocalDate startOfLastWeek = LocalDate.FromWeekYearWeekAndDay(today.Year, rule.GetWeekOfWeekYear(today.PlusWeeks(-1)), IsoDayOfWeek.Monday); - return new(startOfLastWeek, startOfLastWeek.PlusWeeks(1).PlusDays(-1)); + return new DateInterval(startOfLastWeek, startOfLastWeek.PlusWeeks(1).PlusDays(-1)); case PublicationPeriod.ThisMonth: - var startOfThisMonth = LocalDate.FromDateTime(new(today.Year, today.Month, 1)); - return new(startOfThisMonth, today); + var startOfThisMonth = LocalDate.FromDateTime(new DateTime(today.Year, today.Month, 1)); + return new DateInterval(startOfThisMonth, today); case PublicationPeriod.LastMonth: - LocalDate startOfLastMonth = LocalDate.FromDateTime(new(today.Year, today.Month, 1)).PlusMonths(-1); + LocalDate startOfLastMonth = LocalDate.FromDateTime(new DateTime(today.Year, today.Month, 1)).PlusMonths(-1); var endOfLastMonth = new LocalDate(startOfLastMonth.Year, startOfLastMonth.Month, startOfLastMonth.Calendar.GetDaysInMonth(startOfLastMonth.Year, startOfLastMonth.Month)); - return new(startOfLastMonth, endOfLastMonth); + return new DateInterval(startOfLastMonth, endOfLastMonth); case PublicationPeriod.ThisYear: - var startOfThisYear = LocalDate.FromDateTime(new(today.Year, 1, 1)); - return new(startOfThisYear, today); + var startOfThisYear = LocalDate.FromDateTime(new DateTime(today.Year, 1, 1)); + return new DateInterval(startOfThisYear, today); case PublicationPeriod.LastYear: - LocalDate startOfLastYear = LocalDate.FromDateTime(new(today.Year, 1, 1)).PlusYears(-1); - LocalDate endOfLastYear = LocalDate.FromDateTime(new(today.Year, 12, 31)).PlusYears(-1); - return new(startOfLastYear, endOfLastYear); + LocalDate startOfLastYear = LocalDate.FromDateTime(new DateTime(today.Year, 1, 1)).PlusYears(-1); + LocalDate endOfLastYear = LocalDate.FromDateTime(new DateTime(today.Year, 12, 31)).PlusYears(-1); + return new DateInterval(startOfLastYear, endOfLastYear); default: throw new ArgumentOutOfRangeException(nameof(publicationPeriod), publicationPeriod, null); } diff --git a/Solutions/Stacker.Cli/Converters/WordPressTagToHashTagConverter.cs b/Solutions/Stacker.Cli/Converters/TagToHashTagConverter.cs similarity index 79% rename from Solutions/Stacker.Cli/Converters/WordPressTagToHashTagConverter.cs rename to Solutions/Stacker.Cli/Converters/TagToHashTagConverter.cs index dd159bb..87d09ec 100644 --- a/Solutions/Stacker.Cli/Converters/WordPressTagToHashTagConverter.cs +++ b/Solutions/Stacker.Cli/Converters/TagToHashTagConverter.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) Endjin Limited. All rights reserved. // @@ -6,7 +6,7 @@ namespace Stacker.Cli.Converters; -public class WordPressTagToHashTagConverter +public class TagToHashTagConverter { public string Convert(string wordpressTag) { diff --git a/Solutions/Stacker.Cli/Domain/Universal/ContentItem.cs b/Solutions/Stacker.Cli/Domain/Universal/ContentItem.cs index 8edf617..58915d1 100644 --- a/Solutions/Stacker.Cli/Domain/Universal/ContentItem.cs +++ b/Solutions/Stacker.Cli/Domain/Universal/ContentItem.cs @@ -38,20 +38,13 @@ public string CleanSlug public string Status { get; internal set; } - public IEnumerable Tags { get; set; } + public List Tags { get; set; } public string UniqueId { get { - if (string.IsNullOrEmpty(this.Slug)) - { - return this.Id; - } - else - { - return this.CleanSlug; - } + return string.IsNullOrEmpty(this.Slug) ? this.Id : this.CleanSlug; } } } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Domain/WordPress/BlogSite.cs b/Solutions/Stacker.Cli/Domain/WordPress/BlogSite.cs index 8c48379..f1f8c6b 100644 --- a/Solutions/Stacker.Cli/Domain/WordPress/BlogSite.cs +++ b/Solutions/Stacker.Cli/Domain/WordPress/BlogSite.cs @@ -5,11 +5,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Web; using System.Xml; using System.Xml.Linq; -using Newtonsoft.Json; namespace Stacker.Cli.Domain.WordPress; @@ -410,7 +410,7 @@ private void ParseSsoMetaData(XElement metaKeyElement, XElement postMeta, Dictio try { - Dictionary data = JsonConvert.DeserializeObject>(rawMetaData); + Dictionary data = JsonSerializer.Deserialize>(rawMetaData); foreach ((string key, string value) in data) { diff --git a/Solutions/Stacker.Cli/Domain/WordPress/PostExtensions.cs b/Solutions/Stacker.Cli/Domain/WordPress/PostExtensions.cs index 427d665..e00f6ec 100644 --- a/Solutions/Stacker.Cli/Domain/WordPress/PostExtensions.cs +++ b/Solutions/Stacker.Cli/Domain/WordPress/PostExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; + using Stacker.Cli.Configuration; namespace Stacker.Cli.Domain.WordPress; diff --git a/Solutions/Stacker.Cli/Extensions/ServiceCollectionExtensions.cs b/Solutions/Stacker.Cli/Extensions/ServiceCollectionExtensions.cs index c5006df..28cb578 100644 --- a/Solutions/Stacker.Cli/Extensions/ServiceCollectionExtensions.cs +++ b/Solutions/Stacker.Cli/Extensions/ServiceCollectionExtensions.cs @@ -3,11 +3,10 @@ // using Microsoft.Extensions.DependencyInjection; + using Stacker.Cli.Cleaners; -using Stacker.Cli.Commands; using Stacker.Cli.Configuration; using Stacker.Cli.Contracts.Buffer; -using Stacker.Cli.Contracts.Commands; using Stacker.Cli.Contracts.Configuration; using Stacker.Cli.Contracts.Tasks; using Stacker.Cli.Serialization; @@ -22,23 +21,6 @@ public static void ConfigureDependencies(this ServiceCollection serviceCollectio serviceCollection.AddTransient(); serviceCollection.AddTransient(); - serviceCollection.AddTransient, WordPressCommandFactory>(); - serviceCollection.AddTransient, WordPressExportCommandFactory>(); - serviceCollection.AddTransient, WordPressExportUniversalCommandFactory>(); - serviceCollection.AddTransient, WordPressExportMarkDownCommandFactory>(); - - serviceCollection.AddTransient, EnvironmentCommandFactory>(); - serviceCollection.AddTransient, EnvironmentInitCommandFactory>(); - - serviceCollection.AddTransient, FacebookCommandFactory>(); - serviceCollection.AddTransient, FacebookBufferCommandFactory>(); - - serviceCollection.AddTransient, LinkedInCommandFactory>(); - serviceCollection.AddTransient, LinkedInBufferCommandFactory>(); - - serviceCollection.AddTransient, TwitterCommandFactory>(); - serviceCollection.AddTransient, TwitterBufferCommandFactory>(); - serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); diff --git a/Solutions/Stacker.Cli/Formatters/LongFormContentFormatter.cs b/Solutions/Stacker.Cli/Formatters/LongFormContentFormatter.cs index bbba932..be9bde9 100644 --- a/Solutions/Stacker.Cli/Formatters/LongFormContentFormatter.cs +++ b/Solutions/Stacker.Cli/Formatters/LongFormContentFormatter.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using System.Linq; using System.Text; + +using Stacker.Cli.Configuration; using Stacker.Cli.Contracts.Formatters; using Stacker.Cli.Converters; using Stacker.Cli.Domain.Universal; @@ -22,12 +24,12 @@ public LongFormContentFormatter(int maxContentLength, string campaignSource) this.maxContentLength = maxContentLength; } - public IEnumerable Format(string campaignMedium, string campaignName, IEnumerable feedItems) + public IEnumerable Format(string campaignMedium, string campaignName, IEnumerable feedItems, StackerSettings settings) { var postings = new List(); var sb = new StringBuilder(); var sbTracking = new StringBuilder(); - var hashTagConverter = new WordPressTagToHashTagConverter(); + var hashTagConverter = new TagToHashTagConverter(); foreach (ContentItem item in feedItems) { diff --git a/Solutions/Stacker.Cli/Formatters/TweetFormatter.cs b/Solutions/Stacker.Cli/Formatters/TweetFormatter.cs index e141152..ac6bd42 100644 --- a/Solutions/Stacker.Cli/Formatters/TweetFormatter.cs +++ b/Solutions/Stacker.Cli/Formatters/TweetFormatter.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using Stacker.Cli.Configuration; using Stacker.Cli.Contracts.Formatters; -using Stacker.Cli.Converters; using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Formatters; @@ -14,46 +14,47 @@ namespace Stacker.Cli.Formatters; public class TweetFormatter : IContentFormatter { private const int MaxContentLength = 280; - private string campaignSource = "twitter"; + private readonly string campaignSource = "twitter"; - public IEnumerable Format(string campaignMedium, string campaignName, IEnumerable feedItems) + public IEnumerable Format(string campaignMedium, string campaignName, IEnumerable feedItems, StackerSettings settings) { - var tweets = new List(); - var sb = new StringBuilder(); - var sbTracking = new StringBuilder(); - var hashTagConverter = new WordPressTagToHashTagConverter(); + List tweets = new(); + StringBuilder content = new(); + StringBuilder campaignTracking = new(); foreach (ContentItem item in feedItems) { - sbTracking.Append(" "); - sbTracking.Append(item.Content.Link); - sbTracking.Append("?utm_source="); - sbTracking.Append(this.campaignSource.ToLowerInvariant()); - sbTracking.Append("&utm_medium="); - sbTracking.Append(campaignMedium.ToLowerInvariant()); - sbTracking.Append("&utm_campaign="); - sbTracking.Append(campaignName.ToLowerInvariant()); - sbTracking.AppendLine(); + campaignTracking.Append(' '); + campaignTracking.Append(item.Content.Link); + campaignTracking.Append("?utm_source="); + campaignTracking.Append(this.campaignSource.ToLowerInvariant()); + campaignTracking.Append("&utm_medium="); + campaignTracking.Append(campaignMedium.ToLowerInvariant()); + campaignTracking.Append("&utm_campaign="); + campaignTracking.Append(campaignName.ToLowerInvariant()); + campaignTracking.AppendLine(); - sb.Append(item.Content.Title); - sb.Append(" by "); + content.Append(item.Content.Title); + content.Append(" by "); if (string.IsNullOrEmpty(item.Author.TwitterHandle)) { - sb.Append(item.Author.DisplayName); + content.Append(item.Author.DisplayName); } else { - sb.Append("@"); - sb.Append(item.Author.TwitterHandle); + content.Append('@'); + content.Append(item.Author.TwitterHandle); } - if (item.Tags != null && item.Tags.Any()) + if (item?.Tags != null && item.Tags.Any()) { - int tweetLength = sb.Length + item.Content.Link.Length + 1; // 1 = extra space before link + int tweetLength = content.Length + (item.Content.Link.Length + 1) + campaignTracking.Length; // 1 = extra space before link int tagsToInclude = 0; - foreach (string tag in item.Tags) + item.Tags = item.Tags.Except(settings.ExcludedTags).OrderByDescending(word => settings.PriorityTags.IndexOf(word)).ToList(); + + foreach (string tag in item.Tags.Distinct()) { // 2 Offset = Space + # if (tweetLength + tag.Length + 2 <= MaxContentLength) @@ -66,19 +67,19 @@ public IEnumerable Format(string campaignMedium, string campaignName, IE } } - foreach (string tag in item.Tags.Take(tagsToInclude)) + foreach (string tag in item.Tags.Distinct().Take(tagsToInclude)) { - sb.Append(" #"); - sb.Append(hashTagConverter.Convert(tag)); + content.Append(" #"); + content.Append(tag); } } - sb.Append(sbTracking.ToString()); + content.Append(campaignTracking); - tweets.Add(sb.ToString()); + tweets.Add(content.ToString()); - sb.Clear(); - sbTracking.Clear(); + content.Clear(); + campaignTracking.Clear(); } return tweets; diff --git a/Solutions/Stacker.Cli/Infrastructure/Injection/TypeRegistrar.cs b/Solutions/Stacker.Cli/Infrastructure/Injection/TypeRegistrar.cs new file mode 100644 index 0000000..aa12689 --- /dev/null +++ b/Solutions/Stacker.Cli/Infrastructure/Injection/TypeRegistrar.cs @@ -0,0 +1,73 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System; + +using Microsoft.Extensions.DependencyInjection; + +using Spectre.Console.Cli; + +namespace Stacker.Cli.Infrastructure.Injection; + +/// +/// Creates a type resolver from a service collection. +/// +public sealed class TypeRegistrar : ITypeRegistrar +{ + private readonly IServiceCollection builder; + + /// + /// Creates a new instance of . + /// + /// ServiceCollection. + public TypeRegistrar(IServiceCollection builder) + { + this.builder = builder; + } + + /// + /// Builds the type resolver. + /// + /// A new TypeResolver. + public ITypeResolver Build() + { + return new TypeResolver(this.builder.BuildServiceProvider()); + } + + /// + /// Registers a type. + /// + /// Service Contract. + /// Implementation of the type. + public void Register(Type service, Type implementation) + { + this.builder.AddSingleton(service, implementation); + } + + /// + /// Registers an instance. + /// + /// Service Contract. + /// Implementation of the type. + public void RegisterInstance(Type service, object implementation) + { + this.builder.AddSingleton(service, implementation); + } + + /// + /// Registers a lazy type. + /// + /// Service Contract. + /// Function for providing the concrete type. + /// Throws is hte func is null. + public void RegisterLazy(Type service, Func func) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + this.builder.AddSingleton(service, (provider) => func()); + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Infrastructure/Injection/TypeResolver.cs b/Solutions/Stacker.Cli/Infrastructure/Injection/TypeResolver.cs new file mode 100644 index 0000000..6f7fa80 --- /dev/null +++ b/Solutions/Stacker.Cli/Infrastructure/Injection/TypeResolver.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) Endjin Limited. All rights reserved. +// + +using System; + +using Spectre.Console.Cli; + +namespace Stacker.Cli.Infrastructure.Injection; + +#nullable enable annotations + +/// +/// Implementation of that uses the to resolve types. +/// +public sealed class TypeResolver : ITypeResolver, IDisposable +{ + private readonly IServiceProvider provider; + + /// + /// Creates a new instance of . + /// + /// ServiceProvider for resolving types. + /// Thrown if is null. + public TypeResolver(IServiceProvider provider) + { + this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + /// Resolves an instance of the type. + /// + /// Type to resolve. + /// Instance of the Type. + public object? Resolve(Type? type) + { + return type == null ? null : this.provider.GetService(type); + } + + /// + /// Dispose the resolver. + /// + public void Dispose() + { + if (this.provider is IDisposable disposable) + { + disposable.Dispose(); + } + } +} \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Profiles.cs b/Solutions/Stacker.Cli/Profiles.cs index c9806f7..71e9e68 100644 --- a/Solutions/Stacker.Cli/Profiles.cs +++ b/Solutions/Stacker.Cli/Profiles.cs @@ -2,12 +2,12 @@ // Copyright (c) Endjin Limited. All rights reserved. // -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Stacker.Cli; public class Profiles { - [JsonProperty("profile_id")] + [JsonPropertyName("profile_id")] public string ProfileId { get; set; } } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Properties/launchSettings.json b/Solutions/Stacker.Cli/Properties/launchSettings.json index e086ba3..8ca3d83 100644 --- a/Solutions/Stacker.Cli/Properties/launchSettings.json +++ b/Solutions/Stacker.Cli/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Endjin.Stacker.Cli": { "commandName": "Project", - "commandLineArgs": "wordpress export markdown \"C:\\Users\\HowardvanRooijen\\AppData\\Roaming\\endjin\\stacker\\data\\endjinblog.WordPress.2020-06-23.xml\" \"C:\\Temp\\Blog\"" + "commandLineArgs": "wordpress export markdown" } } } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Serialization/ForceQuotedStringValuesEventEmitter.cs b/Solutions/Stacker.Cli/Serialization/ForceQuotedStringValuesEventEmitter.cs index f4fb1dc..8aa0055 100644 --- a/Solutions/Stacker.Cli/Serialization/ForceQuotedStringValuesEventEmitter.cs +++ b/Solutions/Stacker.Cli/Serialization/ForceQuotedStringValuesEventEmitter.cs @@ -3,6 +3,7 @@ // using System.Collections.Generic; + using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.EventEmitters; diff --git a/Solutions/Stacker.Cli/Stacker.Cli.csproj b/Solutions/Stacker.Cli/Stacker.Cli.csproj index db8e014..896dd25 100644 --- a/Solutions/Stacker.Cli/Stacker.Cli.csproj +++ b/Solutions/Stacker.Cli/Stacker.Cli.csproj @@ -45,11 +45,11 @@ - 1701;1702; CS1591;SA1600;SA1124 + 1701;1702; CS1591;SA1600;SA1124;IDE0007 - 1701;1702; CS1591;SA1600;SA1124 + 1701;1702; CS1591;SA1600;SA1124;IDE0007 @@ -57,9 +57,9 @@ - - + + diff --git a/Solutions/Stacker.Cli/StackerCli.cs b/Solutions/Stacker.Cli/StackerCli.cs index cc09976..3e0ae9d 100644 --- a/Solutions/Stacker.Cli/StackerCli.cs +++ b/Solutions/Stacker.Cli/StackerCli.cs @@ -2,14 +2,15 @@ // Copyright (c) Endjin Limited. All rights reserved. // -using System.CommandLine; -using System.CommandLine.Builder; -using System.CommandLine.Invocation; using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + +using Spectre.Console.Cli; + using Stacker.Cli.Commands; -using Stacker.Cli.Contracts.Commands; using Stacker.Cli.Extensions; +using Stacker.Cli.Infrastructure.Injection; namespace Stacker.Cli; @@ -23,22 +24,77 @@ public static class StackerCli /// /// Command Line Switches. /// Exit Code. - public static async Task Main(string[] args) + public static Task Main(string[] args) { - var serviceCollection = new ServiceCollection(); - serviceCollection.ConfigureDependencies(); + ServiceCollection registrations = new(); + registrations.ConfigureDependencies(); + + TypeRegistrar registrar = new(registrations); + CommandApp app = new(registrar); + + app.Configure(config => + { + config.Settings.PropagateExceptions = false; + config.CaseSensitivity(CaseSensitivity.None); + config.SetApplicationName("stacker"); + + config.AddExample("linkedin", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin"); + config.AddExample("facebook", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin"); + config.AddExample("twitter", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin"); + config.AddExample("twitter", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin", "--item-count", "10"); + config.AddExample("twitter", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin", "--publication-period", "ThisMonth"); + config.AddExample("twitter", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin", "--filter-by-tag", "MicrosoftFabric", "--what-if"); + config.AddExample("twitter", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin", "--from-date", "2023/06/01", "--to-date", "2023/06/30"); + config.AddExample("twitter", "buffer", "-c", """c:\temp\content.json""", "-n", "endjin", "--filter-by-tag", "PowerBI", "--from-date", "2023/06/01", "--to-date", "2023/06/30"); + config.AddExample("environment", "init"); + config.AddExample("wordpress", "export", "markdown", "-w", """C:\temp\wordpress-export.xml""", "-o", """C:\Temp\Blog"""); + config.AddExample("wordpress", "export", "universal", "-w", """C:\temp\wordpress-export.xml""", "-o", """C:\Temp\Blog\export.json"""); + + config.AddBranch("facebook", process => + { + process.SetDescription("Facebook functionality."); + process.AddCommand("buffer") + .WithDescription("Uploads content to Buffer to be published via Facebook"); + }); + + config.AddBranch("linkedin", process => + { + process.SetDescription("LinkedIn functionality."); + process.AddCommand("buffer") + .WithDescription("Uploads content to Buffer to be published via LinkedIn"); + }); + + config.AddBranch("twitter", process => + { + process.SetDescription("Twitter functionality."); + process.AddCommand("buffer") + .WithDescription("Uploads content to Buffer to be published via Twitter"); + }); + + config.AddBranch("environment", process => + { + process.SetDescription("Manipulate the stacker environment."); + process.AddCommand("init") + .WithDescription("Initializes the stacker environment."); + }); + + config.AddBranch("wordpress", process => + { + process.SetDescription("WordPress functionality."); + process.AddBranch("export", export => + { + export.SetDescription("Export functionality."); + export.AddCommand("markdown") + .WithDescription("Convert WordPress export files into a markdown format."); - ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + export.AddCommand("universal") + .WithDescription("Convert WordPress export files into a universal format."); + }); + }); - Parser cmd = new CommandLineBuilder() - .AddCommand(serviceProvider.GetRequiredService>().Create()) - .AddCommand(serviceProvider.GetRequiredService>().Create()) - .AddCommand(serviceProvider.GetRequiredService>().Create()) - .AddCommand(serviceProvider.GetRequiredService>().Create()) - .AddCommand(serviceProvider.GetRequiredService>().Create()) - .UseDefaults() - .Build(); + config.ValidateExamples(); + }); - return await cmd.InvokeAsync(args).ConfigureAwait(false); + return app.RunAsync(args); } } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Tasks/ContentTasks.cs b/Solutions/Stacker.Cli/Tasks/ContentTasks.cs index cad41b0..0ff2e98 100644 --- a/Solutions/Stacker.Cli/Tasks/ContentTasks.cs +++ b/Solutions/Stacker.Cli/Tasks/ContentTasks.cs @@ -6,9 +6,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; -using Newtonsoft.Json; + using NodaTime; + +using Spectre.Console; +using Spectre.IO; + using Stacker.Cli.Configuration; using Stacker.Cli.Contracts.Buffer; using Stacker.Cli.Contracts.Configuration; @@ -31,7 +36,16 @@ public ContentTasks(IBufferClient bufferClient, IStackerSettingsManager settings this.settingsManager = settingsManager; } - public async Task BufferContentItemsAsync(string contentFilePath, string profilePrefix, string profileName, PublicationPeriod publicationPeriod, DateTime fromDate, DateTime toDate, int itemCount) + public async Task BufferContentItemsAsync( + FilePath contentFilePath, + string profilePrefix, + string profileName, + PublicationPeriod publicationPeriod, + DateTime fromDate, + DateTime toDate, + int itemCount, + string filterByTag, + bool whatIf) where TContentFormatter : class, IContentFormatter, new() { TContentFormatter formatter = new(); @@ -40,33 +54,37 @@ public async Task BufferContentItemsAsync(string contentFileP StackerSettings settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); - if (settings.BufferProfiles.ContainsKey(profileKey)) + if (settings.BufferProfiles.TryGetValue(profileKey, out string profile)) { - string profileId = settings.BufferProfiles[profileKey]; + AnsiConsole.MarkupLineInterpolated($"[yellow1]Buffer Profile:[/] {profileKey} = {profile}"); + AnsiConsole.MarkupLineInterpolated($"[yellow1]Loading:[/] {contentFilePath}"); - Console.WriteLine($"Buffer Profile: {profileKey} = {profileId}"); - Console.WriteLine($"Loading: {contentFilePath}"); + IEnumerable contentItems = await this.LoadContentItemsAsync(contentFilePath, publicationPeriod, fromDate, toDate, itemCount, filterByTag).ConfigureAwait(false); + IEnumerable formattedContentItems = formatter.Format("social", profileName, contentItems, settings); - IEnumerable contentItems = await this.LoadContentItemsAsync(contentFilePath, publicationPeriod, fromDate, toDate, itemCount).ConfigureAwait(false); - IEnumerable formattedContentItems = formatter.Format("social", profileName, contentItems); - - await this.bufferClient.UploadAsync(formattedContentItems, profileId).ConfigureAwait(false); + await this.bufferClient.UploadAsync(formattedContentItems, profile, whatIf).ConfigureAwait(false); } else { - Console.WriteLine($"Settings for {profileKey} not found. Please check your Stacker configuration."); + AnsiConsole.WriteLine($"Settings for {profileKey} not found. Please check your Stacker configuration."); } } - public async Task> LoadContentItemsAsync(string contentFilePath, PublicationPeriod publicationPeriod, DateTime fromDate, DateTime toDate, int itemCount) + public async Task> LoadContentItemsAsync( + FilePath contentFilePath, + PublicationPeriod publicationPeriod, + DateTime fromDate, + DateTime toDate, + int itemCount, + string filterByTag) { - IEnumerable content = JsonConvert.DeserializeObject>(await File.ReadAllTextAsync(contentFilePath).ConfigureAwait(false)); + List content = JsonSerializer.Deserialize>(await File.ReadAllTextAsync(contentFilePath.FullPath).ConfigureAwait(false)); if (publicationPeriod != PublicationPeriod.None) { DateInterval dateRange = new PublicationPeriodConverter().Convert(publicationPeriod); - content = content.Where(p => (LocalDate.FromDateTime(p.PublishedOn.LocalDateTime) >= dateRange.Start) && (LocalDate.FromDateTime(p.PublishedOn.LocalDateTime) <= dateRange.End)); + content = content.Where(p => (LocalDate.FromDateTime(p.PublishedOn.LocalDateTime) >= dateRange.Start) && (LocalDate.FromDateTime(p.PublishedOn.LocalDateTime) <= dateRange.End)).ToList(); } else { @@ -81,35 +99,51 @@ public async Task> LoadContentItemsAsync(string content { if (toDate.ToString("HH:mm:ss") == "00:00:00") { - toDate = new(toDate.Year, toDate.Month, toDate.Day, 23, 59, 59); + toDate = new DateTime(toDate.Year, toDate.Month, toDate.Day, 23, 59, 59); } } - content = content.Where(p => p.PublishedOn.LocalDateTime >= fromDate && p.PublishedOn.LocalDateTime <= toDate); + content = content.Where(p => p.PublishedOn.LocalDateTime >= fromDate && p.PublishedOn.LocalDateTime <= toDate).ToList(); } else { // if fromDate isn't specified, but toDate is if (toDate != DateTime.MinValue) { - content = content.Where(p => p.PublishedOn.LocalDateTime >= fromDate && p.PublishedOn.LocalDateTime <= toDate); + content = content.Where(p => p.PublishedOn.LocalDateTime >= fromDate && p.PublishedOn.LocalDateTime <= toDate).ToList(); } } } + StackerSettings settings = this.settingsManager.LoadSettings(nameof(StackerSettings)); + + foreach (ContentItem contentItem in content) + { + // Use TagAliases to convert tags into their canonical form. + contentItem.Tags = contentItem.Tags?.Select(tag => + { + TagAliases matchedAlias = settings.TagAliases.FirstOrDefault(alias => alias.Aliases.Any(a => a == tag)); + return matchedAlias != null ? matchedAlias.Tag : tag.Replace("-", " ").Replace(" ", string.Empty); + }).ToList(); + } + // Sort so that content with the shortest lifespan are first. content = content.OrderBy(p => p.PromoteUntil).ToList(); - var contentItems = content.ToList(); + if (!string.IsNullOrEmpty(filterByTag)) + { + content = content.Where(x => x.Tags.Contains(filterByTag, StringComparer.InvariantCultureIgnoreCase)).ToList(); + } if (itemCount == 0) { - itemCount = contentItems.Count; + itemCount = content.Count; } - Console.WriteLine($"Total Posts: {contentItems.Count}"); - Console.WriteLine($"Promoting first: {itemCount}"); + AnsiConsole.MarkupLineInterpolated($"[yellow1]Total Posts:[/] {content.Count}"); + AnsiConsole.MarkupLineInterpolated($"[yellow1]Promoting first:[/] {itemCount}"); + AnsiConsole.WriteLine(); - return contentItems.Take(itemCount); + return content.Take(itemCount); } } \ No newline at end of file diff --git a/Solutions/Stacker.Cli/Tasks/DownloadTasks.cs b/Solutions/Stacker.Cli/Tasks/DownloadTasks.cs index 2c88345..6906f91 100644 --- a/Solutions/Stacker.Cli/Tasks/DownloadTasks.cs +++ b/Solutions/Stacker.Cli/Tasks/DownloadTasks.cs @@ -9,9 +9,13 @@ using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; + using Corvus.Retry; using Corvus.Retry.Policies; using Corvus.Retry.Strategies; + +using Spectre.Console; + using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Tasks; @@ -27,7 +31,7 @@ public DownloadTasks(IHttpClientFactory httpClientFactory) public async Task DownloadAsync(List feed, string outputPath) { - var downloadFeedBlock = new ActionBlock(context => this.DownloadFeedAsync(context), new() { MaxDegreeOfParallelism = Environment.ProcessorCount }); + var downloadFeedBlock = new ActionBlock(context => this.DownloadFeedAsync(context), new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }); foreach (ContentItem contentItem in feed) { @@ -43,7 +47,7 @@ public async Task DownloadAsync(List feed, string outputPath) await downloadFeedBlock.Completion.ConfigureAwait(false); - Console.WriteLine("File Download Completed"); + AnsiConsole.WriteLine("File Download Completed"); } private async Task DownloadFeedAsync(DataflowContext context) @@ -57,7 +61,7 @@ await Retriable.RetryAsync( { context.AlreadyDownloaded = true; - Console.WriteLine("Already Downloaded: " + context.Destination); + AnsiConsole.WriteLine("Already Downloaded: " + context.Destination); return; } @@ -85,7 +89,7 @@ await Retriable.RetryAsync( } } - Console.WriteLine("Downloaded: " + context.Destination); + AnsiConsole.WriteLine("Downloaded: " + context.Destination); response.EnsureSuccessStatusCode(); } @@ -100,7 +104,7 @@ await Retriable.RetryAsync( context.IsFaulted = true; context.FaultError = ex.Message; - Console.WriteLine("Error Downloading: " + context.Destination); + AnsiConsole.WriteLine("Error Downloading: " + context.Destination); } return context; diff --git a/Solutions/Stacker.Cli/Tasks/IDownloadTasks.cs b/Solutions/Stacker.Cli/Tasks/IDownloadTasks.cs index 2a83dd4..a933ede 100644 --- a/Solutions/Stacker.Cli/Tasks/IDownloadTasks.cs +++ b/Solutions/Stacker.Cli/Tasks/IDownloadTasks.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Threading.Tasks; + using Stacker.Cli.Domain.Universal; namespace Stacker.Cli.Tasks; diff --git a/Solutions/Stacker.Cli/packages.lock.json b/Solutions/Stacker.Cli/packages.lock.json index ceda7cf..7c5caf7 100644 --- a/Solutions/Stacker.Cli/packages.lock.json +++ b/Solutions/Stacker.Cli/packages.lock.json @@ -45,12 +45,6 @@ "Microsoft.Extensions.Options": "7.0.0" } }, - "Newtonsoft.Json": { - "type": "Direct", - "requested": "[13.0.3, )", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, "NodaTime": { "type": "Direct", "requested": "[3.1.9, )", @@ -72,6 +66,21 @@ "resolved": "0.47.0", "contentHash": "B2t1ha50pgEn1er2j5cv9RnJRX0bGhJDubkWr+KOcX5KjwVD83DvLXBVfixkiawuPFvh/Ggm1Wu07Qk96BbazA==" }, + "Spectre.Console.Cli": { + "type": "Direct", + "requested": "[0.47.0, )", + "resolved": "0.47.0", + "contentHash": "S2cZCbve4fAgRtigNUNZbF+NLQJcAapSqSbbDYqLtqXJcIZ6tKiRTveYe05d+oLY2bAmP7sgnLdzVknGXruy2Q==", + "dependencies": { + "Spectre.Console": "0.47.0" + } + }, + "Spectre.IO": { + "type": "Direct", + "requested": "[0.13.0, )", + "resolved": "0.13.0", + "contentHash": "K4NvyhogxTsqjFHYRmxgmoS3oXaRNRYEa+BaCvKws7a/OCtyRB16BUjXh92ZKa1piQrMc51HmiMdRnAx8nGD/g==" + }, "StyleCop.Analyzers": { "type": "Direct", "requested": "[1.2.0-beta.507, )", @@ -81,16 +90,6 @@ "StyleCop.Analyzers.Unstable": "1.2.0.507" } }, - "System.CommandLine.Experimental": { - "type": "Direct", - "requested": "[0.3.0-alpha.19577.1, )", - "resolved": "0.3.0-alpha.19577.1", - "contentHash": "d6t9G4NGBq7rB2Gvo5WjV4YuIJcplYj1fGeTef77TQJNXaExW1C4kDsyee9kHlHLejxEEtCrPvZ4pfj0IjfgHQ==", - "dependencies": { - "Microsoft.CSharp": "4.4.1", - "system.memory": "4.5.3" - } - }, "System.Threading.Tasks.Dataflow": { "type": "Direct", "requested": "[7.0.0, )", @@ -116,11 +115,6 @@ "resolved": "1.1.1", "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" }, - "Microsoft.CSharp": { - "type": "Transitive", - "resolved": "4.4.1", - "contentHash": "A5hI3gk6WpcBI0QGZY6/d5CCaYUxJgi7iENn1uYEng+Olo8RfI5ReGVkjXjeu3VR3srLvVYREATXa2M0X7FYJA==" - }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", "resolved": "7.0.0", @@ -173,6 +167,14 @@ "Microsoft.SourceLink.Common": "1.1.1" } }, + "Spectre.Console": { + "type": "Transitive", + "resolved": "0.47.0", + "contentHash": "wz8mszcZr0cSOo8GyoG9e2DFW0SkMT8/n78Q/lIXX7EbCtHNXOoOKWpJ9Str+rCYtmQOGGyDutZzubrUHK/XkA==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.507", @@ -180,8 +182,8 @@ }, "System.Memory": { "type": "Transitive", - "resolved": "4.5.3", - "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive",