diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index d5754b4d..99664814 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -86,6 +86,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, get { if (SelectedQuickEventAccountCalendar == null || + SelectedQuickEventAccountCalendar.IsReadOnly || SelectedQuickEventDate == null || string.IsNullOrWhiteSpace(EventName) || string.IsNullOrWhiteSpace(SelectedStartTimeString) || @@ -204,6 +205,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (DisplayDetailsCalendarItemViewModel?.CalendarItem == null) return; + if (DisplayDetailsCalendarItemViewModel.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } + if (DisplayDetailsCalendarItemViewModel.CalendarItem.IsRecurringParent) { var confirmed = await _dialogService.ShowConfirmationDialogAsync( @@ -460,6 +467,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))] private async Task SaveQuickEventAsync() { + if (SelectedQuickEventAccountCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } + var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime; var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime; var composeResult = new CalendarEventComposeResult @@ -553,6 +566,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, return; } + if (calendarItem.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } + var normalizedTargetStart = calendarItem.IsAllDayEvent ? targetStart.Date : targetStart; @@ -1195,6 +1214,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (targetItem == null) return; + if (targetItem.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } + if (targetItem.IsRecurringParent) { var confirmed = await _dialogService.ShowConfirmationDialogAsync( @@ -1221,6 +1246,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (targetItem == null || targetItem.ShowAs == showAs) return; + if (targetItem.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } + var originalItem = await _calendarService.GetCalendarItemAsync(targetItem.Id).ConfigureAwait(false); var attendees = await _calendarService.GetAttendeesAsync(targetItem.Id).ConfigureAwait(false); @@ -1245,6 +1276,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (targetItem == null) return; + if (targetItem.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } + var operation = responseStatus switch { CalendarItemStatus.Accepted => CalendarSynchronizerOperation.AcceptEvent, diff --git a/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs b/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs index afdd6699..a1c47428 100644 --- a/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/AccountCalendarViewModel.cs @@ -55,6 +55,12 @@ public partial class AccountCalendarViewModel : ObservableObject, IAccountCalend set => SetProperty(AccountCalendar.IsPrimary, value, AccountCalendar, (u, i) => u.IsPrimary = i); } + public bool IsReadOnly + { + get => AccountCalendar.IsReadOnly; + set => SetProperty(AccountCalendar.IsReadOnly, value, AccountCalendar, (u, i) => u.IsReadOnly = i); + } + public bool IsSynchronizationEnabled { get => AccountCalendar.IsSynchronizationEnabled; diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index c86ada88..ea660861 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -440,6 +440,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel private async Task SaveAsync() { if (CurrentEvent == null) return; + if (CurrentEvent.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } try { @@ -506,6 +511,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel private async Task DeleteAsync() { if (CurrentEvent == null) return; + if (CurrentEvent.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } // If the event is a master recurring event, ask for confirmation if (CurrentEvent.IsRecurringParent) @@ -610,6 +620,11 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel private async Task SendRsvpResponse(AttendeeStatus status) { if (CurrentEvent == null) return; + if (CurrentEvent.AssignedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } try { diff --git a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs index 79842c08..74447e8f 100644 --- a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs +++ b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs @@ -16,6 +16,7 @@ public class AccountCalendar : IAccountCalendar public string SynchronizationDeltaToken { get; set; } public string Name { get; set; } public bool IsPrimary { get; set; } + public bool IsReadOnly { get; set; } public bool IsSynchronizationEnabled { get; set; } = true; public bool IsExtended { get; set; } = true; public CalendarItemShowAs DefaultShowAs { get; set; } = CalendarItemShowAs.Busy; diff --git a/Wino.Core.Domain/Interfaces/IAccountCalendar.cs b/Wino.Core.Domain/Interfaces/IAccountCalendar.cs index 7e070f40..77bac0f8 100644 --- a/Wino.Core.Domain/Interfaces/IAccountCalendar.cs +++ b/Wino.Core.Domain/Interfaces/IAccountCalendar.cs @@ -10,6 +10,7 @@ public interface IAccountCalendar string TextColorHex { get; set; } string BackgroundColorHex { get; set; } bool IsPrimary { get; set; } + bool IsReadOnly { get; set; } bool IsSynchronizationEnabled { get; set; } Guid AccountId { get; set; } string RemoteCalendarId { get; set; } diff --git a/Wino.Core.Domain/Interfaces/IMailDialogService.cs b/Wino.Core.Domain/Interfaces/IMailDialogService.cs index dc60c422..e07ea3a4 100644 --- a/Wino.Core.Domain/Interfaces/IMailDialogService.cs +++ b/Wino.Core.Domain/Interfaces/IMailDialogService.cs @@ -16,6 +16,7 @@ namespace Wino.Core.Domain.Interfaces; public interface IMailDialogService : IDialogServiceBase { + void ShowReadOnlyCalendarMessage(); Task ShowHardDeleteConfirmationAsync(); Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService); diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 5f97615f..dc43097e 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -213,6 +213,8 @@ "CalendarEventDetails_Organizer": "Organizer", "CalendarEventDetails_People": "People", "CalendarEventDetails_ReadOnlyEvent": "Read-only event", + "CalendarReadOnly_Title": "Read-only calendar", + "CalendarReadOnly_Message": "You can't update this calendar or its events. This calendar is read-only.", "CalendarContextMenu_Respond": "Respond", "CalendarEventDetails_Reminder": "Reminder", "CalendarReminder_StartedHoursAgo": "Started {0} hours ago", diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index c3ba55f5..1f35dd13 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -145,6 +145,8 @@ public static class GoogleIntegratorExtensions Id = Guid.NewGuid(), TimeZone = calendarListEntry.TimeZone, IsPrimary = calendarListEntry.Primary.GetValueOrDefault(), + IsReadOnly = !string.Equals(calendarListEntry.AccessRole, "owner", StringComparison.OrdinalIgnoreCase) + && !string.Equals(calendarListEntry.AccessRole, "writer", StringComparison.OrdinalIgnoreCase), IsSynchronizationEnabled = true, }; diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index b3bdaeee..3386fb54 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -190,6 +190,7 @@ public static class OutlookIntegratorExtensions Id = Guid.NewGuid(), RemoteCalendarId = outlookCalendar.Id, IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(), + IsReadOnly = !outlookCalendar.CanEdit.GetValueOrDefault(true), Name = outlookCalendar.Name, IsSynchronizationEnabled = true, IsExtended = true, diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs index b98c7ec6..033254ba 100644 --- a/Wino.Core/Services/WinoRequestDelegator.cs +++ b/Wino.Core/Services/WinoRequestDelegator.cs @@ -165,6 +165,13 @@ public class WinoRequestDelegator : IWinoRequestDelegator if (calendarPreparationRequest == null) return; + var resolvedCalendar = await ResolveCalendarAsync(calendarPreparationRequest).ConfigureAwait(false); + if (resolvedCalendar?.IsReadOnly == true) + { + _dialogService.ShowReadOnlyCalendarMessage(); + return; + } + IRequestBase request = calendarPreparationRequest.Operation switch { CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false), @@ -212,6 +219,25 @@ public class WinoRequestDelegator : IWinoRequestDelegator return new CreateCalendarEventRequest(composeResult, assignedCalendar); } + private async Task ResolveCalendarAsync(CalendarOperationPreparationRequest calendarPreparationRequest) + { + if (calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent) + { + var calendarId = calendarPreparationRequest.ComposeResult?.CalendarId ?? Guid.Empty; + return calendarId == Guid.Empty + ? null + : await _calendarService.GetAccountCalendarAsync(calendarId).ConfigureAwait(false); + } + + if (calendarPreparationRequest.CalendarItem?.AssignedCalendar is AccountCalendar assignedCalendar) + return assignedCalendar; + + var fallbackCalendarId = calendarPreparationRequest.CalendarItem?.CalendarId ?? Guid.Empty; + return fallbackCalendarId == Guid.Empty + ? null + : await _calendarService.GetAccountCalendarAsync(fallbackCalendarId).ConfigureAwait(false); + } + private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage) { // For Outlook accounts, declined events are deleted by the server after synchronization. diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 860e51dc..9b3eaf90 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -759,6 +759,8 @@ public class GmailSynchronizer : WinoSynchronizer InfoBarMessage( + Translator.CalendarReadOnly_Title, + Translator.CalendarReadOnly_Message, + InfoBarMessageType.Warning); + public async Task ShowCreateAccountAliasDialogAsync() { var createAccountAliasDialog = new CreateAccountAliasDialog() diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index 216afc9f..7046ffb2 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -168,6 +168,13 @@ public class DatabaseService : IDatabaseService .ConfigureAwait(false); } + if (!accountCalendarColumns.Any(c => c.Name == nameof(AccountCalendar.IsReadOnly))) + { + await Connection + .ExecuteAsync($"ALTER TABLE {nameof(AccountCalendar)} ADD COLUMN {nameof(AccountCalendar.IsReadOnly)} INTEGER NOT NULL DEFAULT 0") + .ConfigureAwait(false); + } + await Connection.ExecuteAsync("DROP TABLE IF EXISTS WinoAccountAddOnCache").ConfigureAwait(false); }