diff --git a/CalendarSync.Console/Options.cs b/CalendarSync.Console/Options.cs deleted file mode 100644 index 13fcca0..0000000 --- a/CalendarSync.Console/Options.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CommandLine; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace CalendarSync.Console -{ - public class Options - { - [Option('s', "secondaryAccountRefreshToken", Required = true, HelpText = "Refresh token for seondary account")] - public string SecondaryAccountRefreshToken { get; set; } - - [Option('x', "secondaryAccountSubjectPrefix", Required = true, HelpText = "Subject prefix for secondary account")] - public string SecondaryAccountSubjectPrefix { get; set; } - - [Option('p', "primaryAccountRefreshToken", Required = true, HelpText = "Refresh token for primary account")] - public string PrimaryAccountRefreshToken { get; set; } - - [Option('y', "primaryAccountSubjectPrefix", Required = true, HelpText = "Subject prefix primary account")] - public string PrimaryAccountSubjectPrefix { get; set; } - - [Option('c', "clientId", Required = true, HelpText = "Trusted ClientId")] - public string ClientId { get; set; } - - [Option('o', "orgConnectionString", Required = true, HelpText = "Connection string to a dev dataverse org")] - public string OrgConnectionString { get; set; } - - [Option('d', "daysToSync", Required = false, HelpText = "Default number of days into the future to sync")] - public uint DaysToSync { get; set; } - } -} diff --git a/CalendarSync.Console/Program.cs b/CalendarSync.Console/Program.cs deleted file mode 100644 index 5d44e1b..0000000 --- a/CalendarSync.Console/Program.cs +++ /dev/null @@ -1,70 +0,0 @@ -using CalendarSync; -using CalendarSync.Console; -using CommandLine; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Win32.TaskScheduler; - -const string DailyTaskName = "SyncCalendar"; -var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .Build(); - -var host = Host.CreateDefaultBuilder().Build(); -var logger = host.Services.GetRequiredService>(); -logger.LogInformation(@" - BE NOT AFRAID - Your calendars will begin to sync - DO NOT close this window until you see the final logging message. -"); - -var primaryAccountRefreshToken = configuration["PrimaryAccountRefreshToken"]; -var primaryAccountSubjectPrefix = configuration["PrimaryAccountSubjectPrefix"]; -var secondaryAccountRefreshToken = configuration["SecondaryAccountRefreshToken "]; -var secondaryAccountSubjectPrefix = configuration["SecondaryAccountSubjectPrefix"]; -var clientId = configuration["ClientId"]; -var orgConnectionString = configuration["OrgConnectionString "]; -var daysToSync = uint.Parse(configuration["DaysToSync"]); - -try -{ - using TaskService ts = new TaskService(); - var calendarSyncTask = ts.GetTask(DailyTaskName); - if (calendarSyncTask == null) - { - TaskDefinition td = ts.NewTask(); - td.RegistrationInfo.Description = "Every day at 9 am sync calendars"; - - DailyTrigger trigger = new() - { - StartBoundary = DateTime.Today + new TimeSpan(10, 45, 0), - DaysInterval = 1 - }; - td.Triggers.Add(trigger); - - var dir = $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}/CalendarSync"; - - td.Actions.Add(new ExecAction($"{dir}/CalendarSync.Console.exe", dir, null)); - - ts.RootFolder.RegisterTaskDefinition(DailyTaskName, td); - } - - var source = new SecondaryAccToPrimaryAccProfile(secondaryAccountRefreshToken, secondaryAccountSubjectPrefix); - var dest = new PrimaryAccToSecondaryAccProfile(primaryAccountRefreshToken, primaryAccountSubjectPrefix); - var service = new CalendarSyncService(dest, source, logger, clientId, orgConnectionString); - var startTime = DateTime.UtcNow; - var endTime = startTime.AddDays(daysToSync); - - await service.SyncRangeBidirectionalAsync(startTime.ToString("O"), endTime.ToString("O")); - - logger.LogInformation(@" - Calendar syncing complete! - Go forth about your day and be productive. - "); -} -catch (Exception e) -{ - Console.WriteLine(e.ToString()); -} diff --git a/CalendarSync/AuthService.cs b/CalendarSync/AuthService.cs deleted file mode 100644 index 5520568..0000000 --- a/CalendarSync/AuthService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json; - -namespace CalendarSync -{ - public class AuthService - { - private readonly string ClientId = ""; - private HttpClient Client { get; set; } = new HttpClient(); - public AuthService(string clientId) - { - ClientId = clientId; - } - - public async Task GetToken(string refreshToken) - { - var values = new Dictionary() - { - ["client_id"] = ClientId, - ["grant_type"] = "refresh_token", - ["scope"] = "offline_access Calendars.ReadWrite", - ["refresh_token"] = refreshToken, - }; - var body = new FormUrlEncodedContent(values); - - using var response = await Client.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", body); - - var resp = await response.Content.ReadAsStringAsync(); - using var stream = await response.Content.ReadAsStreamAsync(); - response.EnsureSuccessStatusCode(); - var responseBody = await JsonSerializer.DeserializeAsync(stream); - return responseBody; - } - } -} \ No newline at end of file diff --git a/CalendarSync/CalendarSyncService.cs b/CalendarSync/CalendarSyncService.cs deleted file mode 100644 index 5e026a5..0000000 --- a/CalendarSync/CalendarSyncService.cs +++ /dev/null @@ -1,115 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Graph; - -namespace CalendarSync -{ - public class CalendarSyncService - { - private GraphService SourceGraph { get; set; } - private GraphService DestinationGraph { get; set; } - private DataverseService Dataverse { get; } - private ILogger Log { get; set; } - private SyncProfile SourceProfile { get; } - private SyncProfile DestinationProfile { get; } - private readonly string ClientId = ""; - private readonly string OrgConnectionString = ""; - - public CalendarSyncService(SyncProfile sourceProfile, SyncProfile destProfile, ILogger logger, string clientId, string orgConnectionString) - { - SourceProfile = sourceProfile; - DestinationProfile = destProfile; - OrgConnectionString = orgConnectionString; - Dataverse = new DataverseService(logger, OrgConnectionString); - Log = logger; - ClientId = clientId; - } - - private async Task InitGraphClientsAsync() - { - var auth = new AuthService(ClientId); - if (SourceGraph == null) - { - var response = await auth.GetToken(SourceProfile.RefreshToken); - SourceGraph = new GraphService(response.AccessToken); - } - if (DestinationGraph == null) - { - var response = await auth.GetToken(DestinationProfile.RefreshToken); - DestinationGraph = new GraphService(response.AccessToken); - } - } - - public async Task SyncRangeBidirectionalAsync(string start, string end) - { - await SyncRangeAsync(start, end); - var reverseClient = new CalendarSyncService(DestinationProfile, SourceProfile, Log, ClientId, OrgConnectionString); - await reverseClient.SyncRangeAsync(start, end); - } - - public async Task SyncRangeAsync(string start, string end) - { - Log.LogInformation($"Syncing from {start} to {end}."); - await InitGraphClientsAsync(); - - // sync changes from source to dest - var events = await SourceGraph.GetEventsInRangeAsync(start, end); - var destinationIds = new HashSet(); - - foreach (var e in events) - { - var destKey = await SyncEventAsync(e); - if (destKey != null) - { - destinationIds.Add(destKey); - } - } - - // delete mapped events in dest that were removed in source - events = await DestinationGraph.GetEventsInRangeAsync(start, end); - var eventsToDelete = events.Where(e => e.Subject.StartsWith(SourceProfile.SubjectPrefix) && !destinationIds.Contains(e.Id)); - foreach (var e in eventsToDelete) - { - await Dataverse.DeleteEventAsync(e.Id); - await DestinationGraph.DeleteEventAsync(e.Id); - } - } - - public async Task SyncEventAsync(Event e) - { - if (e.Subject.StartsWith(DestinationProfile.SubjectPrefix)) - { - Log.LogInformation("Skipping event that originated in destination calendar."); - return null; - } - - // get or create event in d365 - var record = await Dataverse.GetOrCreateEventAsync(e, SourceProfile.SubjectPrefix); - - // map src event to dest event - var mappedEvent = SourceProfile.MapEvent(e); - var destEvent = record.DestinationKey != null ? await DestinationGraph.GetEventByIdAsync(record.DestinationKey) : null; - - if (destEvent == null) - { - // create in dest - var destKey = await DestinationGraph.CreateEventAsync(mappedEvent); - - // update dest key in d365 - await Dataverse.UpdateDestKeyAsync(record.RecordId, destKey); - - // update these stats in case event was deleted - await Dataverse.UpdateEventTimeAsync(record.RecordId, e, SourceProfile.SubjectPrefix); - return destKey; - } - else - { - // get event from dest - var graphEvent = await DestinationGraph.GetEventByIdAsync(record.DestinationKey); - mappedEvent.Id = graphEvent.Id; - await DestinationGraph.UpdateEventAsync(mappedEvent); - await Dataverse.UpdateEventTimeAsync(record.RecordId, e, SourceProfile.SubjectPrefix); - return graphEvent.Id; - } - } - } -} diff --git a/CalendarSync/DataverseService.cs b/CalendarSync/DataverseService.cs deleted file mode 100644 index 0ad2056..0000000 --- a/CalendarSync/DataverseService.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Graph; -using Microsoft.Graph.Extensions; -using Microsoft.PowerPlatform.Dataverse.Client; -using Microsoft.Xrm.Sdk.Query; -using Entity = Microsoft.Xrm.Sdk.Entity; - -namespace CalendarSync -{ - public class DataverseService - { - private ServiceClient Client { get; set; } - private ILogger Log { get; set; } - - public DataverseService(ILogger logger, string orgConnectionString) - { - Client = new ServiceClient(orgConnectionString); - Log = logger; - } - - public async Task GetOrCreateEventAsync(Event e, string sourceName) - { - Log.LogInformation($"Querying Dataverse for calendar event {e.Id}"); - - var query = new QueryByAttribute("pl_calendarevent"); - query.AddAttributeValue("pl_sourcekey", e.Id); - query.ColumnSet = new ColumnSet("pl_calendareventid", "pl_destinationkey"); - query.TopCount = 1; - var response = await Client.RetrieveMultipleAsync(query); - if (response.Entities.Any()) - { - Log.LogInformation("Found a matching calendar event in Dataverse."); - return new DataverseEventResponse(response.Entities.First()); - } - else - { - var create = new Entity("pl_calendarevent") - { - ["pl_sourcekey"] = e.Id, - ["pl_name"] = $"{sourceName} {e.Subject}", - ["pl_start"] = e.Start.ToDateTime(), - ["pl_end"] = e.End.ToDateTime(), - }; - create.Id = await Client.CreateAsync(create); - Log.LogInformation("Created a new calendar event in Dataverse."); - return new DataverseEventResponse(create); - } - } - - public async Task UpdateDestKeyAsync(Guid id, string destinationKey) - { - await Client.UpdateAsync(new Entity("pl_calendarevent", id) - { - ["pl_destinationkey"] = destinationKey, - }); - Log.LogInformation("Updated destination key in Dataverse."); - } - - public async Task UpdateEventTimeAsync(Guid id, Event e, string sourceName) - { - await Client.UpdateAsync(new Entity("pl_calendarevent", id) - { - ["pl_name"] = $"{sourceName} {e.Subject}", - ["pl_start"] = e.Start.ToDateTime(), - ["pl_end"] = e.End.ToDateTime() - }); - Log.LogInformation("Updated event time in Dataverse."); - } - - public async Task DeleteEventAsync(string destinationId) - { - var query = new QueryExpression("pl_calendarevent"); - query.ColumnSet = new ColumnSet("pl_calendareventid"); - query.TopCount = 1; - query.Criteria.AddCondition("pl_destinationkey", ConditionOperator.Equal, destinationId); - var response = await Client.RetrieveMultipleAsync(query); - if (!response.Entities.Any()) - { - return; - } - - var e = response.Entities.First(); - await Client.DeleteAsync(e.LogicalName, e.Id); - - Log.LogInformation("Deleted calendar event in Dataverse."); - } - } - - public class DataverseEventResponse - { - internal Guid RecordId { get; set; } - internal string? DestinationKey { get; set; } - internal DataverseEventResponse(Entity calendarEvent) - { - RecordId = calendarEvent.Id; - DestinationKey = calendarEvent.GetAttributeValue("pl_destinationkey"); - } - } -} diff --git a/CalendarSync/PrimaryAccToSecondaryAccProfile.cs b/CalendarSync/PrimaryAccToSecondaryAccProfile.cs deleted file mode 100644 index 94150d3..0000000 --- a/CalendarSync/PrimaryAccToSecondaryAccProfile.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.Graph; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace CalendarSync -{ - public class PrimaryAccToSecondaryAccProfile : SyncProfile - { - public PrimaryAccToSecondaryAccProfile(string refreshToken, string subjectPrefix) - { - RefreshToken = refreshToken; - SubjectPrefix = subjectPrefix; - } - - public override Event MapEvent(Event e) - { - var mapped = new Event - { - Subject = SubjectPrefix, - Start = e.Start, - End = e.End, - IsAllDay = e.IsAllDay, - ShowAs = e.ShowAs, - Importance = e.Importance, - Sensitivity = e.Sensitivity, - }; - - return mapped; - } - } -} diff --git a/CalendarSync/SecondaryAccToPrimaryAccProfile.cs b/CalendarSync/SecondaryAccToPrimaryAccProfile.cs deleted file mode 100644 index b8567a3..0000000 --- a/CalendarSync/SecondaryAccToPrimaryAccProfile.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Graph; - -namespace CalendarSync -{ - public class SecondaryAccToPrimaryAccProfile : SyncProfile - { - public SecondaryAccToPrimaryAccProfile(string refreshToken, string subjectPrefix) - { - RefreshToken = refreshToken; - SubjectPrefix = subjectPrefix; - } - - public override Event MapEvent(Event e) - { - var mapped = new Event - { - Subject = $"{SubjectPrefix} {e.Subject}", - Start = e.Start, - End = e.End, - IsAllDay = e.IsAllDay, - ShowAs = e.ShowAs, - BodyPreview = e.BodyPreview, - Importance = e.Importance, - Sensitivity = e.Sensitivity, - }; - - return mapped; - } - } -} diff --git a/CalendarSync.Console/CalendarSync.Console.csproj b/Calendula.Console/Calendula.Console.csproj similarity index 89% rename from CalendarSync.Console/CalendarSync.Console.csproj rename to Calendula.Console/Calendula.Console.csproj index 0bb9ba5..e8d8a80 100644 --- a/CalendarSync.Console/CalendarSync.Console.csproj +++ b/Calendula.Console/Calendula.Console.csproj @@ -6,6 +6,7 @@ enable enable a349f04a-d434-4e2e-a9be-60f4f86cc87e + true  @@ -19,7 +20,7 @@ - + diff --git a/CalendarSync.Console/LocalFileService.cs b/Calendula.Console/LocalFileService.cs similarity index 95% rename from CalendarSync.Console/LocalFileService.cs rename to Calendula.Console/LocalFileService.cs index a199d5e..2733c94 100644 --- a/CalendarSync.Console/LocalFileService.cs +++ b/Calendula.Console/LocalFileService.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace CalendarSync.Console +namespace Calendula.Console { internal class LocalFileService { diff --git a/Calendula.Console/Options.cs b/Calendula.Console/Options.cs new file mode 100644 index 0000000..83ef02c --- /dev/null +++ b/Calendula.Console/Options.cs @@ -0,0 +1,33 @@ +using CommandLine; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Calendula.Console +{ + public class Options + { + [Option('s', "secondaryAccountRefreshToken", Required = true, HelpText = "Refresh token for seondary account")] + public string SecondaryAccountRefreshToken { get; set; } + + [Option('x', "secondaryAccountSubjectPrefix", Required = true, HelpText = "Subject prefix for secondary account")] + public string SecondaryAccountSubjectPrefix { get; set; } + + [Option('p', "primaryAccountRefreshToken", Required = true, HelpText = "Refresh token for primary account")] + public string PrimaryAccountRefreshToken { get; set; } + + [Option('y', "primaryAccountSubjectPrefix", Required = true, HelpText = "Subject prefix primary account")] + public string PrimaryAccountSubjectPrefix { get; set; } + + [Option('c', "clientId", Required = true, HelpText = "Trusted ClientId")] + public string ClientId { get; set; } + + [Option('o', "orgConnectionString", Required = true, HelpText = "Connection string to a dev dataverse org")] + public string OrgConnectionString { get; set; } + + [Option('d', "daysToSync", Required = false, HelpText = "Default number of days into the future to sync")] + public uint DaysToSync { get; set; } + } +} diff --git a/Calendula.Console/Program.cs b/Calendula.Console/Program.cs new file mode 100644 index 0000000..d650afd --- /dev/null +++ b/Calendula.Console/Program.cs @@ -0,0 +1,74 @@ +using Calendula; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Win32.TaskScheduler; + +const string DailyTaskName = "Calendula"; +var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); var host = Host.CreateDefaultBuilder().Build(); + +var logger = host.Services.GetRequiredService>(); + +logger.LogInformation(@" +            BE NOT AFRAID +            Your calendars will begin to sync +            DO NOT close this window until you see the final logging message. +"); + +var primaryAccountRefreshToken = configuration["PrimaryAccountRefreshToken"]; +var primaryAccountSubjectPrefix = configuration["PrimaryAccountSubjectPrefix"]; +var secondaryAccountRefreshToken = configuration["SecondaryAccountRefreshToken"]; +var secondaryAccountSubjectPrefix = configuration["SecondaryAccountSubjectPrefix"]; +var hour24Time = int.Parse(configuration["Hour24Time"]); +var minute24Time = int.Parse(configuration["Minute24Time"]); +var clientId = configuration["ClientId"]; +var orgConnectionString = configuration["OrgConnectionString"]; +var daysToSync = uint.Parse(configuration["DaysToSync"]); + +try +{ + using TaskService ts = new TaskService(); + var CalendulaTask = ts.GetTask(DailyTaskName); + + if (CalendulaTask != null) + { + ts.RootFolder.DeleteTask(DailyTaskName); + } + + TaskDefinition td = ts.NewTask(); + + td.RegistrationInfo.Description = "Every day at 9 am sync calendars"; DailyTrigger trigger = new() + { + StartBoundary = DateTime.Today + new TimeSpan(hour24Time, minute24Time, 0), + DaysInterval = 1 + }; + + var dir = $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}/Calendula"; + + td.Triggers.Add(trigger); + td.Actions.Add(new ExecAction($"{dir}/Calendula.Console.exe", dir, null)); + ts.RootFolder.RegisterTaskDefinition(DailyTaskName, td); + + var source = new SecondaryAccToPrimaryAccProfile(secondaryAccountRefreshToken, secondaryAccountSubjectPrefix); + var dest = new PrimaryAccToSecondaryAccProfile(primaryAccountRefreshToken, primaryAccountSubjectPrefix); + var service = new CalendulaService(dest, source, logger, clientId, orgConnectionString); + var startTime = DateTime.UtcNow; + var endTime = startTime.AddDays(daysToSync); + await service.SyncRangeBidirectionalAsync(startTime.ToString("O"), endTime.ToString("O")); + + logger.LogInformation(@" +            Calendar syncing complete!  +            Go forth about your day and be productive. +    "); +} +catch (Exception e) +{ + Console.WriteLine(e.ToString()); +} +finally +{ + Console.ReadKey(); +} diff --git a/CalendarSync.Console/Properties/PublishProfiles/FolderProfile.pubxml b/Calendula.Console/Properties/PublishProfiles/FolderProfile.pubxml similarity index 100% rename from CalendarSync.Console/Properties/PublishProfiles/FolderProfile.pubxml rename to Calendula.Console/Properties/PublishProfiles/FolderProfile.pubxml diff --git a/CalendarSync.Console/Properties/launchSettings.json b/Calendula.Console/Properties/launchSettings.json similarity index 90% rename from CalendarSync.Console/Properties/launchSettings.json rename to Calendula.Console/Properties/launchSettings.json index 0a9bb0a..4d6b709 100644 --- a/CalendarSync.Console/Properties/launchSettings.json +++ b/Calendula.Console/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "CalendarSync.Console": { + "Calendula.Console": { "commandName": "Project", "commandLineArgs": "--secondaryAccountRefreshToken idk --primaryAccountRefreshToken test --primaryAccountSubjectPrefix [primacct] --secondaryAccountSubjectPrefix [secacct] --clientId lezfooo --orgConnectionString asgsgs --daysToSync 2" } diff --git a/CalendarSync.Console/TokenFile.cs b/Calendula.Console/TokenFile.cs similarity index 89% rename from CalendarSync.Console/TokenFile.cs rename to Calendula.Console/TokenFile.cs index a11b7e8..672e56d 100644 --- a/CalendarSync.Console/TokenFile.cs +++ b/Calendula.Console/TokenFile.cs @@ -4,7 +4,7 @@ using System.Text; using System.Threading.Tasks; -namespace CalendarSync.Console +namespace Calendula.Console { internal class TokenFile { diff --git a/CalendarSync.sln b/Calendula.sln similarity index 80% rename from CalendarSync.sln rename to Calendula.sln index fd6d467..9a78dbd 100644 --- a/CalendarSync.sln +++ b/Calendula.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.2.32519.379 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CalendarSync.Console", "CalendarSync.Console\CalendarSync.Console.csproj", "{A99652FA-F4BE-44CC-A5AD-17BDD0D5739E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Calendula.Console", "Calendula.Console\Calendula.Console.csproj", "{A99652FA-F4BE-44CC-A5AD-17BDD0D5739E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CalendarSync", "CalendarSync\CalendarSync.csproj", "{84D2ABE8-8A2E-4DC3-9D94-503C69D64CFB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Calendula", "Calendula\Calendula.csproj", "{84D2ABE8-8A2E-4DC3-9D94-503C69D64CFB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/Calendula/AuthService.cs b/Calendula/AuthService.cs new file mode 100644 index 0000000..f88d4a7 --- /dev/null +++ b/Calendula/AuthService.cs @@ -0,0 +1,34 @@ +using System.Text.Json; + +namespace Calendula +{ + public class AuthService + { + private readonly string ClientId = ""; + private HttpClient Client { get; set; } = new HttpClient(); + public AuthService(string clientId) + { + ClientId = clientId; + } + + public async Task GetToken(string refreshToken) + { + var values = new Dictionary() + { + ["client_id"] = ClientId, + ["grant_type"] = "refresh_token", + ["scope"] = "offline_access Calendars.ReadWrite", + ["refresh_token"] = refreshToken, + }; + var body = new FormUrlEncodedContent(values); + + using var response = await Client.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", body); + + var resp = await response.Content.ReadAsStringAsync(); + using var stream = await response.Content.ReadAsStreamAsync(); + response.EnsureSuccessStatusCode(); + var responseBody = await JsonSerializer.DeserializeAsync(stream); + return responseBody; + } + } +} \ No newline at end of file diff --git a/Calendula/CalendarSyncService.cs b/Calendula/CalendarSyncService.cs new file mode 100644 index 0000000..366b537 --- /dev/null +++ b/Calendula/CalendarSyncService.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Graph; + +namespace Calendula +{ + public class CalendulaService + { + private GraphService SourceGraph { get; set; } + private GraphService DestinationGraph { get; set; } + private DataverseService Dataverse { get; } + private ILogger Log { get; set; } + private SyncProfile SourceProfile { get; } + private SyncProfile DestinationProfile { get; } + private readonly string ClientId = ""; + private readonly string OrgConnectionString = ""; + + public CalendulaService(SyncProfile sourceProfile, SyncProfile destProfile, ILogger logger, string clientId, string orgConnectionString) + { + SourceProfile = sourceProfile; + DestinationProfile = destProfile; + OrgConnectionString = orgConnectionString; + Dataverse = new DataverseService(logger, OrgConnectionString); + Log = logger; + ClientId = clientId; + } + + private async Task InitGraphClientsAsync() + { + var auth = new AuthService(ClientId); + if (SourceGraph == null) + { + var response = await auth.GetToken(SourceProfile.RefreshToken); + SourceGraph = new GraphService(response.AccessToken); + } + if (DestinationGraph == null) + { + var response = await auth.GetToken(DestinationProfile.RefreshToken); + DestinationGraph = new GraphService(response.AccessToken); + } + } + + public async Task SyncRangeBidirectionalAsync(string start, string end) + { + await SyncRangeAsync(start, end); + var reverseClient = new CalendulaService(DestinationProfile, SourceProfile, Log, ClientId, OrgConnectionString); + await reverseClient.SyncRangeAsync(start, end); + } + + public async Task SyncRangeAsync(string start, string end) + { + Log.LogInformation($"Syncing from {start} to {end}."); + await InitGraphClientsAsync(); + + // sync changes from source to dest + var events = await SourceGraph.GetEventsInRangeAsync(start, end); + var destinationIds = new HashSet(); + + foreach (var e in events) + { + var destKey = await SyncEventAsync(e); + if (destKey != null) + { + destinationIds.Add(destKey); + } + } + + // delete mapped events in dest that were removed in source + events = await DestinationGraph.GetEventsInRangeAsync(start, end); + var eventsToDelete = events.Where(e => e.Subject.StartsWith(SourceProfile.SubjectPrefix) && !destinationIds.Contains(e.Id)); + foreach (var e in eventsToDelete) + { + await Dataverse.DeleteEventAsync(e.Id); + await DestinationGraph.DeleteEventAsync(e.Id); + } + } + + public async Task SyncEventAsync(Event e) + { + if (e.Subject.StartsWith(DestinationProfile.SubjectPrefix)) + { + Log.LogInformation("Skipping event that originated in destination calendar."); + return null; + } + + // get or create event in d365 + var record = await Dataverse.GetOrCreateEventAsync(e, SourceProfile.SubjectPrefix); + + // map src event to dest event + var mappedEvent = SourceProfile.MapEvent(e); + var destEvent = record.DestinationKey != null ? await DestinationGraph.GetEventByIdAsync(record.DestinationKey) : null; + + if (destEvent == null) + { + // create in dest + var destKey = await DestinationGraph.CreateEventAsync(mappedEvent); + + // update dest key in d365 + await Dataverse.UpdateDestKeyAsync(record.RecordId, destKey); + + // update these stats in case event was deleted + await Dataverse.UpdateEventTimeAsync(record.RecordId, e, SourceProfile.SubjectPrefix); + return destKey; + } + else + { + // get event from dest + var graphEvent = await DestinationGraph.GetEventByIdAsync(record.DestinationKey); + mappedEvent.Id = graphEvent.Id; + await DestinationGraph.UpdateEventAsync(mappedEvent); + await Dataverse.UpdateEventTimeAsync(record.RecordId, e, SourceProfile.SubjectPrefix); + return graphEvent.Id; + } + } + } +} diff --git a/CalendarSync/CalendarSync.csproj b/Calendula/Calendula.csproj similarity index 100% rename from CalendarSync/CalendarSync.csproj rename to Calendula/Calendula.csproj diff --git a/Calendula/DataverseService.cs b/Calendula/DataverseService.cs new file mode 100644 index 0000000..fe2a11b --- /dev/null +++ b/Calendula/DataverseService.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Graph; +using Microsoft.Graph.Extensions; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk.Query; +using Entity = Microsoft.Xrm.Sdk.Entity; + +namespace Calendula +{ + public class DataverseService + { + private ServiceClient Client { get; set; } + private ILogger Log { get; set; } + + public DataverseService(ILogger logger, string orgConnectionString) + { + Client = new ServiceClient(orgConnectionString); + Log = logger; + } + + public async Task GetOrCreateEventAsync(Event e, string sourceName) + { + Log.LogInformation($"Querying Dataverse for calendar event {e.Id}"); + + var query = new QueryByAttribute("pl_calendarevent"); + query.AddAttributeValue("pl_sourcekey", e.Id); + query.ColumnSet = new ColumnSet("pl_calendareventid", "pl_destinationkey"); + query.TopCount = 1; + var response = await Client.RetrieveMultipleAsync(query); + if (response.Entities.Any()) + { + Log.LogInformation("Found a matching calendar event in Dataverse."); + return new DataverseEventResponse(response.Entities.First()); + } + else + { + var create = new Entity("pl_calendarevent") + { + ["pl_sourcekey"] = e.Id, + ["pl_name"] = $"{sourceName} {e.Subject}", + ["pl_start"] = e.Start.ToDateTime(), + ["pl_end"] = e.End.ToDateTime(), + }; + create.Id = await Client.CreateAsync(create); + Log.LogInformation("Created a new calendar event in Dataverse."); + return new DataverseEventResponse(create); + } + } + + public async Task UpdateDestKeyAsync(Guid id, string destinationKey) + { + await Client.UpdateAsync(new Entity("pl_calendarevent", id) + { + ["pl_destinationkey"] = destinationKey, + }); + Log.LogInformation("Updated destination key in Dataverse."); + } + + public async Task UpdateEventTimeAsync(Guid id, Event e, string sourceName) + { + await Client.UpdateAsync(new Entity("pl_calendarevent", id) + { + ["pl_name"] = $"{sourceName} {e.Subject}", + ["pl_start"] = e.Start.ToDateTime(), + ["pl_end"] = e.End.ToDateTime() + }); + Log.LogInformation("Updated event time in Dataverse."); + } + + public async Task DeleteEventAsync(string destinationId) + { + var query = new QueryExpression("pl_calendarevent"); + query.ColumnSet = new ColumnSet("pl_calendareventid"); + query.TopCount = 1; + query.Criteria.AddCondition("pl_destinationkey", ConditionOperator.Equal, destinationId); + var response = await Client.RetrieveMultipleAsync(query); + if (!response.Entities.Any()) + { + return; + } + + var e = response.Entities.First(); + await Client.DeleteAsync(e.LogicalName, e.Id); + + Log.LogInformation("Deleted calendar event in Dataverse."); + } + } + + public class DataverseEventResponse + { + internal Guid RecordId { get; set; } + internal string? DestinationKey { get; set; } + internal DataverseEventResponse(Entity calendarEvent) + { + RecordId = calendarEvent.Id; + DestinationKey = calendarEvent.GetAttributeValue("pl_destinationkey"); + } + } +} diff --git a/CalendarSync/GraphService.cs b/Calendula/GraphService.cs similarity index 98% rename from CalendarSync/GraphService.cs rename to Calendula/GraphService.cs index 680de01..f833a4e 100644 --- a/CalendarSync/GraphService.cs +++ b/Calendula/GraphService.cs @@ -1,7 +1,7 @@ using Microsoft.Graph; using System.Net.Http.Headers; -namespace CalendarSync +namespace Calendula { public class GraphService { diff --git a/Calendula/PrimaryAccToSecondaryAccProfile.cs b/Calendula/PrimaryAccToSecondaryAccProfile.cs new file mode 100644 index 0000000..3a8d592 --- /dev/null +++ b/Calendula/PrimaryAccToSecondaryAccProfile.cs @@ -0,0 +1,34 @@ +using Microsoft.Graph; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Calendula +{ + public class PrimaryAccToSecondaryAccProfile : SyncProfile + { + public PrimaryAccToSecondaryAccProfile(string refreshToken, string subjectPrefix) + { + RefreshToken = refreshToken; + SubjectPrefix = subjectPrefix; + } + + public override Event MapEvent(Event e) + { + var mapped = new Event + { + Subject = SubjectPrefix, + Start = e.Start, + End = e.End, + IsAllDay = e.IsAllDay, + ShowAs = e.ShowAs, + Importance = e.Importance, + Sensitivity = e.Sensitivity, + }; + + return mapped; + } + } +} diff --git a/CalendarSync/RefreshTokenResponse.cs b/Calendula/RefreshTokenResponse.cs similarity index 95% rename from CalendarSync/RefreshTokenResponse.cs rename to Calendula/RefreshTokenResponse.cs index 0c448da..63fb026 100644 --- a/CalendarSync/RefreshTokenResponse.cs +++ b/Calendula/RefreshTokenResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace CalendarSync +namespace Calendula { public class RefreshTokenResponse { diff --git a/Calendula/SecondaryAccToPrimaryAccProfile.cs b/Calendula/SecondaryAccToPrimaryAccProfile.cs new file mode 100644 index 0000000..3f9dc7b --- /dev/null +++ b/Calendula/SecondaryAccToPrimaryAccProfile.cs @@ -0,0 +1,30 @@ +using Microsoft.Graph; + +namespace Calendula +{ + public class SecondaryAccToPrimaryAccProfile : SyncProfile + { + public SecondaryAccToPrimaryAccProfile(string refreshToken, string subjectPrefix) + { + RefreshToken = refreshToken; + SubjectPrefix = subjectPrefix; + } + + public override Event MapEvent(Event e) + { + var mapped = new Event + { + Subject = $"{SubjectPrefix} {e.Subject}", + Start = e.Start, + End = e.End, + IsAllDay = e.IsAllDay, + ShowAs = e.ShowAs, + BodyPreview = e.BodyPreview, + Importance = e.Importance, + Sensitivity = e.Sensitivity, + }; + + return mapped; + } + } +} diff --git a/CalendarSync/SyncProfile.cs b/Calendula/SyncProfile.cs similarity index 91% rename from CalendarSync/SyncProfile.cs rename to Calendula/SyncProfile.cs index 2439ddc..b7b1be5 100644 --- a/CalendarSync/SyncProfile.cs +++ b/Calendula/SyncProfile.cs @@ -1,6 +1,6 @@ using Microsoft.Graph; -namespace CalendarSync +namespace Calendula { public abstract class SyncProfile { diff --git a/README.md b/README.md index 7892ca3..22d32b8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ -# CalendarSync +# Calendula -Syncs events between outlook calendars on two separate microsoft acounts +Syncs events between outlook calendars on two separate microsoft acounts. + +Calendula is a [flower](https://en.wikipedia.org/wiki/Calendula), and its name means 'little calendar' in latin.