From fc04fb08aa785971da72048ec7983144b01a7431 Mon Sep 17 00:00:00 2001 From: Marlon Regenhardt Date: Tue, 20 Feb 2024 22:18:25 +0100 Subject: [PATCH] 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