From 61426c9a502029fb6349afc262ea596af46ad8b6 Mon Sep 17 00:00:00 2001 From: Rob Prouse Date: Sun, 29 Sep 2024 12:49:22 -0400 Subject: [PATCH 1/4] Checkpoint working on fetching task list from Google --- Guppi.Application/DependencyInjection.cs | 1 + .../Exceptions/ErrorException.cs | 7 ++ .../Exceptions/WarningException.cs | 7 +- Guppi.Application/Guppi.Application.csproj | 1 + Guppi.Application/Services/ITodoService.cs | 96 +++++++++++++++++++ Guppi.Console/Program.cs | 1 + Guppi.Console/Properties/launchSettings.json | 2 +- Guppi.Console/Skills/NotesSkill.cs | 3 +- Guppi.Console/Skills/TodoSkill.cs | 48 ++++++++++ 9 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 Guppi.Application/Exceptions/ErrorException.cs create mode 100644 Guppi.Application/Services/ITodoService.cs create mode 100644 Guppi.Console/Skills/TodoSkill.cs diff --git a/Guppi.Application/DependencyInjection.cs b/Guppi.Application/DependencyInjection.cs index b92edb2..e1f1345 100644 --- a/Guppi.Application/DependencyInjection.cs +++ b/Guppi.Application/DependencyInjection.cs @@ -17,6 +17,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services .AddTransient() .AddTransient() .AddTransient() + .AddSingleton() .AddTransient() .AddTransient(); } diff --git a/Guppi.Application/Exceptions/ErrorException.cs b/Guppi.Application/Exceptions/ErrorException.cs new file mode 100644 index 0000000..8ecff73 --- /dev/null +++ b/Guppi.Application/Exceptions/ErrorException.cs @@ -0,0 +1,7 @@ +using System; + +namespace Guppi.Application.Exceptions; + +public class ErrorException(string message) : Exception(message) +{ +} diff --git a/Guppi.Application/Exceptions/WarningException.cs b/Guppi.Application/Exceptions/WarningException.cs index dd3d5ae..e5442d2 100644 --- a/Guppi.Application/Exceptions/WarningException.cs +++ b/Guppi.Application/Exceptions/WarningException.cs @@ -1,8 +1,7 @@ using System; -namespace Guppi.Application.Exceptions +namespace Guppi.Application.Exceptions; + +public class WarningException(string message) : Exception(message) { - public class WarningException(string message) : Exception(message) - { - } } diff --git a/Guppi.Application/Guppi.Application.csproj b/Guppi.Application/Guppi.Application.csproj index d11822c..c822996 100644 --- a/Guppi.Application/Guppi.Application.csproj +++ b/Guppi.Application/Guppi.Application.csproj @@ -9,6 +9,7 @@ + diff --git a/Guppi.Application/Services/ITodoService.cs b/Guppi.Application/Services/ITodoService.cs new file mode 100644 index 0000000..ebfdfec --- /dev/null +++ b/Guppi.Application/Services/ITodoService.cs @@ -0,0 +1,96 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Util.Store; +using Guppi.Application.Exceptions; +using System.IO; +using System.Threading; +using Spectre.Console; +using Google.Apis.Tasks.v1; +using Google.Apis.Services; +using System.Threading.Tasks; +using System.Linq; + +using TaskList = Google.Apis.Tasks.v1.Data.TaskList; + +namespace Guppi.Application.Services; + +public interface ITodoService +{ + Task Sync(); +} + +public class TodoService : ITodoService +{ + static string[] Scopes = { TasksService.Scope.Tasks }; + static string ApplicationName = "Guppi ActionProvider.Tasks"; + static string TaskListName = "todo.txt"; + + public string Name => "Google Tasks"; + + TasksService _service; + + public async Task Sync() + { + LogIntoGoogle(); + + TaskList taskList = await GetTaskList(); + AnsiConsole.WriteLine($"Retrieved task list {taskList.Title}"); + + var tasks = await _service.Tasks.List(taskList.Id).ExecuteAsync(); + AnsiConsole.WriteLine($"{tasks.Items.Count} tasks retrieved from Google"); + } + + private void LogIntoGoogle() + { + string credentials = Configuration.GetConfigurationFile("task_credentials"); + if (!File.Exists(credentials)) + { + throw new UnconfiguredException("Please download the credentials. See the Readme."); + } + + UserCredential credential = null; + + using (var stream = new FileStream(credentials, FileMode.Open, FileAccess.Read)) + { + string token = Configuration.GetConfigurationFile("task_token"); + credential = GoogleWebAuthorizationBroker.AuthorizeAsync( + GoogleClientSecrets.FromStream(stream).Secrets, + Scopes, + "user", + CancellationToken.None, + new FileDataStore(token, true)).Result; + } + + if (credential is null) + { + throw new UnauthorizedException("Failed to login to Google Tasks"); + } + + _service = new TasksService(new BaseClientService.Initializer() + { + HttpClientInitializer = credential, + ApplicationName = ApplicationName, + }); + } + + private async Task GetTaskList() + { + TasklistsResource.ListRequest listRequest = _service.Tasklists.List(); + + var lists = await listRequest.ExecuteAsync(); + + var work = lists.Items.FirstOrDefault(x => x.Title == TaskListName); + + // If the list does not exist, create it + if (work is null) + { + TaskList newList = new TaskList { Title = TaskListName }; + work = await _service.Tasklists.Insert(newList).ExecuteAsync(); + } + + if (work is null) + { + throw new ErrorException("Failed to fetch or create task list"); + } + return work; + } +} diff --git a/Guppi.Console/Program.cs b/Guppi.Console/Program.cs index f2ae97a..01671be 100644 --- a/Guppi.Console/Program.cs +++ b/Guppi.Console/Program.cs @@ -24,6 +24,7 @@ static IServiceProvider ConfigureServices() => .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() diff --git a/Guppi.Console/Properties/launchSettings.json b/Guppi.Console/Properties/launchSettings.json index 38705d8..f2c8591 100644 --- a/Guppi.Console/Properties/launchSettings.json +++ b/Guppi.Console/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Guppi.Console": { "commandName": "Project", - "commandLineArgs": "cal agenda" + "commandLineArgs": "todo sync" } } } \ No newline at end of file diff --git a/Guppi.Console/Skills/NotesSkill.cs b/Guppi.Console/Skills/NotesSkill.cs index 9af850c..4f996f0 100644 --- a/Guppi.Console/Skills/NotesSkill.cs +++ b/Guppi.Console/Skills/NotesSkill.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.CommandLine; using System.CommandLine.NamingConventionBinder; -using System.Threading.Tasks; using Guppi.Application.Services; namespace Guppi.Console.Skills; @@ -43,7 +42,7 @@ public IEnumerable GetCommands() code, config }; - return new[] { notes }; + return [notes]; } private void Add(string title, string vault) => diff --git a/Guppi.Console/Skills/TodoSkill.cs b/Guppi.Console/Skills/TodoSkill.cs new file mode 100644 index 0000000..0a1c27d --- /dev/null +++ b/Guppi.Console/Skills/TodoSkill.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; +using System.Threading.Tasks; +using Guppi.Application.Exceptions; +using Guppi.Application.Services; +using Spectre.Console; + +namespace Guppi.Console.Skills; + +internal class TodoSkill(ITodoService service) : ISkill +{ + private readonly ITodoService _service = service; + + public IEnumerable GetCommands() + { + var sync = new Command("sync", "Syncs the todo list with Google Tasks.") + { + Handler = CommandHandler.Create(async () => await Sync()) + }; + + var todo = new Command("todo", "Works with the todo list") + { + sync + }; + return [todo]; + } + + private async Task Sync() + { + try + { + await _service.Sync(); + } + catch (ErrorException ee) + { + AnsiConsole.MarkupLine($"[red][[:cross_mark: ${ee.Message}]][/]"); + } + catch (UnauthorizedException ue) + { + AnsiConsole.MarkupLine($"[red][[:cross_mark: ${ue.Message}]][/]"); + } + catch (UnconfiguredException ue) + { + AnsiConsole.MarkupLine($"[yellow][[:yellow_circle: ${ue.Message}]][/]"); + } + } +} From 764e312f3718ed77160c001ef229a9f9caa8f5ac Mon Sep 17 00:00:00 2001 From: Rob Prouse Date: Sun, 29 Sep 2024 15:23:54 -0400 Subject: [PATCH 2/4] Integrate dotnet-todo and update project structure Added dotnet-todo submodule and updated DependencyInjection.cs to include its services. Introduced ColoredStringExtensions.cs for converting ColoredString objects. Refactored DateTimeExtensions.cs by moving time icon methods to TimeIconExtensions.cs and added GetRfc3339Date method. Updated Guppi.Application.csproj with references to todo projects. Moved ITodoService implementation to TodoService.cs. Incremented Guppi.Console.csproj version to 5.2.0. Updated Guppi.sln for Visual Studio 17 and added todo projects. Enhanced README.md with Todo.txt sync instructions and reorganized calendar configuration. --- .gitmodules | 3 + Guppi.Application/DependencyInjection.cs | 40 +++-- .../Extensions/ColoredStringExtensions.cs | 18 ++ .../Extensions/DateTimeExtensions.cs | 59 +------ .../Extensions/TimeIconExtensions.cs | 57 ++++++ Guppi.Application/Guppi.Application.csproj | 2 + Guppi.Application/Services/ITodoService.cs | 88 --------- Guppi.Application/Services/TodoService.cs | 167 ++++++++++++++++++ Guppi.Console/Guppi.Console.csproj | 2 +- Guppi.sln | 55 +++++- README.md | 23 +-- dotnet-todo | 1 + 12 files changed, 342 insertions(+), 173 deletions(-) create mode 100644 .gitmodules create mode 100644 Guppi.Application/Extensions/ColoredStringExtensions.cs create mode 100644 Guppi.Application/Extensions/TimeIconExtensions.cs create mode 100644 Guppi.Application/Services/TodoService.cs create mode 160000 dotnet-todo diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8bc8d5c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "dotnet-todo"] + path = dotnet-todo + url = git@github.com:rprouse/dotnet-todo.git diff --git a/Guppi.Application/DependencyInjection.cs b/Guppi.Application/DependencyInjection.cs index e1f1345..4003aba 100644 --- a/Guppi.Application/DependencyInjection.cs +++ b/Guppi.Application/DependencyInjection.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.IO; +using System; using Guppi.Application.Services; using Microsoft.Extensions.DependencyInjection; @@ -6,18 +7,27 @@ namespace Guppi.Application; public static class DependencyInjection { - public static IServiceCollection AddApplication(this IServiceCollection services) => services - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddSingleton() - .AddTransient() - .AddTransient(); + public static IServiceCollection AddApplication(this IServiceCollection services) + { + // Setup the Todo application's services + string configFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".todo.json"); + + Alteridem.Todo.Application.DependencyInjection.AddApplication(services); + Alteridem.Todo.Infrastructure.DependencyInjection.AddInfrastructure(services, configFile); + + return services + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddSingleton() + .AddTransient() + .AddTransient(); + } } diff --git a/Guppi.Application/Extensions/ColoredStringExtensions.cs b/Guppi.Application/Extensions/ColoredStringExtensions.cs new file mode 100644 index 0000000..fa9d8a7 --- /dev/null +++ b/Guppi.Application/Extensions/ColoredStringExtensions.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Linq; +using Alteridem.Todo.Domain.Common; +using ColoredConsole; + +namespace Guppi.Application.Extensions; + +public static class ColoredStringExtensions +{ + public static ColorToken ToColorToken(this ColoredString coloredString) => + new ColorToken(coloredString.Text, coloredString.Color, coloredString.BackgroundColor); + + public static ColorToken[] ToColorTokens(this IEnumerable coloredStrings) => + coloredStrings.Select(cs => cs.ToColorToken()).ToArray(); + + public static string ToPlainString(this IEnumerable coloredStrings) => + string.Join(null, coloredStrings.Select(c => c.Text)); +} diff --git a/Guppi.Application/Extensions/DateTimeExtensions.cs b/Guppi.Application/Extensions/DateTimeExtensions.cs index 105fff4..f3a92e9 100644 --- a/Guppi.Application/Extensions/DateTimeExtensions.cs +++ b/Guppi.Application/Extensions/DateTimeExtensions.cs @@ -1,58 +1,9 @@ using System; -using Spectre.Console; -namespace Guppi.Application.Extensions -{ - public static class TimeIconExtensions - { - private static string[] Clocks = new[] - { - Emoji.Known.TwelveOClock, - Emoji.Known.OneOClock, - Emoji.Known.TwoOClock, - Emoji.Known.ThreeOClock, - Emoji.Known.FourOClock, - Emoji.Known.FiveOClock, - Emoji.Known.SixOClock, - Emoji.Known.SevenOClock, - Emoji.Known.EightOClock, - Emoji.Known.NineOClock, - Emoji.Known.TenOClock, - Emoji.Known.ElevenOClock, - }; - - private static string[] HalfClocks = new[] - { - Emoji.Known.TwelveThirty, - Emoji.Known.OneThirty, - Emoji.Known.TwoThirty, - Emoji.Known.ThreeThirty, - Emoji.Known.FourThirty, - Emoji.Known.FiveThirty, - Emoji.Known.SixThirty, - Emoji.Known.SevenThirty, - Emoji.Known.EightThirty, - Emoji.Known.NineThirty, - Emoji.Known.TenThirty, - Emoji.Known.ElevenThirty, - }; +namespace Guppi.Application.Extensions; - /// - /// Gets the clock emoji that matches the given time - /// - /// - /// - public static string GetEmoji(this DateTime time) - { - int hour = time.Hour % 12; - return time.Minute < 30 ? Clocks[hour] : HalfClocks[hour]; - } - - /// Gets the clock emoji that matches the given time - /// - /// - /// - public static string GetEmoji(this DateTime? time) => - time is null ? Emoji.Known.TwelveOClock : time.Value.GetEmoji(); - } +public static class DataTimeExtensions +{ + public static DateTime GetRfc3339Date(this string date) => + DateTime.TryParse(date, out DateTime result) ? result : DateTime.Now; } diff --git a/Guppi.Application/Extensions/TimeIconExtensions.cs b/Guppi.Application/Extensions/TimeIconExtensions.cs new file mode 100644 index 0000000..33e2e5a --- /dev/null +++ b/Guppi.Application/Extensions/TimeIconExtensions.cs @@ -0,0 +1,57 @@ +using System; +using Spectre.Console; + +namespace Guppi.Application.Extensions; + +public static class TimeIconExtensions +{ + private static string[] Clocks = new[] + { + Emoji.Known.TwelveOClock, + Emoji.Known.OneOClock, + Emoji.Known.TwoOClock, + Emoji.Known.ThreeOClock, + Emoji.Known.FourOClock, + Emoji.Known.FiveOClock, + Emoji.Known.SixOClock, + Emoji.Known.SevenOClock, + Emoji.Known.EightOClock, + Emoji.Known.NineOClock, + Emoji.Known.TenOClock, + Emoji.Known.ElevenOClock, + }; + + private static string[] HalfClocks = new[] + { + Emoji.Known.TwelveThirty, + Emoji.Known.OneThirty, + Emoji.Known.TwoThirty, + Emoji.Known.ThreeThirty, + Emoji.Known.FourThirty, + Emoji.Known.FiveThirty, + Emoji.Known.SixThirty, + Emoji.Known.SevenThirty, + Emoji.Known.EightThirty, + Emoji.Known.NineThirty, + Emoji.Known.TenThirty, + Emoji.Known.ElevenThirty, + }; + + /// + /// Gets the clock emoji that matches the given time + /// + /// + /// + public static string GetEmoji(this DateTime time) + { + int hour = time.Hour % 12; + return time.Minute < 30 ? Clocks[hour] : HalfClocks[hour]; + } + + /// Gets the clock emoji that matches the given time + /// + /// + /// + public static string GetEmoji(this DateTime? time) => + time is null ? Emoji.Known.TwelveOClock : time.Value.GetEmoji(); +} diff --git a/Guppi.Application/Guppi.Application.csproj b/Guppi.Application/Guppi.Application.csproj index c822996..6a0f8c0 100644 --- a/Guppi.Application/Guppi.Application.csproj +++ b/Guppi.Application/Guppi.Application.csproj @@ -22,6 +22,8 @@ + + diff --git a/Guppi.Application/Services/ITodoService.cs b/Guppi.Application/Services/ITodoService.cs index ebfdfec..a4058b1 100644 --- a/Guppi.Application/Services/ITodoService.cs +++ b/Guppi.Application/Services/ITodoService.cs @@ -1,15 +1,4 @@ -using Google.Apis.Auth.OAuth2; -using Google.Apis.Util.Store; -using Guppi.Application.Exceptions; -using System.IO; -using System.Threading; -using Spectre.Console; -using Google.Apis.Tasks.v1; -using Google.Apis.Services; using System.Threading.Tasks; -using System.Linq; - -using TaskList = Google.Apis.Tasks.v1.Data.TaskList; namespace Guppi.Application.Services; @@ -17,80 +6,3 @@ public interface ITodoService { Task Sync(); } - -public class TodoService : ITodoService -{ - static string[] Scopes = { TasksService.Scope.Tasks }; - static string ApplicationName = "Guppi ActionProvider.Tasks"; - static string TaskListName = "todo.txt"; - - public string Name => "Google Tasks"; - - TasksService _service; - - public async Task Sync() - { - LogIntoGoogle(); - - TaskList taskList = await GetTaskList(); - AnsiConsole.WriteLine($"Retrieved task list {taskList.Title}"); - - var tasks = await _service.Tasks.List(taskList.Id).ExecuteAsync(); - AnsiConsole.WriteLine($"{tasks.Items.Count} tasks retrieved from Google"); - } - - private void LogIntoGoogle() - { - string credentials = Configuration.GetConfigurationFile("task_credentials"); - if (!File.Exists(credentials)) - { - throw new UnconfiguredException("Please download the credentials. See the Readme."); - } - - UserCredential credential = null; - - using (var stream = new FileStream(credentials, FileMode.Open, FileAccess.Read)) - { - string token = Configuration.GetConfigurationFile("task_token"); - credential = GoogleWebAuthorizationBroker.AuthorizeAsync( - GoogleClientSecrets.FromStream(stream).Secrets, - Scopes, - "user", - CancellationToken.None, - new FileDataStore(token, true)).Result; - } - - if (credential is null) - { - throw new UnauthorizedException("Failed to login to Google Tasks"); - } - - _service = new TasksService(new BaseClientService.Initializer() - { - HttpClientInitializer = credential, - ApplicationName = ApplicationName, - }); - } - - private async Task GetTaskList() - { - TasklistsResource.ListRequest listRequest = _service.Tasklists.List(); - - var lists = await listRequest.ExecuteAsync(); - - var work = lists.Items.FirstOrDefault(x => x.Title == TaskListName); - - // If the list does not exist, create it - if (work is null) - { - TaskList newList = new TaskList { Title = TaskListName }; - work = await _service.Tasklists.Insert(newList).ExecuteAsync(); - } - - if (work is null) - { - throw new ErrorException("Failed to fetch or create task list"); - } - return work; - } -} diff --git a/Guppi.Application/Services/TodoService.cs b/Guppi.Application/Services/TodoService.cs new file mode 100644 index 0000000..3b69d91 --- /dev/null +++ b/Guppi.Application/Services/TodoService.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Alteridem.Todo.Application.Commands; +using Alteridem.Todo.Application.Queries; +using Alteridem.Todo.Domain.Interfaces; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Services; +using Google.Apis.Tasks.v1; +using Google.Apis.Util.Store; +using Guppi.Application.Exceptions; +using Guppi.Application.Extensions; +using MediatR; +using Spectre.Console; +using GoogleTask = Google.Apis.Tasks.v1.Data.Task; +using GoogleTaskList = Google.Apis.Tasks.v1.Data.TaskList; + +namespace Guppi.Application.Services; + +public class TodoService(IMediator mediator, ITaskConfiguration configuration) : ITodoService +{ + static string[] Scopes = { TasksService.Scope.Tasks }; + static string ApplicationName = "Guppi ActionProvider.Tasks"; + + const string TaskListName = "todo.txt"; + const string IdTag = "id"; + const string UpdatedTag = "updated"; + + public string Name => "Google Tasks"; + + TasksService _service; + private IMediator Mediator { get; } = mediator; + private ITaskConfiguration Configuration { get; } = configuration; + + public async Task Sync() + { + LogIntoGoogle(); + + GoogleTaskList taskList = await GetTaskListFromGoogle(); + AnsiConsole.WriteLine($"Retrieved task list {taskList.Title}"); + + List googleTasks = new (); + string nextPageToken = null; + do + { + var request = _service.Tasks.List(taskList.Id); + request.MaxResults = 100; + request.PageToken = nextPageToken; + request.ShowCompleted = false; + var tasks = await request.ExecuteAsync(); + googleTasks.AddRange(tasks.Items); + nextPageToken = tasks.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + AnsiConsole.WriteLine($"{googleTasks.Count} tasks retrieved from Google"); + + var todo = (await GetTaskListFromTodoTxt(Configuration.TodoFile)).Tasks; + var done = (await GetTaskListFromTodoTxt(Configuration.DoneFile)).Tasks; + AnsiConsole.WriteLine($"{todo.Count} tasks retrieved from todo.txt"); + + // Local tasks without an ID are new and need to be added to Google + var notSynced = todo.Where(t => !t.SpecialTags.ContainsKey(IdTag)).ToList(); + foreach (var task in notSynced) + { + // Update on Google + var newTask = new Google.Apis.Tasks.v1.Data.Task { Title = task.Text }; + var result = await _service.Tasks.Insert(newTask, taskList.Id).ExecuteAsync(); + task.Description += $" {IdTag}:{result.Id} {UpdatedTag}:{result.Updated}"; + AnsiConsole.WriteLine($"Added task {task.LineNumber} to Google"); + + // Update locally + var replace = new ReplaceCommand { ItemNumber = task.LineNumber, Text = task.ToString() }; + await Mediator.Send(replace); + } + + // Tasks in Google that are in the done list need to be deleted from Google + var doneLocally = done.Where(t => t.SpecialTags.ContainsKey(IdTag)).ToList(); + foreach (var task in doneLocally.Where(t => googleTasks.Any(g => g.Id == t.SpecialTags[IdTag]))) + { + var gTask = googleTasks.First(g => g.Id == task.SpecialTags[IdTag]); + await _service.Tasks.Delete(taskList.Id, gTask.Id).ExecuteAsync(); + AnsiConsole.WriteLine($"Deleted task {task.SpecialTags[IdTag]} from Google"); + googleTasks.Remove(gTask); + } + + // Tasks in Google that are not in the local list need to be added to the local list + var notLocal = googleTasks.Where(t => string.IsNullOrEmpty(t.Completed) && !todo.Any(l => l.SpecialTags.ContainsKey(IdTag) && l.SpecialTags[IdTag] == t.Id)).ToList(); + foreach (var task in notLocal) + { + string taskStr = $"{task.Updated.GetRfc3339Date().ToString("yyyy-MM-dd")} {task.Title}"; + if (!string.IsNullOrEmpty(task.Due)) + { + taskStr += $" due:{task.Due}"; + } + taskStr += $" {IdTag}:{task.Id} {UpdatedTag}:{task.Updated}"; + var append = new AddTaskCommand { Filename = Configuration.TodoFile, Task = taskStr, AddCreationDate = false }; + await Mediator.Send(append); + AnsiConsole.WriteLine($"Added task {task.Id} to todo.txt"); + } + + // Local tasks that are newer than the Google tasks need to be updated in Google + + // Google tasks that are newer than the local tasks need to be updated in the local tasks + } + + private void LogIntoGoogle() + { + string credentials = Application.Configuration.GetConfigurationFile("task_credentials"); + if (!File.Exists(credentials)) + { + throw new UnconfiguredException("Please download the credentials. See the Readme."); + } + + UserCredential credential = null; + + using (var stream = new FileStream(credentials, FileMode.Open, FileAccess.Read)) + { + string token = Application.Configuration.GetConfigurationFile("task_token"); + credential = GoogleWebAuthorizationBroker.AuthorizeAsync( + GoogleClientSecrets.FromStream(stream).Secrets, + Scopes, + "user", + CancellationToken.None, + new FileDataStore(token, true)).Result; + } + + if (credential is null) + { + throw new UnauthorizedException("Failed to login to Google Tasks"); + } + + _service = new TasksService(new BaseClientService.Initializer() + { + HttpClientInitializer = credential, + ApplicationName = ApplicationName, + }); + } + + private async Task GetTaskListFromGoogle() + { + TasklistsResource.ListRequest listRequest = _service.Tasklists.List(); + + var lists = await listRequest.ExecuteAsync(); + + var work = lists.Items.FirstOrDefault(x => x.Title == TaskListName); + + // If the list does not exist, create it + if (work is null) + { + GoogleTaskList newList = new GoogleTaskList { Title = TaskListName }; + work = await _service.Tasklists.Insert(newList).ExecuteAsync(); + } + + if (work is null) + { + throw new ErrorException("Failed to fetch or create task list"); + } + return work; + } + + private async Task GetTaskListFromTodoTxt(string filename) + { + var query = new ListTasksQuery { Filename = filename, Terms = [] }; + return await Mediator.Send(query); + } +} diff --git a/Guppi.Console/Guppi.Console.csproj b/Guppi.Console/Guppi.Console.csproj index 5f0b1c7..a081fa7 100644 --- a/Guppi.Console/Guppi.Console.csproj +++ b/Guppi.Console/Guppi.Console.csproj @@ -13,7 +13,7 @@ https://github.com/rprouse/guppi https://github.com/rprouse/guppi dotnet-guppi - 5.1.1 + 5.2.0 true guppi ./nupkg diff --git a/Guppi.sln b/Guppi.sln index c90ceb3..18d75cd 100644 --- a/Guppi.sln +++ b/Guppi.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30002.166 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35312.102 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guppi.Console", "Guppi.Console\Guppi.Console.csproj", "{D6F1B003-9CED-41B4-843A-39B0364A33AC}" EndProject @@ -26,9 +26,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\continuous_integration.yml = .github\workflows\continuous_integration.yml EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Guppi.Domain", "Guppi.Domain\Guppi.Domain.csproj", "{90BCB62A-B6E4-4A53-A116-4C1D714A2B84}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guppi.Domain", "Guppi.Domain\Guppi.Domain.csproj", "{90BCB62A-B6E4-4A53-A116-4C1D714A2B84}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Guppi.Infrastructure", "Guppi.Infrastructure\Guppi.Infrastructure.csproj", "{CC52D05B-B0C7-49C4-9E8E-95E34FF320AA}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Guppi.Infrastructure", "Guppi.Infrastructure\Guppi.Infrastructure.csproj", "{CC52D05B-B0C7-49C4-9E8E-95E34FF320AA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "todo.infrastructure", "dotnet-todo\src\todo.infrastructure\todo.infrastructure.csproj", "{9877E299-A30B-4D43-BF39-BC25531900E4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "todo.application", "dotnet-todo\src\todo.application\todo.application.csproj", "{DFA78AF6-E861-484B-AC06-8A6F14D1ED28}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "todo.domain", "dotnet-todo\src\todo.domain\todo.domain.csproj", "{7CE715B0-D04E-466F-A80E-5137FDA94DE4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet-todo", "dotnet-todo", "{907A9A53-E207-4775-AB6B-5E4696B18819}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -100,12 +108,51 @@ Global {CC52D05B-B0C7-49C4-9E8E-95E34FF320AA}.Release|x64.Build.0 = Release|Any CPU {CC52D05B-B0C7-49C4-9E8E-95E34FF320AA}.Release|x86.ActiveCfg = Release|Any CPU {CC52D05B-B0C7-49C4-9E8E-95E34FF320AA}.Release|x86.Build.0 = Release|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Debug|x64.Build.0 = Debug|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Debug|x86.Build.0 = Debug|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Release|Any CPU.Build.0 = Release|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Release|x64.ActiveCfg = Release|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Release|x64.Build.0 = Release|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Release|x86.ActiveCfg = Release|Any CPU + {9877E299-A30B-4D43-BF39-BC25531900E4}.Release|x86.Build.0 = Release|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Debug|x64.ActiveCfg = Debug|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Debug|x64.Build.0 = Debug|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Debug|x86.ActiveCfg = Debug|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Debug|x86.Build.0 = Debug|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Release|Any CPU.Build.0 = Release|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Release|x64.ActiveCfg = Release|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Release|x64.Build.0 = Release|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Release|x86.ActiveCfg = Release|Any CPU + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28}.Release|x86.Build.0 = Release|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Debug|x64.ActiveCfg = Debug|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Debug|x64.Build.0 = Debug|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Debug|x86.ActiveCfg = Debug|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Debug|x86.Build.0 = Debug|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Release|Any CPU.Build.0 = Release|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Release|x64.ActiveCfg = Release|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Release|x64.Build.0 = Release|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Release|x86.ActiveCfg = Release|Any CPU + {7CE715B0-D04E-466F-A80E-5137FDA94DE4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {E2148457-6DD5-42A3-89F9-ECBDCB37211A} = {7B1C4A11-86DC-4D68-8BF3-F0E54A202603} + {9877E299-A30B-4D43-BF39-BC25531900E4} = {907A9A53-E207-4775-AB6B-5E4696B18819} + {DFA78AF6-E861-484B-AC06-8A6F14D1ED28} = {907A9A53-E207-4775-AB6B-5E4696B18819} + {7CE715B0-D04E-466F-A80E-5137FDA94DE4} = {907A9A53-E207-4775-AB6B-5E4696B18819} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3594B806-A913-431A-AA3D-1E58D7578451} diff --git a/README.md b/README.md index 8514ab4..9a438b6 100644 --- a/README.md +++ b/README.md @@ -25,24 +25,25 @@ To configure: 4. In the browser dev tools, copy the session cookie minus the `session=` 5. Set the `src` directory for the Visual Studio AoC solution -### Calendar +### Sync Todo.txt to Google Tasks -Displays your next calendar event or today's agenda from Google Calendar and Office 365. Right -now it gets both and both must be configured. +Syncs the [Todo.txt](https://github.com/rprouse/dotnet-todo) tasks to/from Google Tasks. -#### Google Calendar - -To get the information to configure: +To get the information to configure, follow the instructions at: -1. Sign in to the [Azure portal](https://portal.azure.com/) -2. In the left-hand navigation pane, select the `Azure Active Directory` service, and then select `Add application registration` -3. Set a name and appropriate account types, then set the redirect URL to native with the url `http://localhost:39428` -3. Download client configuration and save to `C:\Users\rob\AppData\Local\Guppi\calendar_credentials.json` +1. [Enable the Google Tasks API](https://developers.google.com/tasks/reference/rest) +2. Configure the API as a Desktop App +3. Download client configuration and save to `C:\Users\rob\AppData\Local\Guppi\task_credentials.json` 4. Run and log in using OAuth2. To check your API information, see the [API Console](https://console.developers.google.com/). -#### Office 365 +### Calendar + +Displays your next calendar event or today's agenda from Google Calendar and Office 365. Right +now it gets both and both must be configured. + +#### Google Calendar To get the information to configure, follow the instructions at: diff --git a/dotnet-todo b/dotnet-todo new file mode 160000 index 0000000..66c8351 --- /dev/null +++ b/dotnet-todo @@ -0,0 +1 @@ +Subproject commit 66c835157a2518fecdeeba07d957ce3e40d49b79 From 6a15b08267ea7ad716c5ff24de0d67688e818c63 Mon Sep 17 00:00:00 2001 From: Rob Prouse Date: Sun, 29 Sep 2024 15:29:54 -0400 Subject: [PATCH 3/4] Enhance CI workflow to handle submodules and full history Updated the `Checkout Code` step in the CI workflow to include: - `submodules: true` for fetching and checking out submodules. - `fetch-depth: 0` to ensure the full commit history is fetched. These changes improve build and dependency management for projects using submodules. --- .github/workflows/continuous_integration.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 842aca7..0301db1 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -1,4 +1,4 @@ -name: Continuous Integration +name: Continuous Integration on: push: @@ -13,6 +13,9 @@ jobs: steps: - name: 📥 Checkout Code uses: actions/checkout@v3 + with: + submodules: true # Fetch and checkout submodules + fetch-depth: 0 # Ensure the full history is fetched, useful when dealing with submodules - name: 💉 Install dependencies run: dotnet restore From 8095ae9a4c0b29ed1509fa5ce2274662e8b554c9 Mon Sep 17 00:00:00 2001 From: Rob Prouse Date: Sun, 29 Sep 2024 15:40:57 -0400 Subject: [PATCH 4/4] Coderabbit suggestions --- .../Extensions/DateTimeExtensions.cs | 6 ++--- .../Extensions/TimeIconExtensions.cs | 14 ++++++------ Guppi.Application/Services/TodoService.cs | 22 ++++++++++++------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/Guppi.Application/Extensions/DateTimeExtensions.cs b/Guppi.Application/Extensions/DateTimeExtensions.cs index f3a92e9..7b17ba7 100644 --- a/Guppi.Application/Extensions/DateTimeExtensions.cs +++ b/Guppi.Application/Extensions/DateTimeExtensions.cs @@ -2,8 +2,8 @@ namespace Guppi.Application.Extensions; -public static class DataTimeExtensions +public static class DateTimeExtensions { - public static DateTime GetRfc3339Date(this string date) => - DateTime.TryParse(date, out DateTime result) ? result : DateTime.Now; + public static DateTimeOffset GetRfc3339Date(this string date) => + DateTimeOffset.TryParse(date, out DateTimeOffset result) ? result : DateTimeOffset.Now; } diff --git a/Guppi.Application/Extensions/TimeIconExtensions.cs b/Guppi.Application/Extensions/TimeIconExtensions.cs index 33e2e5a..5148d82 100644 --- a/Guppi.Application/Extensions/TimeIconExtensions.cs +++ b/Guppi.Application/Extensions/TimeIconExtensions.cs @@ -1,12 +1,12 @@ -using System; +using System; using Spectre.Console; namespace Guppi.Application.Extensions; public static class TimeIconExtensions { - private static string[] Clocks = new[] - { + private static readonly string[] Clocks = + [ Emoji.Known.TwelveOClock, Emoji.Known.OneOClock, Emoji.Known.TwoOClock, @@ -19,10 +19,10 @@ public static class TimeIconExtensions Emoji.Known.NineOClock, Emoji.Known.TenOClock, Emoji.Known.ElevenOClock, - }; + ]; - private static string[] HalfClocks = new[] - { + private static readonly string[] HalfClocks = + [ Emoji.Known.TwelveThirty, Emoji.Known.OneThirty, Emoji.Known.TwoThirty, @@ -35,7 +35,7 @@ public static class TimeIconExtensions Emoji.Known.NineThirty, Emoji.Known.TenThirty, Emoji.Known.ElevenThirty, - }; + ]; /// /// Gets the clock emoji that matches the given time diff --git a/Guppi.Application/Services/TodoService.cs b/Guppi.Application/Services/TodoService.cs index 3b69d91..c0f7289 100644 --- a/Guppi.Application/Services/TodoService.cs +++ b/Guppi.Application/Services/TodoService.cs @@ -36,7 +36,7 @@ public class TodoService(IMediator mediator, ITaskConfiguration configuration) : public async Task Sync() { - LogIntoGoogle(); + await LogIntoGoogle(); GoogleTaskList taskList = await GetTaskListFromGoogle(); AnsiConsole.WriteLine($"Retrieved task list {taskList.Title}"); @@ -50,7 +50,10 @@ public async Task Sync() request.PageToken = nextPageToken; request.ShowCompleted = false; var tasks = await request.ExecuteAsync(); - googleTasks.AddRange(tasks.Items); + if (tasks.Items is not null) + { + googleTasks.AddRange(tasks.Items); + } nextPageToken = tasks.NextPageToken; } while (!string.IsNullOrEmpty(nextPageToken)); AnsiConsole.WriteLine($"{googleTasks.Count} tasks retrieved from Google"); @@ -85,7 +88,10 @@ public async Task Sync() } // Tasks in Google that are not in the local list need to be added to the local list - var notLocal = googleTasks.Where(t => string.IsNullOrEmpty(t.Completed) && !todo.Any(l => l.SpecialTags.ContainsKey(IdTag) && l.SpecialTags[IdTag] == t.Id)).ToList(); + var notLocal = googleTasks + .Where(t => string.IsNullOrEmpty(t.Completed)) + .Where(t => !todo.Any(l => l.SpecialTags.ContainsKey(IdTag) && l.SpecialTags[IdTag] == t.Id)) + .ToList(); foreach (var task in notLocal) { string taskStr = $"{task.Updated.GetRfc3339Date().ToString("yyyy-MM-dd")} {task.Title}"; @@ -104,7 +110,7 @@ public async Task Sync() // Google tasks that are newer than the local tasks need to be updated in the local tasks } - private void LogIntoGoogle() + private async Task LogIntoGoogle() { string credentials = Application.Configuration.GetConfigurationFile("task_credentials"); if (!File.Exists(credentials)) @@ -117,12 +123,12 @@ private void LogIntoGoogle() using (var stream = new FileStream(credentials, FileMode.Open, FileAccess.Read)) { string token = Application.Configuration.GetConfigurationFile("task_token"); - credential = GoogleWebAuthorizationBroker.AuthorizeAsync( - GoogleClientSecrets.FromStream(stream).Secrets, + credential = await GoogleWebAuthorizationBroker.AuthorizeAsync( + (await GoogleClientSecrets.FromStreamAsync(stream)).Secrets, Scopes, "user", CancellationToken.None, - new FileDataStore(token, true)).Result; + new FileDataStore(token, true)); } if (credential is null) @@ -143,7 +149,7 @@ private async Task GetTaskListFromGoogle() var lists = await listRequest.ExecuteAsync(); - var work = lists.Items.FirstOrDefault(x => x.Title == TaskListName); + var work = lists.Items?.FirstOrDefault(x => x.Title == TaskListName); // If the list does not exist, create it if (work is null)