From fc04fb08aa785971da72048ec7983144b01a7431 Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Tue, 20 Feb 2024 22:18:25 +0100 Subject: [PATCH 1/8] Upgrade to Microsoft To Do and .NET 8 --- .../TodoCliAuthenticationProviderFactory.cs | 39 ++--- src/Todo.CLI/Auth/TokenCacheHelper.cs | 70 ++++---- src/Todo.CLI/Commands/AddCommand.cs | 43 +++-- src/Todo.CLI/Commands/CompleteCommand.cs | 30 ++-- src/Todo.CLI/Commands/ListCommand.cs | 37 ++--- src/Todo.CLI/Commands/RemoveCommand.cs | 16 +- src/Todo.CLI/Commands/TodoCommand.cs | 51 +++--- src/Todo.CLI/Handlers/AddCommandHandler.cs | 56 +++++-- .../Handlers/CompleteCommandHandler.cs | 83 ++++++---- src/Todo.CLI/Handlers/ListCommandHandler.cs | 114 +++++++------ src/Todo.CLI/Handlers/RemoveCommandHandler.cs | 80 ++++++---- src/Todo.CLI/Handlers/TodoCommandHandler.cs | 38 ++--- src/Todo.CLI/Program.cs | 41 ++--- src/Todo.CLI/Todo.CLI.csproj | 23 ++- src/Todo.CLI/TodoCliConfiguration.cs | 17 +- src/Todo.Core/Model/TodoItem.cs | 23 ++- src/Todo.Core/Model/TodoList.cs | 10 ++ .../Repository/ITodoItemRepository.cs | 24 ++- .../Repository/ITodoListRepository.cs | 18 +++ src/Todo.Core/Repository/RepositoryBase.cs | 22 ++- .../Repository/TodoItemRepository.cs | 151 +++++++++++++----- .../Repository/TodoListRepository.cs | 81 ++++++++++ src/Todo.Core/Todo.Core.csproj | 9 +- .../TodoDependencyInjectionExtensions.cs | 34 ++++ 24 files changed, 681 insertions(+), 429 deletions(-) create mode 100644 src/Todo.Core/Model/TodoList.cs create mode 100644 src/Todo.Core/Repository/ITodoListRepository.cs create mode 100644 src/Todo.Core/Repository/TodoListRepository.cs create mode 100644 src/Todo.Core/TodoDependencyInjectionExtensions.cs diff --git a/src/Todo.CLI/Auth/TodoCliAuthenticationProviderFactory.cs b/src/Todo.CLI/Auth/TodoCliAuthenticationProviderFactory.cs index 3cffc34..3c409f3 100644 --- a/src/Todo.CLI/Auth/TodoCliAuthenticationProviderFactory.cs +++ b/src/Todo.CLI/Auth/TodoCliAuthenticationProviderFactory.cs @@ -1,26 +1,29 @@ using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.Graph; -using Microsoft.Graph.Auth; + +namespace Todo.CLI.Auth; + +using Microsoft.Extensions.DependencyInjection; using Microsoft.Identity.Client; +using Microsoft.Kiota.Abstractions.Authentication; -namespace Todo.CLI.Auth +static class TodoCliAuthenticationProviderFactory { - static class TodoCliAuthenticationProviderFactory + public static IAuthenticationProvider GetAuthenticationProvider(IServiceProvider factory) { - public static IAuthenticationProvider GetAuthenticationProvider(IServiceProvider factory) - { - var config = (TodoCliConfiguration)factory.GetService(typeof(TodoCliConfiguration)); + var config = factory.GetRequiredService(); + + IPublicClientApplication app = PublicClientApplicationBuilder + .Create(config.ClientId) + .WithRedirectUri("http://localhost") // Only loopback redirect uri is supported, see https://aka.ms/msal-net-os-browser for details + .Build(); + + TokenCacheHelper.EnableSerialization(app.UserTokenCache); - IPublicClientApplication app = PublicClientApplicationBuilder - .Create(config.ClientId) - .WithRedirectUri("http://localhost") // Only loopback redirect uri is supported, see https://aka.ms/msal-net-os-browser for details - .Build(); - - TokenCacheHelper.EnableSerialization(app.UserTokenCache); + var login = app.AcquireTokenInteractive(config.Scopes).WithPrompt(Prompt.NoPrompt).ExecuteAsync() + .GetAwaiter().GetResult(); + var token = login.AccessToken; - return new InteractiveAuthenticationProvider(app, config.Scopes); - } + return new ApiKeyAuthenticationProvider("Bearer " + token, "Authorization", + ApiKeyAuthenticationProvider.KeyLocation.Header); } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Auth/TokenCacheHelper.cs b/src/Todo.CLI/Auth/TokenCacheHelper.cs index 1c8811f..2a14899 100644 --- a/src/Todo.CLI/Auth/TokenCacheHelper.cs +++ b/src/Todo.CLI/Auth/TokenCacheHelper.cs @@ -1,55 +1,51 @@ using Microsoft.Identity.Client; -using System; -using System.Collections.Generic; using System.IO; using System.Security.Cryptography; -using System.Text; -namespace Todo.CLI.Auth +namespace Todo.CLI.Auth; + +static class TokenCacheHelper { - static class TokenCacheHelper + public static void EnableSerialization(ITokenCache tokenCache) { - public static void EnableSerialization(ITokenCache tokenCache) - { - tokenCache.SetBeforeAccess(BeforeAccessNotification); - tokenCache.SetAfterAccess(AfterAccessNotification); - } + tokenCache.SetBeforeAccess(BeforeAccessNotification); + tokenCache.SetAfterAccess(AfterAccessNotification); + } - /// - /// Path to the token cache - /// - public static readonly string CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin3"; + /// + /// Path to the token cache + /// + public static readonly string CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin"; - private static readonly object FileLock = new object(); + private static readonly object FileLock = new object(); - private static void BeforeAccessNotification(TokenCacheNotificationArgs args) + private static void BeforeAccessNotification(TokenCacheNotificationArgs args) + { + lock (FileLock) { - lock (FileLock) - { - args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath) - ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath), - null, - DataProtectionScope.CurrentUser) - : null); - } + args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath) + ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath), + null, + DataProtectionScope.CurrentUser) + : null); } + } - private static void AfterAccessNotification(TokenCacheNotificationArgs args) + private static void AfterAccessNotification(TokenCacheNotificationArgs args) + { + // if the access operation resulted in a cache update + if (args.HasStateChanged) { - // if the access operation resulted in a cache update - if (args.HasStateChanged) + lock (FileLock) { - lock (FileLock) - { - // reflect changesgs in the persistent store - File.WriteAllBytes(CacheFilePath, - ProtectedData.Protect(args.TokenCache.SerializeMsalV3(), - null, - DataProtectionScope.CurrentUser) - ); - } + // reflect changesgs in the persistent store + File.WriteAllBytes(CacheFilePath, + ProtectedData.Protect(args.TokenCache.SerializeMsalV3(), + null, + DataProtectionScope.CurrentUser) + ); } } } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Commands/AddCommand.cs b/src/Todo.CLI/Commands/AddCommand.cs index 8386362..7c6353f 100644 --- a/src/Todo.CLI/Commands/AddCommand.cs +++ b/src/Todo.CLI/Commands/AddCommand.cs @@ -1,29 +1,40 @@ using System; -using System.Collections.Generic; using System.CommandLine; -using System.Text; using Todo.CLI.Handlers; -namespace Todo.CLI.Commands +namespace Todo.CLI.Commands; + +public class AddCommand : Command { - public class AddCommand : Command + public AddCommand(IServiceProvider serviceProvider) : base("add", "Adds a to do item or list.") { - public AddCommand(IServiceProvider serviceProvider) : base("add") - { - Description = "Adds a to do item."; + Add(new AddListCommand(serviceProvider)); + Add(new AddItemCommand(serviceProvider)); + } + + internal class AddListCommand : Command + { + private static readonly Argument NameArgument = new("name", "The name of the new to do list."); - AddArgument(GetSubjectArgument()); + public AddListCommand(IServiceProvider serviceProvider) : base("list", "Adds a new to do list.") + { + AddArgument(NameArgument); - Handler = AddCommandHandler.Create(serviceProvider); + this.SetHandler(AddCommandHandler.List.Create(serviceProvider), NameArgument); } + } - private Argument GetSubjectArgument() + internal class AddItemCommand : Command + { + private static readonly Argument ListArgument = new("list", "The list to add the to do item to."); + private static readonly Argument SubjectArgument = new("subject", "The subject of the new to do item."); + + public AddItemCommand(IServiceProvider serviceProvider) : base("item", "Adds a new to do item to the given list.") { - return new Argument("subject") - { - Description = "The subject of the new to do item.", - ArgumentType = typeof(string) - }; + AddArgument(ListArgument); + AddArgument(SubjectArgument); + + this.SetHandler(AddCommandHandler.Item.Create(serviceProvider), ListArgument, SubjectArgument); } } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Commands/CompleteCommand.cs b/src/Todo.CLI/Commands/CompleteCommand.cs index 3e88b0d..36fe389 100644 --- a/src/Todo.CLI/Commands/CompleteCommand.cs +++ b/src/Todo.CLI/Commands/CompleteCommand.cs @@ -1,25 +1,25 @@ using System; using System.CommandLine; using Todo.CLI.Handlers; -using Todo.Core; -using Todo.Core.Model; -namespace Todo.CLI.Commands +namespace Todo.CLI.Commands; + +public class CompleteCommand : Command { - public class CompleteCommand : Command - { - public CompleteCommand(IServiceProvider serviceProvider) : base("complete") - { - Description = "Completes a to do item."; + private static readonly Argument ItemArg = + new("name", + "The name of the todo item to complete. If multiple lists have this item, the first one will be completed.") + { Arity = ArgumentArity.ZeroOrOne }; + private static readonly Option ListOpt = new(["--list", "-l"], "The name of the list to complete the item in.") + { Arity = ArgumentArity.ZeroOrOne }; - AddOption(GetItemOption()); + public CompleteCommand(IServiceProvider serviceProvider) : base("complete") + { + Description = "Completes a to do item."; - Handler = CompleteCommandHandler.Create(serviceProvider); - } + Add(ItemArg); + Add(ListOpt); - private Option GetItemOption() - { - return new Option(new string[] { "id", "item-id" }, "The unique identifier of the todo item to complete."); - } + this.SetHandler(CompleteCommandHandler.Create(serviceProvider), ItemArg, ListOpt); } } \ No newline at end of file diff --git a/src/Todo.CLI/Commands/ListCommand.cs b/src/Todo.CLI/Commands/ListCommand.cs index cf658b2..fb65a38 100644 --- a/src/Todo.CLI/Commands/ListCommand.cs +++ b/src/Todo.CLI/Commands/ListCommand.cs @@ -1,31 +1,26 @@ using System; using System.CommandLine; using Todo.CLI.Handlers; -using Todo.Core; -namespace Todo.CLI.Commands +namespace Todo.CLI.Commands; + +public class ListCommand : Command { - public class ListCommand : Command + private static readonly Option GetAllOption = new(["-a", "--all"], "Lists all to do items including the completed ones."); + private static readonly Option NoStatusOption = new(["--no-status"], "Suppresses the bullet indicating whether the item is completed or not."); + private static readonly Argument ListNameArgument = new("list-name", "Only list tasks of this To-Do list.") { - public ListCommand(IServiceProvider serviceProvider) : base("list") - { - Description = "Retrieves a list of the to do items."; - - AddOption(GetAllOption()); - AddOption(GetNoStatusOption()); + Arity = ArgumentArity.ZeroOrOne + }; - Handler = ListCommandHandler.Create(serviceProvider); - } - - private Option GetAllOption() - { - return new Option(new string[] { "-a", "--all" }, "Lists all to do items including the completed ones."); - } + public ListCommand(IServiceProvider serviceProvider) : base("list") + { + Description = "Retrieves a list of the to do items across all To-Do lists."; - private Option GetNoStatusOption() - { - return new Option(new string[] { "--no-status" }, "Suppresses the bullet indicating whether the item is completed or not."); - } + Add(GetAllOption); + Add(NoStatusOption); + Add(ListNameArgument); + this.SetHandler(ListCommandHandler.Create(serviceProvider), GetAllOption, NoStatusOption, ListNameArgument); } -} \ No newline at end of file +} diff --git a/src/Todo.CLI/Commands/RemoveCommand.cs b/src/Todo.CLI/Commands/RemoveCommand.cs index 4355d44..ebafb23 100644 --- a/src/Todo.CLI/Commands/RemoveCommand.cs +++ b/src/Todo.CLI/Commands/RemoveCommand.cs @@ -1,17 +1,15 @@ using System; using System.CommandLine; using Todo.CLI.Handlers; -using Todo.Core; -using Todo.Core.Model; -namespace Todo.CLI.Commands +namespace Todo.CLI.Commands; + +public class RemoveCommand : Command { - public class RemoveCommand : Command + private static readonly Option ListOpt = new(["--list", "-l"], "The name of the list to remove the item from."); + public RemoveCommand(IServiceProvider serviceProvider) : base("remove", "Deletes a to do item.") { - public RemoveCommand(IServiceProvider serviceProvider) : base("remove") - { - Description = "Deletes a to do item."; - Handler = RemoveCommandHandler.Create(serviceProvider); - } + Add(ListOpt); + this.SetHandler(RemoveCommandHandler.Create(serviceProvider), ListOpt); } } \ No newline at end of file diff --git a/src/Todo.CLI/Commands/TodoCommand.cs b/src/Todo.CLI/Commands/TodoCommand.cs index 5243138..efb888f 100644 --- a/src/Todo.CLI/Commands/TodoCommand.cs +++ b/src/Todo.CLI/Commands/TodoCommand.cs @@ -1,38 +1,27 @@ -using Todo.CLI.Handlers; -using System; +using System; using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; -using System.Reflection; -namespace Todo.CLI.Commands +namespace Todo.CLI.Commands; + +public class TodoCommand : RootCommand { - public class TodoCommand : RootCommand + private static readonly Option Version = new(["-v", "--version"], "Prints out the todo CLI version."); + public TodoCommand(IServiceProvider serviceProvider) { - public TodoCommand(IServiceProvider serviceProvider) - { - // Add static parameters - Description = "A CLI to manage Microsoft to do items."; - - // Add options - AddOption(GetVersionOption()); - - // Add handlers - Handler = TodoCommandHandler.Create(); + // Add static parameters + Description = "A CLI to manage Microsoft to do items."; + + // Add back when https://github.com/dotnet/command-line-api/issues/1691 is resolved. + //// Add options + //Add(Version); - // Add subcommands - AddCommand(new AddCommand(serviceProvider)); - AddCommand(new ListCommand(serviceProvider)); - AddCommand(new CompleteCommand(serviceProvider)); - AddCommand(new RemoveCommand(serviceProvider)); - } + //// Add handlers + //this.SetHandler(TodoCommandHandler.Create(), Version); - private Option GetVersionOption() - { - return new Option(new string[] { "-v", "--version" }, "Prints out the todo CLI version.") - { - Argument = new Argument() - }; - } + // Add subcommands + Add(new AddCommand(serviceProvider)); + Add(new ListCommand(serviceProvider)); + Add(new CompleteCommand(serviceProvider)); + Add(new RemoveCommand(serviceProvider)); } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Handlers/AddCommandHandler.cs b/src/Todo.CLI/Handlers/AddCommandHandler.cs index 26473eb..90fbc8f 100644 --- a/src/Todo.CLI/Handlers/AddCommandHandler.cs +++ b/src/Todo.CLI/Handlers/AddCommandHandler.cs @@ -1,25 +1,49 @@ -using System; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; -using System.Reflection; -using Todo.Core; -using Todo.Core.Model; +namespace Todo.CLI.Handlers; -namespace Todo.CLI.Handlers +using System; +using System.Threading.Tasks; +using Core.Model; +using Core.Repository; +using Microsoft.Extensions.DependencyInjection; + +public class AddCommandHandler { - public class AddCommandHandler + internal class List { - public static ICommandHandler Create(IServiceProvider serviceProvider) + public static Func Create(IServiceProvider serviceProvider) { - return CommandHandler.Create(async (subject) => + return async name => { - var todoItemRepository = (ITodoItemRepository)serviceProvider.GetService(typeof(ITodoItemRepository)); - await todoItemRepository.AddAsync(new TodoItem() + if (string.IsNullOrEmpty(name)) + throw new InvalidOperationException("name is required to add a list."); + + var todoListRepository = serviceProvider.GetRequiredService(); + await todoListRepository.AddAsync(new TodoList + { + Name = name + }); + }; + } + } + + internal class Item + { + public static Func Create(IServiceProvider serviceProvider) + { + return async (listName, subject) => + { + if (string.IsNullOrEmpty(listName)) + throw new InvalidOperationException("list is required to add an item."); + + var todoListRepo = serviceProvider.GetRequiredService(); + var list = await todoListRepo.GetByNameAsync(listName) ?? throw new InvalidOperationException($"No list found with the name '{listName}'."); + var todoItemRepository = serviceProvider.GetRequiredService(); + await todoItemRepository.AddAsync(new TodoItem { - Subject = subject + Subject = subject, + ListId = list.Id }); - }); + }; } } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Handlers/CompleteCommandHandler.cs b/src/Todo.CLI/Handlers/CompleteCommandHandler.cs index 640a28e..9773318 100644 --- a/src/Todo.CLI/Handlers/CompleteCommandHandler.cs +++ b/src/Todo.CLI/Handlers/CompleteCommandHandler.cs @@ -1,58 +1,79 @@ using InquirerCS; using System; using System.Collections.Generic; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; using System.Linq; -using System.Reflection; using System.Threading.Tasks; -using Todo.Core; using Todo.Core.Model; -namespace Todo.CLI.Handlers +namespace Todo.CLI.Handlers; + +using Core.Repository; +using Microsoft.Extensions.DependencyInjection; + +public class CompleteCommandHandler { - public class CompleteCommandHandler - { - private const string PromptMessage = "Which item(s) would you like to delete?"; - private const string UIHelpMessage = "Use arrow keys to navigate between options. [SPACEBAR] to mark the options, and [ENTER] to confirm your input."; + private const string PromptMessage = "Which item(s) would you like to delete?"; + private const string UIHelpMessage = "Use arrow keys to navigate between options. [SPACEBAR] to mark the options, and [ENTER] to confirm your input."; - public static ICommandHandler Create(IServiceProvider serviceProvider) + public static Func> Create(IServiceProvider serviceProvider) + { + return async (itemName, listName) => { - return CommandHandler.Create(async (itemId) => + try { - var todoItemRepository = (ITodoItemRepository)serviceProvider.GetService(typeof(ITodoItemRepository)); - - if (!string.IsNullOrEmpty(itemId)) + var todoItemRepository = serviceProvider.GetRequiredService(); + var items = string.IsNullOrEmpty(listName) + ? await todoItemRepository.ListAllAsync(false) + : await todoItemRepository.ListByListNameAsync(listName, false); + + if (!string.IsNullOrEmpty(itemName)) { - var item = new TodoItem { Id = itemId }; + var item = items.FirstOrDefault(i => i.Subject == itemName); + if (item is null) + { + Console.ForegroundColor = ConsoleColor.Red; + await Console.Error.WriteLineAsync($"Item called \"{itemName}\" not found."); + Console.ResetColor(); + return 1; + } await todoItemRepository.CompleteAsync(item); } else { - // Retrieve items that are not completed - var items = await todoItemRepository.ListAsync(listAll: false); - // Ask user which items to complete var message = PromptMessage - + Environment.NewLine - + Environment.NewLine - + UIHelpMessage; - + + Environment.NewLine + + Environment.NewLine + + UIHelpMessage; + var selectedItems = Question .Checkbox(message, items) .Prompt(); CompleteItems(todoItemRepository, selectedItems); } - + Console.Clear(); - }); - } + return 0; + } + catch (ArgumentOutOfRangeException exc) + { + if (exc.ParamName == "top" && exc.Message.Contains("The value must be greater than or equal to zero and less than the console's buffer size in that dimension.", StringComparison.Ordinal)) + { + Console.Clear(); + Console.ForegroundColor = ConsoleColor.Red; + await Console.Error.WriteLineAsync("Too many tasks to display on the current console. Filter tasks by passing a specific list using the --list parameter, or increase buffer size of the console."); + Console.ResetColor(); + return 1; + } - private static void CompleteItems(ITodoItemRepository todoItemRepository, IEnumerable selectedItems) - { - Task.WaitAll(selectedItems.Select(item => todoItemRepository.CompleteAsync(item)).ToArray()); - } + throw; + } + }; + } + + private static void CompleteItems(ITodoItemRepository todoItemRepository, IEnumerable selectedItems) + { + Task.WaitAll(selectedItems.Select(todoItemRepository.CompleteAsync).ToArray()); } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Handlers/ListCommandHandler.cs b/src/Todo.CLI/Handlers/ListCommandHandler.cs index 219ed6e..5c0a188 100644 --- a/src/Todo.CLI/Handlers/ListCommandHandler.cs +++ b/src/Todo.CLI/Handlers/ListCommandHandler.cs @@ -1,64 +1,84 @@ -using System; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; -using System.Reflection; -using Todo.Core; -using Todo.Core.Model; +namespace Todo.CLI.Handlers; -namespace Todo.CLI.Handlers +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Core.Model; +using Core.Repository; +using Microsoft.Extensions.DependencyInjection; + +public class ListCommandHandler { - public class ListCommandHandler + private const char TodoBullet = '-'; + private const char CompletedBullet = '\u2713'; // Sqrt - check mark + + public static Func Create(IServiceProvider serviceProvider) { - private const char TodoBullet = '-'; - private const char CompletedBullet = '\u2713'; // Sqrt - check mark + return (all, noStatus, listName) => Execute(serviceProvider, all, noStatus, listName); + } - public static ICommandHandler Create(IServiceProvider serviceProvider) + private static async Task Execute(IServiceProvider sp, bool all, bool noStatus, string listName) + { + if (!string.IsNullOrWhiteSpace(listName)) { - return CommandHandler.Create(async (all, noStatus) => + var listRepo = sp.GetRequiredService(); + var list = await listRepo.GetByNameAsync(listName); + if (list?.Id is null) + Console.WriteLine($"No list found with the name '{listName}'."); + else { - var todoItemRetriever = (ITodoItemRepository)serviceProvider.GetService(typeof(ITodoItemRepository)); - var todoItems = await todoItemRetriever.ListAsync(all); - - foreach (var item in todoItems) - { - if(!noStatus) - { - RenderBullet(item); - Console.Write(" "); - } + var itemRepo = sp.GetRequiredService(); + list.Tasks = (await itemRepo.ListByListIdAsync(list.Id, all)).ToList(); + Render(list); + } - Render(item); - } - }); + return; } - private static void Render(TodoItem item) + var taskRepo = sp.GetRequiredService(); + await foreach (var item in taskRepo.EnumerateAllAsync(all)) { - Console.Write(item.Subject); - Console.Write(Environment.NewLine); + if (!noStatus) + { + RenderBullet(item); + Console.Write(" "); + } + + Render(item); } + } - private static void RenderBullet(TodoItem item) - { - ConsoleColor bulletColor; - char bullet; + private static void Render(TodoList list) + { + Console.WriteLine($"{list.Name} ({list.Count}):"); + foreach (var item in list.Tasks) Render(item); + } - if (item.IsCompleted) - { - bulletColor = ConsoleColor.Green; - bullet = CompletedBullet; - } - else - { - bulletColor = ConsoleColor.Red; - bullet = TodoBullet; - } + private static void Render(TodoItem item) + { + Console.WriteLine(item); + } - var previousColor = Console.ForegroundColor; - Console.ForegroundColor = bulletColor; - Console.Write(bullet); - Console.ForegroundColor = previousColor; + private static void RenderBullet(TodoItem item) + { + ConsoleColor bulletColor; + char bullet; + + if (item.IsCompleted) + { + bulletColor = ConsoleColor.Green; + bullet = CompletedBullet; } + else + { + bulletColor = ConsoleColor.Red; + bullet = TodoBullet; + } + + var previousColor = Console.ForegroundColor; + Console.ForegroundColor = bulletColor; + Console.Write(bullet); + Console.ForegroundColor = previousColor; } } diff --git a/src/Todo.CLI/Handlers/RemoveCommandHandler.cs b/src/Todo.CLI/Handlers/RemoveCommandHandler.cs index 6bd7cbe..d89d68f 100644 --- a/src/Todo.CLI/Handlers/RemoveCommandHandler.cs +++ b/src/Todo.CLI/Handlers/RemoveCommandHandler.cs @@ -1,49 +1,69 @@ using InquirerCS; using System; using System.Collections.Generic; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; using System.Linq; -using System.Reflection; using System.Threading.Tasks; -using Todo.Core; using Todo.Core.Model; +using Microsoft.Extensions.DependencyInjection; -namespace Todo.CLI.Handlers +namespace Todo.CLI.Handlers; + +using Core.Repository; + +public class RemoveCommandHandler { - public class RemoveCommandHandler - { - private const string PromptMessage = "Which item(s) would you like to delete?"; - private const string UIHelpMessage = "Use arrow keys to navigate between options. [SPACEBAR] to mark the options, and [ENTER] to confirm your input."; + private const string PromptMessage = "Which item(s) would you like to delete?"; + private const string UIHelpMessage = "Use arrow keys to navigate between options. [SPACEBAR] to mark the options, and [ENTER] to confirm your input."; - public static ICommandHandler Create(IServiceProvider serviceProvider) + public static Func> Create(IServiceProvider serviceProvider) + { + return async listName => { - return CommandHandler.Create(async () => - { - var todoItemRepository = (ITodoItemRepository)serviceProvider.GetService(typeof(ITodoItemRepository)); + var todoItemRepository = serviceProvider.GetRequiredService(); - // Retrieve all items - var items = await todoItemRepository.ListAsync(listAll: true); + // Retrieve items + var items = (string.IsNullOrEmpty(listName) + ? await todoItemRepository.ListAllAsync(includeCompleted: true) + : await todoItemRepository.ListByListNameAsync(listName, includeCompleted: true)).ToList(); - // Ask user which item to delete - var message = PromptMessage - + Environment.NewLine - + Environment.NewLine - + UIHelpMessage; + // Ask user which item to delete + var message = PromptMessage + + Environment.NewLine + + Environment.NewLine + + UIHelpMessage; + try + { var selectedItems = Question .Checkbox(message, items) .Prompt(); - + DeleteItems(todoItemRepository, selectedItems); - }); - } + return 0; + } + catch (ArgumentOutOfRangeException exc) + { + if (exc.ParamName == "top" && + exc.Message.Contains( + "The value must be greater than or equal to zero and less than the console's buffer size in that dimension.", + StringComparison.Ordinal)) + { + Console.Clear(); + Console.ForegroundColor = ConsoleColor.Red; + await Console.Error.WriteLineAsync( + $"Too many tasks ({items.Count}) to display on the current console. Filter tasks by passing a specific list using the --list parameter, or increase buffer size of the console."); + Console.ResetColor(); + return 1; + } - private static void DeleteItems(ITodoItemRepository todoItemRepository, IEnumerable selectedItems) - { - Task.WaitAll(selectedItems.Select(item => todoItemRepository.DeleteAsync(item)).ToArray()); - Console.Clear(); - } + throw; + } + }; + } + + private static void DeleteItems(ITodoItemRepository todoItemRepository, IEnumerable selectedItems) + { + Task.WaitAll(selectedItems.Select(todoItemRepository.DeleteAsync).ToArray()); + Console.Clear(); } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Handlers/TodoCommandHandler.cs b/src/Todo.CLI/Handlers/TodoCommandHandler.cs index 83e472f..8ddff4c 100644 --- a/src/Todo.CLI/Handlers/TodoCommandHandler.cs +++ b/src/Todo.CLI/Handlers/TodoCommandHandler.cs @@ -1,31 +1,23 @@ using System; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; using System.Reflection; -namespace Todo.CLI.Handlers +namespace Todo.CLI.Handlers; + +public class TodoCommandHandler { - public class TodoCommandHandler + public static Action Create() { - public static ICommandHandler Create() + return version => { - return CommandHandler.Create((version) => - { - if (version) - { - PrintVersion(); - return; - } - }); - } + if (version) PrintVersion(); + }; + } - private static void PrintVersion() - { - var entryAssembly = Assembly.GetEntryAssembly(); - var entryAssemblyName = entryAssembly.GetName(); - var description = entryAssembly.GetCustomAttribute()?.Description; - Console.WriteLine($"{entryAssemblyName.Name} {entryAssemblyName.Version} - {description}"); - } + private static void PrintVersion() + { + var entryAssembly = Assembly.GetEntryAssembly(); + var entryAssemblyName = entryAssembly.GetName(); + var description = entryAssembly.GetCustomAttribute()?.Description; + Console.WriteLine($"{entryAssemblyName.Name} {entryAssemblyName.Version} - {description}"); } -} +} \ No newline at end of file diff --git a/src/Todo.CLI/Program.cs b/src/Todo.CLI/Program.cs index 59924dd..89fe464 100644 --- a/src/Todo.CLI/Program.cs +++ b/src/Todo.CLI/Program.cs @@ -1,38 +1,25 @@ using Todo.CLI.Commands; -using System; using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; -using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Todo.Core; -using Microsoft.Graph; using Todo.CLI.Auth; -using Todo.Core.Repository; -using System.Threading.Tasks; +using Todo.CLI; -namespace Todo.CLI -{ - class Program - { - static async Task Main(string[] args) - { - var config = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) - .Build(); +var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .Build(); - var todoCliConfig = new TodoCliConfiguration(); - config.Bind("TodoCliConfiguration", todoCliConfig); +var todoCliConfig = new TodoCliConfiguration(); +config.Bind("TodoCliConfiguration", todoCliConfig); - var services = new ServiceCollection() - .AddSingleton(typeof(TodoCliConfiguration), todoCliConfig) - .AddTransient(factory => new TodoItemRepository(TodoCliAuthenticationProviderFactory.GetAuthenticationProvider(factory))); +var services = new ServiceCollection() + .AddSingleton(todoCliConfig) + .AddSingleton(TodoCliAuthenticationProviderFactory.GetAuthenticationProvider) + .AddTodoRepositories(); - var serviceProvider = services.BuildServiceProvider(); +var serviceProvider = services.BuildServiceProvider(); - return await new TodoCommand(serviceProvider) - .InvokeAsync(args); - } - } -} +var todoCommand = new TodoCommand(serviceProvider); +return await todoCommand + .InvokeAsync(args); // Exception here \ No newline at end of file diff --git a/src/Todo.CLI/Todo.CLI.csproj b/src/Todo.CLI/Todo.CLI.csproj index c5eecba..a459102 100644 --- a/src/Todo.CLI/Todo.CLI.csproj +++ b/src/Todo.CLI/Todo.CLI.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.0 + net8.0 Todo.CLI mehmetseckin Todo CLI @@ -10,12 +10,11 @@ https://github.com/mehmetseckin/todo-cli/ Git microsoft-todo todo CLI - 0.1.3 - 0.1.3 - 0.1.3 + 0.2.0 + 0.2.0 + 0.2.0 todo true - Todo.CLI.Program @@ -29,13 +28,13 @@ - - - - - - - + + + + + + + diff --git a/src/Todo.CLI/TodoCliConfiguration.cs b/src/Todo.CLI/TodoCliConfiguration.cs index dea936b..e1b8ffc 100644 --- a/src/Todo.CLI/TodoCliConfiguration.cs +++ b/src/Todo.CLI/TodoCliConfiguration.cs @@ -1,12 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Text; +using System.Collections.Generic; -namespace Todo.CLI +namespace Todo.CLI; + +public class TodoCliConfiguration { - public class TodoCliConfiguration - { - public string ClientId { get; set; } - public IEnumerable Scopes { get; set; } - } -} + public string ClientId { get; set; } + public IEnumerable Scopes { get; set; } +} \ No newline at end of file diff --git a/src/Todo.Core/Model/TodoItem.cs b/src/Todo.Core/Model/TodoItem.cs index 5c9471a..ab43265 100644 --- a/src/Todo.Core/Model/TodoItem.cs +++ b/src/Todo.Core/Model/TodoItem.cs @@ -1,16 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.Graph; +namespace Todo.Core.Model; -namespace Todo.Core.Model +public class TodoItem { - public class TodoItem - { - public string Id { get; set; } - public string Subject { get; set; } - public bool IsCompleted { get; set; } + public string? Id { get; set; } + public string? Subject { get; set; } + public bool IsCompleted { get; set; } + public string Status { get; set; } = "NotStarted"; + public DateTime? Completed { get; set; } + public string? ListId { get; set; } - public override string ToString() => Subject; - } -} + public override string ToString() => $"{Subject} - {Status} {(IsCompleted ? Completed?.ToString("yyyy-mm-dd") : string.Empty)}"; +} \ No newline at end of file diff --git a/src/Todo.Core/Model/TodoList.cs b/src/Todo.Core/Model/TodoList.cs new file mode 100644 index 0000000..c72334c --- /dev/null +++ b/src/Todo.Core/Model/TodoList.cs @@ -0,0 +1,10 @@ +namespace Todo.Core.Model; + +public class TodoList +{ + public string? Id { get; set; } + public string? Name { get; set; } + public int Count => Tasks.Count; + public bool Shared { get; set; } + public List Tasks { get; set; } = []; +} diff --git a/src/Todo.Core/Repository/ITodoItemRepository.cs b/src/Todo.Core/Repository/ITodoItemRepository.cs index b61c43a..c9c9822 100644 --- a/src/Todo.Core/Repository/ITodoItemRepository.cs +++ b/src/Todo.Core/Repository/ITodoItemRepository.cs @@ -1,18 +1,16 @@ - -using Microsoft.Graph; -using System; +namespace Todo.Core.Repository; + using System.Collections.Generic; -using System.Text; using System.Threading.Tasks; using Todo.Core.Model; -namespace Todo.Core +public interface ITodoItemRepository { - public interface ITodoItemRepository - { - Task AddAsync(TodoItem item); - Task> ListAsync(bool listAll); - Task CompleteAsync(TodoItem item); - Task DeleteAsync(TodoItem item); - } -} + Task AddAsync(TodoItem item); + Task> ListAllAsync(bool includeCompleted); + IAsyncEnumerable EnumerateAllAsync(bool includeCompleted); + Task> ListByListIdAsync(string listId, bool includeCompleted); + Task> ListByListNameAsync(string listName, bool includeCompleted); + Task CompleteAsync(TodoItem item); + Task DeleteAsync(TodoItem item); +} \ No newline at end of file diff --git a/src/Todo.Core/Repository/ITodoListRepository.cs b/src/Todo.Core/Repository/ITodoListRepository.cs new file mode 100644 index 0000000..1a8e6b6 --- /dev/null +++ b/src/Todo.Core/Repository/ITodoListRepository.cs @@ -0,0 +1,18 @@ +namespace Todo.Core.Repository; + +using System.Threading.Tasks; +using Todo.Core.Model; + +public interface ITodoListRepository +{ + Task AddAsync(TodoList list); + Task> GetAllAsync(); + + /// + /// Finds a list by name. + /// + /// Name of the list. + /// A object including all its items, or if no list was found under the given name. + Task GetByNameAsync(string name); + Task DeleteAsync(TodoList list); +} \ No newline at end of file diff --git a/src/Todo.Core/Repository/RepositoryBase.cs b/src/Todo.Core/Repository/RepositoryBase.cs index ca9364b..776ad33 100644 --- a/src/Todo.Core/Repository/RepositoryBase.cs +++ b/src/Todo.Core/Repository/RepositoryBase.cs @@ -1,17 +1,13 @@ -using Microsoft.Graph; -using System; -using System.Collections.Generic; -using System.Text; +namespace Todo.Core.Repository; -namespace Todo.Core.Repository +using Microsoft.Kiota.Abstractions.Authentication; + +public abstract class RepositoryBase { - public abstract class RepositoryBase - { - protected IAuthenticationProvider AuthenticationProvider { get; } + protected IAuthenticationProvider AuthenticationProvider { get; } - public RepositoryBase(IAuthenticationProvider authenticationProvider) - { - AuthenticationProvider = authenticationProvider; - } + protected RepositoryBase(IAuthenticationProvider authenticationProvider) + { + AuthenticationProvider = authenticationProvider; } -} +} \ No newline at end of file diff --git a/src/Todo.Core/Repository/TodoItemRepository.cs b/src/Todo.Core/Repository/TodoItemRepository.cs index 7438d73..03346ee 100644 --- a/src/Todo.Core/Repository/TodoItemRepository.cs +++ b/src/Todo.Core/Repository/TodoItemRepository.cs @@ -1,61 +1,124 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Graph; +using Microsoft.Graph; using Todo.Core.Model; -using TaskStatus = Microsoft.Graph.TaskStatus; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions.Authentication; -namespace Todo.Core.Repository +namespace Todo.Core.Repository; + +using TaskStatus = Microsoft.Graph.Models.TaskStatus; + +internal class TodoItemRepository : RepositoryBase, ITodoItemRepository { - public class TodoItemRepository : RepositoryBase, ITodoItemRepository + public TodoItemRepository(IAuthenticationProvider authenticationProvider) + : base(authenticationProvider) { - public TodoItemRepository(IAuthenticationProvider authenticationProvider) - : base(authenticationProvider) - { - } + } - public async Task AddAsync(TodoItem item) + public async Task AddAsync(TodoItem item) + { + if (string.IsNullOrEmpty(item.ListId)) + throw new InvalidOperationException("item needs a ListId to identify the list to add it to."); + + var graphServiceClient = new GraphServiceClient(AuthenticationProvider); + _ = await graphServiceClient.Me.Todo.Lists[item.ListId].Tasks.PostAsync(new TodoTask() { - var graphServiceClient = new GraphServiceClient(AuthenticationProvider); - await graphServiceClient.Me.Outlook.Tasks.Request().AddAsync(new OutlookTask() - { - Subject = item.Subject - }); - } + Title = item.Subject, + Status = item.IsCompleted + ? TaskStatus.Completed + : TaskStatus.NotStarted + }); + } + + public async Task CompleteAsync(TodoItem item) + { + if (string.IsNullOrEmpty(item.ListId) || string.IsNullOrEmpty(item.Id)) + throw new InvalidOperationException("item needs a ListId and an Id."); - public async Task CompleteAsync(TodoItem item) + var graphServiceClient = new GraphServiceClient(AuthenticationProvider); + await graphServiceClient.Me.Todo.Lists[item.ListId].Tasks[item.Id].PatchAsync(new TodoTask() { - var graphServiceClient = new GraphServiceClient(AuthenticationProvider); - var requestUrl = graphServiceClient.Me.Outlook.Tasks.AppendSegmentToRequestUrl($"{item.Id}/complete"); - var builder = new OutlookTaskCompleteRequestBuilder(requestUrl, graphServiceClient); - await builder.Request().PostAsync(); - } + Status = TaskStatus.Completed + }); + } + + public async Task DeleteAsync(TodoItem item) + { + if (string.IsNullOrEmpty(item.ListId) || string.IsNullOrEmpty(item.Id)) + throw new InvalidOperationException("item needs a ListId and an Id."); + + var graphServiceClient = new GraphServiceClient(AuthenticationProvider); + await graphServiceClient.Me.Todo.Lists[item.ListId].Tasks[item.Id].DeleteAsync(); + } - public async Task DeleteAsync(TodoItem item) + public async Task> ListAllAsync(bool includeCompleted) + { + var graphServiceClient = new GraphServiceClient(AuthenticationProvider); + var lists = await graphServiceClient.Me.Todo.Lists.GetAsync(); + if (lists?.Value is null) return []; + var tasks = lists.Value + .AsParallel() + .Select(list => (list, tasks: graphServiceClient.Me.Todo.Lists[list.Id].Tasks.GetAsync())) + .Select((input, _) => (input.list, tasks: input.tasks.GetAwaiter().GetResult()?.Value)) + .Where(l => l.tasks is not null) + .SelectMany(l => l.tasks!.Select(t => (l.list, task: t))); + + if (!includeCompleted) { - var graphServiceClient = new GraphServiceClient(AuthenticationProvider); - var requestUrl = graphServiceClient.Me.Outlook.Tasks.AppendSegmentToRequestUrl($"{item.Id}"); - var builder = new OutlookTaskRequestBuilder(requestUrl, graphServiceClient); - await builder.Request().DeleteAsync(); + tasks = tasks.Where(t => t.task.Status is not TaskStatus.Completed); } - public async Task> ListAsync(bool listAll) + return tasks.Select(input => input.task.ToModel(input.list.Id)); + } + + public async IAsyncEnumerable EnumerateAllAsync(bool includeCompleted) + { + var graphServiceClient = new GraphServiceClient(AuthenticationProvider); + var lists = await graphServiceClient.Me.Todo.Lists.GetAsync(); + if (lists?.Value is null) yield break; + var tasks = lists.Value + .Select(l => Task.Run(async () => + (ListId: l.Id, Tasks: await graphServiceClient.Me.Todo.Lists[l.Id].Tasks.GetAsync()))) + .ToList(); + + while (tasks.Count > 0) { - var graphServiceClient = new GraphServiceClient(AuthenticationProvider); - var request = graphServiceClient.Me.Outlook.Tasks.Request(); - if(!listAll) + var task = await Task.WhenAny(tasks); + tasks.Remove(task); + var (listId, taskResponse) = await task; + IEnumerable items = taskResponse!.Value!; + if (includeCompleted) + items = items.Where(t => t.Status is not TaskStatus.Completed); + foreach (var todoTask in items) { - request.Filter($"status ne '{TaskStatus.Completed.ToString().ToLower()}'"); + yield return todoTask.ToModel(listId); } - var tasks = await request.GetAsync(); - return tasks.Select(task => new TodoItem() - { - Id = task.Id, - Subject = task.Subject, - IsCompleted = task.Status == TaskStatus.Completed - }); } } -} + + public async Task> ListByListIdAsync(string listId, bool includeCompleted) + { + ArgumentException.ThrowIfNullOrEmpty(listId); + + var graphServiceClient = new GraphServiceClient(AuthenticationProvider); + IEnumerable? tasks = (await graphServiceClient.Me.Todo.Lists[listId].Tasks.GetAsync())?.Value; + if (tasks is null) + return new List(0); + + if (!includeCompleted) + { + tasks = tasks.Where(t => t.Status is not TaskStatus.Completed); + } + + return tasks.Select(t => t.ToModel(listId)); + } + + public async Task> ListByListNameAsync(string listName, bool includeCompleted) + { + ArgumentException.ThrowIfNullOrEmpty(listName); + + var graphServiceClient = new GraphServiceClient(AuthenticationProvider); + var lists = await graphServiceClient.Me.Todo.Lists.GetAsync(); + var list = lists?.Value?.FirstOrDefault(l => l.DisplayName == listName); + return list is null ? new List(0) : await ListByListIdAsync(list.Id!, includeCompleted); + } +} \ No newline at end of file diff --git a/src/Todo.Core/Repository/TodoListRepository.cs b/src/Todo.Core/Repository/TodoListRepository.cs new file mode 100644 index 0000000..8ad6e09 --- /dev/null +++ b/src/Todo.Core/Repository/TodoListRepository.cs @@ -0,0 +1,81 @@ +namespace Todo.Core.Repository; + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions.Authentication; +using Model; + +internal class TodoListRepository : RepositoryBase, ITodoListRepository +{ + public TodoListRepository(IAuthenticationProvider authenticationProvider) : base(authenticationProvider) + { + } + + public async Task AddAsync(TodoList list) + { + var client = new GraphServiceClient(AuthenticationProvider); + await client.Me.Todo.Lists.PostAsync(new TodoTaskList + { + DisplayName = list.Name, + Tasks = list.Tasks?.Select(t => new TodoTask + { + Title = t.Subject + }).ToList() + }); + } + + public async Task> GetAllAsync() + { + var client = new GraphServiceClient(AuthenticationProvider); + var lists = await client.Me.Todo.Lists.GetAsync(); + return lists?.Value?.Select(list => new TodoList + { + Id = list.Id, + Name = list.DisplayName, + Tasks = list.Tasks?.Select(t => new TodoItem + { + Id = t.Id, + Subject = t.Title, + IsCompleted = t.Status == Microsoft.Graph.Models.TaskStatus.Completed, + ListId = list.Id, + Completed = t.CompletedDateTime?.ToDateTime(), + Status = t.Status?.ToString() ?? "Unknown" + }).ToList() ?? new() + }) ?? Array.Empty(); + } + + public async Task GetByNameAsync(string name) + { + if(string.IsNullOrEmpty(name)) + throw new InvalidOperationException("name is required to get a list by name."); + + var client = new GraphServiceClient(AuthenticationProvider); + var lists = await client.Me.Todo.Lists.GetAsync(); + var list = lists?.Value?.FirstOrDefault(l => l.DisplayName == name); + return list is null ? null : new TodoList + { + Id = list.Id, + Name = list.DisplayName, + Tasks = list.Tasks?.Select(t => new TodoItem + { + Id = t.Id, + Subject = t.Title, + IsCompleted = t.Status == Microsoft.Graph.Models.TaskStatus.Completed, + ListId = list.Id, + Completed = t.CompletedDateTime?.ToDateTime(), + Status = t.Status?.ToString() ?? "Unknown" + }).ToList() ?? new() + }; + } + + public async Task DeleteAsync(TodoList list) + { + if (string.IsNullOrEmpty(list.Id)) + throw new InvalidOperationException("list needs an Id to be deleted."); + + var client = new GraphServiceClient(AuthenticationProvider); + await client.Me.Todo.Lists[list.Id].DeleteAsync(); + } +} diff --git a/src/Todo.Core/Todo.Core.csproj b/src/Todo.Core/Todo.Core.csproj index 86737b9..0503bf9 100644 --- a/src/Todo.Core/Todo.Core.csproj +++ b/src/Todo.Core/Todo.Core.csproj @@ -1,12 +1,15 @@  - netcoreapp3.0 + net8.0 + enable + enable - - + + + diff --git a/src/Todo.Core/TodoDependencyInjectionExtensions.cs b/src/Todo.Core/TodoDependencyInjectionExtensions.cs new file mode 100644 index 0000000..7cd82b1 --- /dev/null +++ b/src/Todo.Core/TodoDependencyInjectionExtensions.cs @@ -0,0 +1,34 @@ +namespace Todo.Core; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Graph.Models; +using Model; +using Repository; + +public static class TodoDependencyInjectionExtensions +{ + /// + /// Adds repositories for lists and items to the service collection. Depends on to be present in the DI container. + /// + /// The service collection to add the repositories to. + /// The service collection itself for chaining. + public static IServiceCollection AddTodoRepositories(this IServiceCollection services) + { + return services + .AddTransient() + .AddTransient(); + } + + internal static TodoItem ToModel(this Microsoft.Graph.Models.TodoTask task, string? listId) + { + return new TodoItem + { + Id = task.Id, + Subject = task.Title, + IsCompleted = task.Status == Microsoft.Graph.Models.TaskStatus.Completed, + Status = task.Status?.ToString() ?? "Unknown", + Completed = task.CompletedDateTime?.ToDateTime(), + ListId = listId + }; + } +} \ No newline at end of file From ec395bc6dc70f9b88e8a9ed2d541386cebb8fa6d Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Tue, 20 Feb 2024 22:28:24 +0100 Subject: [PATCH 2/8] Update runtime identifiers --- pipelines/cd.yml | 12 ++++++------ src/Todo.CLI/Todo.CLI.csproj | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pipelines/cd.yml b/pipelines/cd.yml index 4f69d9e..833908a 100644 --- a/pipelines/cd.yml +++ b/pipelines/cd.yml @@ -9,8 +9,8 @@ trigger: stages: - template: templates/stages/build.yml parameters: - name: 'win10_x64' - runtimeIdentifier: 'win10-x64' + name: 'win_x64' + runtimeIdentifier: 'win-x64' vmImage: 'windows-latest' - template: templates/stages/build.yml parameters: @@ -25,10 +25,10 @@ stages: - stage: 'Release' variables: artifactsDirectory: '$(Pipeline.Workspace)/artifacts' - win10_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).win10-x64' + win10_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).win-x64' linux_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).linux-x64' osx_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).osx-x64' - dependsOn: ['win10_x64', 'linux_x64', 'osx_x64'] + dependsOn: ['win_x64', 'linux_x64', 'osx_x64'] jobs: - job: 'release_job' steps: @@ -39,9 +39,9 @@ stages: inputs: targetPath: '$(artifactsDirectory)' - task: ArchiveFiles@2 - displayName: 'Create $(win10_x64_artifactPath).zip' + displayName: 'Create $(win_x64_artifactPath).zip' inputs: - rootFolderOrFile: '$(win10_x64_artifactPath)' + rootFolderOrFile: '$(win_x64_artifactPath)' includeRootFolder: false archiveFile: '$(win10_x64_artifactPath).zip' - task: ArchiveFiles@2 diff --git a/src/Todo.CLI/Todo.CLI.csproj b/src/Todo.CLI/Todo.CLI.csproj index a459102..d62cce3 100644 --- a/src/Todo.CLI/Todo.CLI.csproj +++ b/src/Todo.CLI/Todo.CLI.csproj @@ -18,7 +18,7 @@ - win10-x64 + win-x64 true true From 2cc2bc590375288d1bb9a86ff8271c61a4362619 Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Tue, 20 Feb 2024 22:37:14 +0100 Subject: [PATCH 3/8] Update readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c50d45f..dc6906d 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ # Todo CLI -A cross-platform command-line interface to interact with Microsoft To Do, built using .NET Core 3. +A cross-platform command-line interface to interact with Microsoft To Do, built using .NET 8. ## Build Status | Platform | Status | | ------ | ------------ | | CI | [![CI build status](https://dev.azure.com/mtseckin/todo-cli/_apis/build/status/CI)](https://dev.azure.com/mtseckin/todo-cli/_build/latest?definitionId=1) | -| Windows 10 (x64) | [![Windows 10 (x64) build status](https://dev.azure.com/mtseckin/todo-cli/_apis/build/status/CD?stageName=win10_x64)](https://dev.azure.com/mtseckin/todo-cli/_build/latest?definitionId=5) | +| Windows (x64) | [![Windows (x64) build status](https://dev.azure.com/mtseckin/todo-cli/_apis/build/status/CD?stageName=win_x64)](https://dev.azure.com/mtseckin/todo-cli/_build/latest?definitionId=5) | | Linux (x64) | [![Linux (x64) build status](https://dev.azure.com/mtseckin/todo-cli/_apis/build/status/CD?stageName=linux_x64)](https://dev.azure.com/mtseckin/todo-cli/_build/latest?definitionId=5) | | macOS X (x64) | [![macO X (x64) build status](https://dev.azure.com/mtseckin/todo-cli/_apis/build/status/CD?stageName=osx_x64)](https://dev.azure.com/mtseckin/todo-cli/_build/latest?definitionId=5) | @@ -62,7 +62,7 @@ Be nice to people, give constructive feedback, and have fun! This project is built using the following nuggets of awesomeness, and many more. Many thanks to the folks who are working on and maintaining these products. -- [.NET Core 3](https://github.com/dotnet/core) +- [.NET 8](https://github.com/dotnet/core) - [System.CommandLine](https://github.com/dotnet/command-line-api) - [Microsoft Graph Beta SDK](https://github.com/microsoftgraph/msgraph-beta-sdk-dotnet) -- [Inquirer.cs](https://github.com/agolaszewski/Inquirer.cs) +- [Inquirer.cs](https://github.com/hayer/Inquirer.cs) From ca2eda644a3d496df86058daf1104b7f8c0c6e82 Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Tue, 20 Feb 2024 22:39:18 +0100 Subject: [PATCH 4/8] Update another runtime identifier --- pipelines/templates/stages/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipelines/templates/stages/build.yml b/pipelines/templates/stages/build.yml index 3f09203..20d4063 100644 --- a/pipelines/templates/stages/build.yml +++ b/pipelines/templates/stages/build.yml @@ -1,6 +1,6 @@ parameters: name: '' - runtimeIdentifier: 'win10-x64' + runtimeIdentifier: 'win-x64' vmImage: 'windows-latest' dependsOn: [] From a7990874f775a5738ec82179d871579f81e4509b Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Wed, 21 Feb 2024 14:08:05 +0100 Subject: [PATCH 5/8] Chagne another win10 -> win --- pipelines/cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pipelines/cd.yml b/pipelines/cd.yml index 833908a..7c4fcbc 100644 --- a/pipelines/cd.yml +++ b/pipelines/cd.yml @@ -25,7 +25,7 @@ stages: - stage: 'Release' variables: artifactsDirectory: '$(Pipeline.Workspace)/artifacts' - win10_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).win-x64' + win_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).win-x64' linux_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).linux-x64' osx_x64_artifactPath: '$(artifactsDirectory)/todo.$(build.buildNumber).osx-x64' dependsOn: ['win_x64', 'linux_x64', 'osx_x64'] @@ -43,7 +43,7 @@ stages: inputs: rootFolderOrFile: '$(win_x64_artifactPath)' includeRootFolder: false - archiveFile: '$(win10_x64_artifactPath).zip' + archiveFile: '$(win_x64_artifactPath).zip' - task: ArchiveFiles@2 displayName: 'Create $(linux_x64_artifactPath).tar.gz' inputs: From 142c560c1c5e773cb55eef504e3840e49577b789 Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Wed, 21 Feb 2024 14:32:37 +0100 Subject: [PATCH 6/8] Remove completed date from item output when --noStatus is used --- src/Todo.CLI/Handlers/ListCommandHandler.cs | 6 +++--- src/Todo.Core/Model/TodoItem.cs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Todo.CLI/Handlers/ListCommandHandler.cs b/src/Todo.CLI/Handlers/ListCommandHandler.cs index 5c0a188..c922f96 100644 --- a/src/Todo.CLI/Handlers/ListCommandHandler.cs +++ b/src/Todo.CLI/Handlers/ListCommandHandler.cs @@ -45,7 +45,7 @@ private static async Task Execute(IServiceProvider sp, bool all, bool noStatus, Console.Write(" "); } - Render(item); + Render(item, noStatus); } } @@ -55,9 +55,9 @@ private static void Render(TodoList list) foreach (var item in list.Tasks) Render(item); } - private static void Render(TodoItem item) + private static void Render(TodoItem item, bool noStatus) { - Console.WriteLine(item); + Console.WriteLine(item.ToString(noStatus)); } private static void RenderBullet(TodoItem item) diff --git a/src/Todo.Core/Model/TodoItem.cs b/src/Todo.Core/Model/TodoItem.cs index ab43265..c10ad19 100644 --- a/src/Todo.Core/Model/TodoItem.cs +++ b/src/Todo.Core/Model/TodoItem.cs @@ -10,4 +10,5 @@ public class TodoItem public string? ListId { get; set; } public override string ToString() => $"{Subject} - {Status} {(IsCompleted ? Completed?.ToString("yyyy-mm-dd") : string.Empty)}"; + public string ToString(bool noStatus) => noStatus ? Subject : ToString(); } \ No newline at end of file From b6df85146e4456f41246fd92511ee7d6f2fd37d2 Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Fri, 23 Feb 2024 00:00:42 +0100 Subject: [PATCH 7/8] Add filters to list/remove items completes before a given date --- src/Todo.CLI/Commands/ListCommand.cs | 15 +++++++++--- src/Todo.CLI/Commands/RemoveCommand.cs | 4 +++- src/Todo.CLI/Handlers/ListCommandHandler.cs | 23 +++++++++++-------- src/Todo.CLI/Handlers/RemoveCommandHandler.cs | 13 +++++++++-- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/Todo.CLI/Commands/ListCommand.cs b/src/Todo.CLI/Commands/ListCommand.cs index fb65a38..8cfa373 100644 --- a/src/Todo.CLI/Commands/ListCommand.cs +++ b/src/Todo.CLI/Commands/ListCommand.cs @@ -6,21 +6,30 @@ namespace Todo.CLI.Commands; public class ListCommand : Command { - private static readonly Option GetAllOption = new(["-a", "--all"], "Lists all to do items including the completed ones."); - private static readonly Option NoStatusOption = new(["--no-status"], "Suppresses the bullet indicating whether the item is completed or not."); + private static readonly Option GetAllOption = + new(["-a", "--all"], "Lists all to do items including the completed ones."); + + private static readonly Option NoStatusOption = new(["--no-status"], + "Suppresses the bullet indicating whether the item is completed or not."); + + private static readonly Option OlderThanOption = + new(["--older-than"], "Only items completed before this date."); + private static readonly Argument ListNameArgument = new("list-name", "Only list tasks of this To-Do list.") { Arity = ArgumentArity.ZeroOrOne }; + public ListCommand(IServiceProvider serviceProvider) : base("list") { Description = "Retrieves a list of the to do items across all To-Do lists."; Add(GetAllOption); Add(NoStatusOption); + Add(OlderThanOption); Add(ListNameArgument); - this.SetHandler(ListCommandHandler.Create(serviceProvider), GetAllOption, NoStatusOption, ListNameArgument); + this.SetHandler(ListCommandHandler.Create(serviceProvider), GetAllOption, NoStatusOption, OlderThanOption, ListNameArgument); } } diff --git a/src/Todo.CLI/Commands/RemoveCommand.cs b/src/Todo.CLI/Commands/RemoveCommand.cs index ebafb23..9527d60 100644 --- a/src/Todo.CLI/Commands/RemoveCommand.cs +++ b/src/Todo.CLI/Commands/RemoveCommand.cs @@ -7,9 +7,11 @@ namespace Todo.CLI.Commands; public class RemoveCommand : Command { private static readonly Option ListOpt = new(["--list", "-l"], "The name of the list to remove the item from."); + private static readonly Option OlderThanOpt = new(new[] { "--older-than" }, "Only items completed before this date."); public RemoveCommand(IServiceProvider serviceProvider) : base("remove", "Deletes a to do item.") { Add(ListOpt); - this.SetHandler(RemoveCommandHandler.Create(serviceProvider), ListOpt); + Add(OlderThanOpt); + this.SetHandler(RemoveCommandHandler.Create(serviceProvider), ListOpt, OlderThanOpt); } } \ No newline at end of file diff --git a/src/Todo.CLI/Handlers/ListCommandHandler.cs b/src/Todo.CLI/Handlers/ListCommandHandler.cs index c922f96..5fc0cd2 100644 --- a/src/Todo.CLI/Handlers/ListCommandHandler.cs +++ b/src/Todo.CLI/Handlers/ListCommandHandler.cs @@ -1,7 +1,6 @@ namespace Todo.CLI.Handlers; using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Core.Model; @@ -13,12 +12,12 @@ public class ListCommandHandler private const char TodoBullet = '-'; private const char CompletedBullet = '\u2713'; // Sqrt - check mark - public static Func Create(IServiceProvider serviceProvider) + public static Func Create(IServiceProvider serviceProvider) { - return (all, noStatus, listName) => Execute(serviceProvider, all, noStatus, listName); + return (all, noStatus, olderThan, listName) => Execute(serviceProvider, all, noStatus, olderThan, listName); } - private static async Task Execute(IServiceProvider sp, bool all, bool noStatus, string listName) + private static async Task Execute(IServiceProvider sp, bool all, bool noStatus, DateTime? olderThan, string listName) { if (!string.IsNullOrWhiteSpace(listName)) { @@ -29,15 +28,21 @@ private static async Task Execute(IServiceProvider sp, bool all, bool noStatus, else { var itemRepo = sp.GetRequiredService(); - list.Tasks = (await itemRepo.ListByListIdAsync(list.Id, all)).ToList(); - Render(list); + var tasksCall = await itemRepo.ListByListIdAsync(list.Id, all); + if(olderThan.HasValue) + tasksCall = tasksCall.Where(item => item.IsCompleted && item.Completed < olderThan); + list.Tasks = tasksCall.ToList(); + Render(list, noStatus); } return; } var taskRepo = sp.GetRequiredService(); - await foreach (var item in taskRepo.EnumerateAllAsync(all)) + var tasks = taskRepo.EnumerateAllAsync(all).ToBlockingEnumerable(); + if (olderThan.HasValue) + tasks = tasks.Where(item => item.IsCompleted && item.Completed < olderThan); + foreach (var item in tasks) { if (!noStatus) { @@ -49,10 +54,10 @@ private static async Task Execute(IServiceProvider sp, bool all, bool noStatus, } } - private static void Render(TodoList list) + private static void Render(TodoList list, bool noStatus) { Console.WriteLine($"{list.Name} ({list.Count}):"); - foreach (var item in list.Tasks) Render(item); + foreach (var item in list.Tasks) Render(item, noStatus); } private static void Render(TodoItem item, bool noStatus) diff --git a/src/Todo.CLI/Handlers/RemoveCommandHandler.cs b/src/Todo.CLI/Handlers/RemoveCommandHandler.cs index d89d68f..de18590 100644 --- a/src/Todo.CLI/Handlers/RemoveCommandHandler.cs +++ b/src/Todo.CLI/Handlers/RemoveCommandHandler.cs @@ -15,9 +15,9 @@ public class RemoveCommandHandler private const string PromptMessage = "Which item(s) would you like to delete?"; private const string UIHelpMessage = "Use arrow keys to navigate between options. [SPACEBAR] to mark the options, and [ENTER] to confirm your input."; - public static Func> Create(IServiceProvider serviceProvider) + public static Func> Create(IServiceProvider serviceProvider) { - return async listName => + return async (listName, olderThan) => { var todoItemRepository = serviceProvider.GetRequiredService(); @@ -26,6 +26,15 @@ public static Func> Create(IServiceProvider serviceProvider) ? await todoItemRepository.ListAllAsync(includeCompleted: true) : await todoItemRepository.ListByListNameAsync(listName, includeCompleted: true)).ToList(); + if (olderThan.HasValue) + { + items = items.Where(item => + item.IsCompleted && item.Completed.HasValue && + item.Completed.Value < olderThan.Value + ) + .ToList(); + } + // Ask user which item to delete var message = PromptMessage + Environment.NewLine From 0b9569a5fdf930da0b30f72419d5639f41cb2bff Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Fri, 23 Feb 2024 00:35:30 +0100 Subject: [PATCH 8/8] Fix item completed date format string --- src/Todo.Core/Model/TodoItem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Todo.Core/Model/TodoItem.cs b/src/Todo.Core/Model/TodoItem.cs index c10ad19..d64fff9 100644 --- a/src/Todo.Core/Model/TodoItem.cs +++ b/src/Todo.Core/Model/TodoItem.cs @@ -9,6 +9,6 @@ public class TodoItem public DateTime? Completed { get; set; } public string? ListId { get; set; } - public override string ToString() => $"{Subject} - {Status} {(IsCompleted ? Completed?.ToString("yyyy-mm-dd") : string.Empty)}"; + public override string ToString() => $"{Subject} - {Status} {(IsCompleted ? Completed?.ToString("yyyy-MM-dd") : string.Empty)}"; public string ToString(bool noStatus) => noStatus ? Subject : ToString(); } \ No newline at end of file