diff --git a/CalDavSynchronizer/CalDavSynchronizer.csproj b/CalDavSynchronizer/CalDavSynchronizer.csproj index 3e011d1b..c0f9c6ed 100644 --- a/CalDavSynchronizer/CalDavSynchronizer.csproj +++ b/CalDavSynchronizer/CalDavSynchronizer.csproj @@ -901,6 +901,8 @@ + + @@ -940,6 +942,10 @@ + + + + @@ -1090,7 +1096,7 @@ - 99D12A4419EA17C4DB056A34FEDAFDC1A576A921 + 1420A545B90CAFB4925D2F952464AA1F36A0AA27 diff --git a/CalDavSynchronizer/ComponentContainer.cs b/CalDavSynchronizer/ComponentContainer.cs index 55281a84..d57c8f63 100644 --- a/CalDavSynchronizer/ComponentContainer.cs +++ b/CalDavSynchronizer/ComponentContainer.cs @@ -55,9 +55,11 @@ using CalDavSynchronizer.Implementation.Tasks; using CalDavSynchronizer.Implementation.TimeZones; using CalDavSynchronizer.ProfileTypes; +using CalDavSynchronizer.Properties; using CalDavSynchronizer.Scheduling.ComponentCollectors; using CalDavSynchronizer.Ui.Options; using CalDavSynchronizer.Ui.Options.BulkOptions.ViewModels; +using CalDavSynchronizer.Ui.Options.ResourceSelection.ViewModels; using CalDavSynchronizer.Ui.Options.Models; using CalDavSynchronizer.Ui.Options.ViewModels; using CalDavSynchronizer.Ui.SystrayNotification; @@ -67,6 +69,7 @@ using GenSync.Synchronization; using AppointmentId = CalDavSynchronizer.Implementation.Events.AppointmentId; using MessageBox = System.Windows.Forms.MessageBox; +using Microsoft.Win32; namespace CalDavSynchronizer { @@ -233,9 +236,335 @@ public ComponentContainer (Application application, IGeneralOptionsDataAccess ge s_logger.Error ("Can't access SyncObjects", ex); } + // Set the registry key "HKEY_CURRENT_USER\Software\CalDavSynchronizer\AutoconfigureKolab" + // to "1" to enable the Kolab autoconfigure feature. This setting is not available + // through the general options dialog, as it is not so general after all... + bool haveImap = false; + foreach (var innerAccount in _session.Accounts) + using (var account = GenericComObjectWrapper.Create(innerAccount)) + if ((account.Inner as Account)?.AccountType == OlAccountType.olImap) + haveImap = true; + + if (haveImap && generalOptions.AutoconfigureKolab) + { + try + { + AutoconfigureKolab(options, generalOptions); + } + catch (COMException ex) + { + s_logger.Error("Error during autoconfigure", ex); + MessageBox.Show(Strings.Get($"Error during autoconfigure") + ":\n".ToString() + ex.ToString(), MessageBoxTitle); + } + } + _oneTimeTaskRunner = new OneTimeTaskRunner(_outlookSession); } + private async void AutoconfigureKolab(Options[] options, GeneralOptions generalOptions) + { + s_logger.Info("Starting Kolab autoconfigure"); + string profileType = "Kolab"; + // Make sure the add-on language is active in our context + Thread.CurrentThread.CurrentUICulture = new CultureInfo(GeneralOptionsDataAccess.CultureName); + // Remove all Kolab options without Outlook folder, so we can recreate them + options = options.Where(o => o.ProfileTypeOrNull != profileType || o.OutlookFolderEntryId != null).ToArray(); + // Create all objects required to use the options collection models + string[] categories; + using (var categoriesWrapper = GenericComObjectWrapper.Create(_session.Categories)) + categories = categoriesWrapper.Inner.ToSafeEnumerable().Select(c => c.Name).ToArray(); + var faultFinder = generalOptions.FixInvalidSettings + ? new SettingsFaultFinder(EnumDisplayNameProvider.Instance) + : NullSettingsFaultFinder.Instance; + var optionTasks = new OptionTasks( + _session, EnumDisplayNameProvider.Instance, faultFinder, _outlookSession); + var viewOptions = new ViewOptions( + generalOptions.EnableAdvancedView); + var sessionData = new OptionModelSessionData( + _outlookSession.GetCategories().ToDictionary( + c => c.Name, _outlookSession.CategoryNameComparer)); + var optionsCollectionModel = new OptionsCollectionViewModel( + generalOptions.ExpandAllSyncProfiles, GetProfileDataDirectory, _uiService, optionTasks, _profileTypeRegistry, + (parent, type) => type.CreateModelFactory( + parent, _outlookAccountPasswordProvider, categories, optionTasks, faultFinder, generalOptions, viewOptions, sessionData), + viewOptions); + optionsCollectionModel.SetOptionsCollection(options); + + // Add the "add multiple resources" pseudo-option of type "Kolab" + var kolabOptionsModel = (KolabMultipleOptionsTemplateViewModel)optionsCollectionModel.AddMultipleHeadless( + _profileTypeRegistry.AllTypes.First(t => t.Name == profileType)); + + // Auto-detect all updates + kolabOptionsModel.AutoCreateOutlookFolders = true; + kolabOptionsModel.OnlyAddNewUrls = true; + kolabOptionsModel.AutoConfigure = true; + kolabOptionsModel.GetAccountSettingsCommand.Execute(null); + s_logger.Debug("Discover CalDAV/CardDAV resources"); + var serverResources = await kolabOptionsModel.DiscoverResourcesAsync(); + var newOptions = optionsCollectionModel.GetOptionsCollection(); + + // remove all options that are no longer available. + // Do this only if we really have a response from the server. + if (serverResources.ContainsResources) + { + s_logger.Debug("Remove all sync profiles and outlook folders whose CalDAV resources are no longer available"); + var allUris = + serverResources.Calendars.Select(c => c.Uri.ToString()).Concat( + serverResources.AddressBooks.Select(a => a.Uri.ToString())).Concat( + serverResources.TaskLists.Select(d => d.Id)).ToArray(); + var remainingOptions = new List(); + var markDeleted = " - " + Strings.Localize("Deleted") + " " + DateTime.Now.ToString(); + var markDeletedSafe = markDeleted.Replace('.', '_'); + var defaultCalendarFolder = new GenericComObjectWrapper(Globals.ThisAddIn.Application.Session.GetDefaultFolder(OlDefaultFolders.olFolderCalendar) as Folder); + foreach (var option in newOptions) + { + if (option.ProfileTypeOrNull != profileType || allUris.Contains(option.CalenderUrl)) + { + s_logger.Debug($"Keep sync profile '{option.Name}'"); + remainingOptions.Add(option); + } + else if (option.OutlookFolderEntryId == null) + { + s_logger.Info($"Remove stale Kolab sync profile '{option.Name}'"); + } + else + { + s_logger.Info($"Remove stale Kolab sync profile '{option.Name}' and delete synced outlook folder"); + GenericComObjectWrapper folder = null; + try + { + folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(option.OutlookFolderEntryId) as Folder); + } + catch (Exception ex) + { + s_logger.Error($"Could not find outlook folder ID '{option.OutlookFolderEntryId}' für sync profile '{option.Name}'", ex); + } + if (folder != null && folder.Inner.EntryID == defaultCalendarFolder.Inner.EntryID) + { + s_logger.Info($"We can't delete '{folder.Inner.Name}', so move all entries to a new folder and mark this folder als deleted."); + GenericComObjectWrapper deletedCalendarFolder = null; + foreach (String tempFolderName in new[] {folder.Inner.Name + markDeleted, folder.Inner.Name + markDeletedSafe}) + { + if (deletedCalendarFolder == null) + try + { + deletedCalendarFolder = new GenericComObjectWrapper(defaultCalendarFolder.Inner.Folders.Add(folder.Inner.Name + markDeleted, OlDefaultFolders.olFolderCalendar) as Folder); + deletedCalendarFolder.Inner.Name = folder.Inner.Name + markDeleted; + } + catch (Exception ex) + { + s_logger.Debug($"Could not create temporary folder '{tempFolderName}'", ex); + } + } + if (deletedCalendarFolder == null) + { + s_logger.Error($"Could not create temporary folder '{folder.Inner.Name + markDeleted}'"); + } + else + { + s_logger.Info($"Move all entries from outlook default calendar '{folder.Inner.Name}' to temporary folder '{deletedCalendarFolder.Inner.Name}'"); + try + { + deletedCalendarFolder.Inner.Description = folder.Inner.Description; + folder.Inner.Description = ""; + foreach (var innerItem in folder.Inner.Items) + using (var item = GenericComObjectWrapper.Create(innerItem)) + (item as AppointmentItem)?.Move(deletedCalendarFolder.Inner); + deletedCalendarFolder.Inner.Delete(); + } + catch (Exception ex) + { + s_logger.Error($"Failed to move all items from '{folder.Inner.Name}' to temporary folder '{deletedCalendarFolder.Inner.Name}'", ex); + } + } + } + else if (folder != null) + { + try { folder.Inner.Name += markDeleted; } + catch { try { folder.Inner.Name += markDeletedSafe; } catch { } } + try + { + folder.Inner.Delete(); + } + catch (Exception ex) + { + s_logger.Error($"Could not move '{folder.Inner.Name}' to Trash", ex); + } + } + } + } + newOptions = remainingOptions.ToArray(); + + // If one of the remaining CaldDAV calendars is the default calendar + // and the Outlook default calendar is not currently synced: + // - Remove existing Outlook calendar subfolder + // - Sync Outlook default calendar + var defaultCalendarResource = serverResources.Calendars.FirstOrDefault(c => c.IsDefault); + var defaultCalendarFolderIsMapped = newOptions.Any(o => o.OutlookFolderEntryId == defaultCalendarFolder.Inner.EntryID); + if (defaultCalendarFolderIsMapped) { s_logger.Debug($"Found existing mapping for default folder."); } + if (defaultCalendarResource != null && !defaultCalendarFolderIsMapped) + { + s_logger.Info($"New Default CalDAV folder '{defaultCalendarResource.Name}' detected."); + var defaultCalendarMapping = newOptions.FirstOrDefault(m => m.CalenderUrl == defaultCalendarResource.Uri.ToString()); + if (defaultCalendarMapping?.OutlookFolderEntryId != defaultCalendarFolder.Inner.EntryID) + { + // Delete existing folder + GenericComObjectWrapper folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(defaultCalendarMapping.OutlookFolderEntryId) as Folder); + s_logger.Info($"Delete existing folder '{folder.Inner.Name}' for Mapping '{defaultCalendarMapping.Name}'"); + try { folder.Inner.Name += markDeleted; } + catch { try { folder.Inner.Name += markDeletedSafe; } catch { } } + try + { + // Deleting a folder that has just been created might fail + folder.Inner.Delete(); + } + catch (Exception ex) + { + s_logger.Error($"Could not move folder '{folder.Inner.Name}' to trash", ex); + } + // Map to Outlook default folder + s_logger.Debug($"Route mapping '{defaultCalendarMapping.Name}' to folder '{defaultCalendarFolder.Inner.Name}'"); + defaultCalendarMapping.OutlookFolderEntryId = defaultCalendarFolder.Inner.EntryID; + defaultCalendarMapping.OutlookFolderStoreId = defaultCalendarFolder.Inner.StoreID; + } + } + } + + // Update existing Kolab Calendar resources + foreach (var resource in serverResources.Calendars ?? Enumerable.Empty()) + { + s_logger.Debug($"Update calendar '{resource.Name}'"); + foreach (var option in newOptions.Where(o => o.OutlookFolderEntryId != null && o.CalenderUrl == resource.Uri.ToString())) { + GenericComObjectWrapper folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(option.OutlookFolderEntryId) as Folder); + if (resource.ReadOnly) + { + s_logger.Debug($"Calendar '{resource.Name}' is read-only"); + option.SynchronizationMode = SynchronizationMode.ReplicateServerIntoOutlook; + folder.Inner.Description = Strings.Get($"Read-only calendar") + $" »{option.Name}«:\n" + Strings.Get($"local changes made in Outlook are discarded and replaced by data from the server."); + try + { + // Setting the icon might fail if we sync with the default Outlook calendar folder + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.CalendarReadOnly) as stdole.StdPicture); + } + catch { } + } + else + { + s_logger.Debug($"Calendar '{resource.Name}' is read-write"); + option.SynchronizationMode = SynchronizationMode.MergeInBothDirections; + folder.Inner.Description = Strings.Get($"Read-write calendar") + $" »{option.Name}«:\n" + Strings.Get($"local changes made in Outlook and remote changes from the server are merged."); + try + { + // Setting the icon might fail if we sync with the default Outlook calendar folder + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.CalendarReadWrite) as stdole.StdPicture); + } + catch { } + } + // Quick and Dirty: On shared and read-only calendars, don't sync reminders + if (resource.ReadOnly || resource.Name.StartsWith("(") || resource.Name.StartsWith("shared » ")) + { + s_logger.Debug($"Calendar '{resource.Name}' is read-only or shared"); + var eventMappingOptions = (EventMappingConfiguration)option.MappingConfiguration; + if (eventMappingOptions.MapReminder != ReminderMapping.@false) + { + eventMappingOptions.MapReminder = ReminderMapping.@false; + // Remove local reminders. + foreach (var innerItem in folder.Inner.Items) + { + using (var item = GenericComObjectWrapper.Create(innerItem)) + { + if (item.Inner is AppointmentItem) + { + (item.Inner as AppointmentItem).ReminderSet = false; + (item.Inner as AppointmentItem).ReminderMinutesBeforeStart = 0; + (item.Inner as AppointmentItem).Save(); + } + } + } + } + } + } + } + + // Update existing Kolab Address book resources + foreach (var resource in serverResources.AddressBooks ?? Enumerable.Empty()) + { + s_logger.Debug($"Update address book '{resource.Name}'"); + foreach (var option in newOptions.Where(o => o.OutlookFolderEntryId != null && o.CalenderUrl == resource.Uri.ToString())) + { + GenericComObjectWrapper folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(option.OutlookFolderEntryId) as Folder); + if (resource.ReadOnly) + { + option.SynchronizationMode = SynchronizationMode.ReplicateServerIntoOutlook; + folder.Inner.Description = Strings.Get($"Read-only address book") + $" »{option.Name}«:\n" + Strings.Get($"local changes made in Outlook are discarded and replaced by data from the server."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.AddressbookReadOnly) as stdole.StdPicture); + } + else + { + option.SynchronizationMode = SynchronizationMode.MergeInBothDirections; + folder.Inner.Description = Strings.Get($"Read-write address book") + $" »{option.Name}«:\n" + Strings.Get($"local changes made in Outlook and remote changes from the server are merged."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.AddressbookReadWrite) as stdole.StdPicture); + } + } + } + + // Update existing Kolab Task list resources + foreach (var resource in serverResources.TaskLists ?? Enumerable.Empty()) + { + s_logger.Debug($"Update task list '{resource.Name}'"); + foreach (var option in newOptions.Where(o => o.OutlookFolderEntryId != null && o.CalenderUrl == resource.Id)) + { + GenericComObjectWrapper folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(option.OutlookFolderEntryId) as Folder); + if (resource.ReadOnly) + { + option.SynchronizationMode = SynchronizationMode.ReplicateServerIntoOutlook; + folder.Inner.Description = Strings.Get($"Read-only task list") + $" »{option.Name}«:\n" + Strings.Get($"local changes made in Outlook are discarded and replaced by data from the server."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.TasklistReadOnly) as stdole.StdPicture); + } + else + { + option.SynchronizationMode = SynchronizationMode.MergeInBothDirections; + folder.Inner.Description = Strings.Get($"Read-write task list") + $" »{option.Name}«:\n" + Strings.Get($"local changes made in Outlook and remote changes from the server are merged."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.TasklistReadWrite) as stdole.StdPicture); + } + } + } + + // Set free/busy URL in Registry if not yet set + string regPath = + @"Software\Microsoft\Office\" + Globals.ThisAddIn.Application.Version.Split(new char[] { '.' })[0] + @".0" + + @"\Outlook\\Options\Calendar\Internet Free/Busy"; + var key = Registry.CurrentUser.OpenSubKey(regPath, true); + if (key == null) + key = Registry.CurrentUser.CreateSubKey(regPath); + var value = key.GetValue("Read URL"); + if (value == null || string.IsNullOrEmpty(value.ToString())) + { + s_logger.Info("Set free/busy URL"); + ServerSettingsTemplateViewModel server = kolabOptionsModel.ServerSettingsViewModel as ServerSettingsTemplateViewModel; + if (!string.IsNullOrEmpty(server.CalenderUrl)) + { + Uri url = new Uri(new Uri(server.CalenderUrl), "/freebusy/"); + key.SetValue("Read URL", url.ToString() + "%NAME%@%SERVER%"); + } + } + + // Save new settings + s_logger.Debug("Save autoconfigured settings"); + await ApplyNewOptions(options, newOptions, generalOptions, optionsCollectionModel.GetOneTimeTasks()); + } + private void PermanentStatusesViewModel_OptionsRequesting(object sender, OptionsEventArgs e) { e.Options = _optionsDataAccess.Load(); diff --git a/CalDavSynchronizer/Contracts/GeneralOption.cs b/CalDavSynchronizer/Contracts/GeneralOption.cs index 3e4a451d..81ced267 100644 --- a/CalDavSynchronizer/Contracts/GeneralOption.cs +++ b/CalDavSynchronizer/Contracts/GeneralOption.cs @@ -49,6 +49,7 @@ public class GeneralOptions public int ThresholdForProgressDisplay { get; set; } public int MaxSucessiveWarnings { get; set; } public string CultureName { get; set; } + public bool AutoconfigureKolab { get; set; } public GeneralOptions Clone() { diff --git a/CalDavSynchronizer/DataAccess/AddressBookData.cs b/CalDavSynchronizer/DataAccess/AddressBookData.cs index 33154d29..41486d54 100644 --- a/CalDavSynchronizer/DataAccess/AddressBookData.cs +++ b/CalDavSynchronizer/DataAccess/AddressBookData.cs @@ -24,11 +24,13 @@ public class AddressBookData { public Uri Uri { get; } public string Name { get; } + public bool ReadOnly { get; } - public AddressBookData (Uri uri, string name) + public AddressBookData (Uri uri, string name, bool readOnly = false) { Uri = uri; Name = name; + ReadOnly = readOnly; } } } \ No newline at end of file diff --git a/CalDavSynchronizer/DataAccess/CalDavDataAccess.cs b/CalDavSynchronizer/DataAccess/CalDavDataAccess.cs index 5e9a04a6..079eae0c 100644 --- a/CalDavSynchronizer/DataAccess/CalDavDataAccess.cs +++ b/CalDavSynchronizer/DataAccess/CalDavDataAccess.cs @@ -150,6 +150,11 @@ public async Task GetUserResourcesNoThrow (bool useWellKnownUrl XmlNode homeSetNode = calendarHomeSetProperties.XmlDocument.SelectSingleNode ("/D:multistatus/D:response/D:propstat/D:prop/C:calendar-home-set", calendarHomeSetProperties.XmlNamespaceManager); if (homeSetNode != null && homeSetNode.HasChildNodes) { + // https://tools.ietf.org/html/rfc6638#section-9.2 + XmlNode scheduleDefaultCalendarNode = calendarHomeSetProperties.XmlDocument.SelectSingleNode("/D:multistatus/D:response/D:propstat/D:prop/C:schedule-default-calendar-URL", calendarHomeSetProperties.XmlNamespaceManager); + String scheduleDefaultCalendarPath = scheduleDefaultCalendarNode == null ? "" : + scheduleDefaultCalendarNode.InnerText.EndsWith("/") ? scheduleDefaultCalendarNode.InnerText : scheduleDefaultCalendarNode.InnerText + "/"; + bool isFirstCalendar = true; foreach (XmlNode homeSetNodeHref in homeSetNode.ChildNodes) { if (!string.IsNullOrEmpty (homeSetNodeHref.InnerText)) @@ -182,16 +187,20 @@ public async Task GetUserResourcesNoThrow (bool useWellKnownUrl if (supportedComponentsNode != null) { var path = urlNode.InnerText.EndsWith ("/") ? urlNode.InnerText : urlNode.InnerText + "/"; + var uri = new Uri(calendarDocument.DocumentUri, path); + bool ro = await IsReadOnly(uri); if (supportedComponentsNode.InnerXml.Contains ("VEVENT")) { + bool isDefault = (scheduleDefaultCalendarNode != null && scheduleDefaultCalendarPath == path || scheduleDefaultCalendarNode == null && isFirstCalendar); + isFirstCalendar = false; var displayName = string.IsNullOrEmpty (displayNameNode.InnerText) ? "Default Calendar" : displayNameNode.InnerText; - calendars.Add (new CalendarData (new Uri (calendarDocument.DocumentUri, path), displayName, calendarColor)); + calendars.Add (new CalendarData (uri, displayName, calendarColor, ro, isDefault)); } if (supportedComponentsNode.InnerXml.Contains ("VTODO")) { var displayName = string.IsNullOrEmpty (displayNameNode.InnerText) ? "Default Tasks" : displayNameNode.InnerText; - taskLists.Add (new TaskListData (new Uri (calendarDocument.DocumentUri, path).ToString(), displayName)); + taskLists.Add (new TaskListData (uri.ToString(), displayName, ro)); } } } @@ -233,6 +242,7 @@ private Task GetCalendarHomeSet (Uri url) + " diff --git a/CalDavSynchronizer/DataAccess/CalendarData.cs b/CalDavSynchronizer/DataAccess/CalendarData.cs index 7c61bafa..2dc6562f 100644 --- a/CalDavSynchronizer/DataAccess/CalendarData.cs +++ b/CalDavSynchronizer/DataAccess/CalendarData.cs @@ -25,12 +25,16 @@ public class CalendarData public Uri Uri { get; } public string Name { get; } public ArgbColor? Color { get; } + public bool ReadOnly { get; } + public bool IsDefault { get; } - public CalendarData (Uri uri, string name, ArgbColor? color) + public CalendarData (Uri uri, string name, ArgbColor? color, bool readOnly = false, bool isDefault = false) { Uri = uri; Name = name; Color = color; + ReadOnly = readOnly; + IsDefault = isDefault; } } } \ No newline at end of file diff --git a/CalDavSynchronizer/DataAccess/CardDavDataAccess.cs b/CalDavSynchronizer/DataAccess/CardDavDataAccess.cs index 70ac43ad..680b81d8 100644 --- a/CalDavSynchronizer/DataAccess/CardDavDataAccess.cs +++ b/CalDavSynchronizer/DataAccess/CardDavDataAccess.cs @@ -170,7 +170,9 @@ public async Task> GetUserAddressBooksNoThrow (bo { var path = urlNode.InnerText.EndsWith ("/") ? urlNode.InnerText : urlNode.InnerText + "/"; var displayName = string.IsNullOrEmpty (displayNameNode.InnerText) ? "Default Addressbook" : displayNameNode.InnerText; - addressbooks.Add (new AddressBookData (new Uri (addressBookDocument.DocumentUri, path), displayName)); + var uri = new Uri(addressBookDocument.DocumentUri, path); + bool ro = await IsReadOnly(uri); + addressbooks.Add (new AddressBookData (uri, displayName, ro)); } } } diff --git a/CalDavSynchronizer/DataAccess/GeneralOptionsDataAccess.cs b/CalDavSynchronizer/DataAccess/GeneralOptionsDataAccess.cs index 8b27036d..893ac921 100644 --- a/CalDavSynchronizer/DataAccess/GeneralOptionsDataAccess.cs +++ b/CalDavSynchronizer/DataAccess/GeneralOptionsDataAccess.cs @@ -65,6 +65,7 @@ public class GeneralOptionsDataAccess : IGeneralOptionsDataAccess private const string ValueNameMaxSucessiveWarnings = "MaxSucessiveWarnings"; private const string ValueNameCultureName = "CultureName"; + private const string ValueNameAutoconfigureKolab = "AutoconfigureKolab"; public GeneralOptions LoadOptions () { @@ -102,7 +103,8 @@ public GeneralOptions LoadOptions () ShowProgressBar = (int) (key.GetValue (ValueNameShowProgressBar) ?? Convert.ToInt32 (Boolean.Parse (ConfigurationManager.AppSettings["showProgressBar"] ?? bool.TrueString))) != 0, ThresholdForProgressDisplay = (int) (key.GetValue (ValueNameThresholdForProgressDisplay) ?? int.Parse(ConfigurationManager.AppSettings["loadOperationThresholdForProgressDisplay"] ?? "50")), MaxSucessiveWarnings = (int) (key.GetValue (ValueNameMaxSucessiveWarnings) ?? 2), - CultureName = GetCultureName(key) + CultureName = GetCultureName(key), + AutoconfigureKolab = (int)(key.GetValue(ValueNameAutoconfigureKolab) ?? 0) != 0 }; } } diff --git a/CalDavSynchronizer/DataAccess/TaskListData.cs b/CalDavSynchronizer/DataAccess/TaskListData.cs index c5b2c391..fad10dfa 100644 --- a/CalDavSynchronizer/DataAccess/TaskListData.cs +++ b/CalDavSynchronizer/DataAccess/TaskListData.cs @@ -24,11 +24,13 @@ public class TaskListData { public string Id { get; } public string Name { get; } + public bool ReadOnly { get; } - public TaskListData (string id, string name) + public TaskListData (string id, string name, bool readOnly = false) { Id = id; Name = name; + ReadOnly = readOnly; } } } \ No newline at end of file diff --git a/CalDavSynchronizer/DataAccess/WebDavDataAccess.cs b/CalDavSynchronizer/DataAccess/WebDavDataAccess.cs index d23eb6f3..e67861d4 100644 --- a/CalDavSynchronizer/DataAccess/WebDavDataAccess.cs +++ b/CalDavSynchronizer/DataAccess/WebDavDataAccess.cs @@ -113,6 +113,48 @@ public async Task GetPrivileges () return privileges; } + // Only consider resource as read-only if we are sure. That is: + // - we can query ACL property + // - we see a READ privilage, but no WRITE privilege + public async Task IsReadOnly(Uri resourceUrl) + { + try + { + var document = await _webDavClient.ExecuteWebDavRequestAndReadResponse( + resourceUrl, + "PROPFIND", + 0, + null, + null, + "application/xml", + @" + + + + + + " + ); + + if ( + document.XmlDocument.SelectSingleNode("/D:multistatus/D:response/D:propstat/D:prop/D:acl/D:ace/D:grant/D:privilege/D:read", document.XmlNamespaceManager) != null + && document.XmlDocument.SelectSingleNode("/D:multistatus/D:response/D:propstat/D:prop/D:acl/D:ace/D:grant/D:privilege/D:write", document.XmlNamespaceManager) == null + ) + { + return true; + } + else + { + return false; + } + } + catch (Exception x) + { + s_logger.Error(null, x); + return false; + } + } + protected async Task GetEtag (Uri absoluteEntityUrl) { var headers = await _webDavClient.ExecuteWebDavRequestAndReturnResponseHeaders (absoluteEntityUrl, "GET", null, null, null, null, null); diff --git a/CalDavSynchronizer/Globalization/StringResources.de-DE.resx b/CalDavSynchronizer/Globalization/StringResources.de-DE.resx index 48dad348..fdb6b91a 100644 --- a/CalDavSynchronizer/Globalization/StringResources.de-DE.resx +++ b/CalDavSynchronizer/Globalization/StringResources.de-DE.resx @@ -1404,4 +1404,31 @@ Andere Protokolle können mittels protocol: address hinzugefügt werden Synchronisiere öffentliche Outlook Termine auf Standard Sichtbarkeit anstelle von PUBLIC + + Gelöscht + + + Änderungen in Outlook und Änderungen vom Server werden zusammengeführt. + + + Änderungen in Outlook werden verworfen und durch Daten vom Server ersetzt. + + + Adressbuch mit Nur-Lese-Zugriff + + + Kalender mit Nur-Lese-Zugriff + + + Aufgabenliste mit Nur-Lese-Zugriff + + + Adressbuch mit Lese- und Schreibzugriff + + + Kalender mit Lese- und Schreibzugriff + + + Aufgabenliste mit Lese- und Schreibzugriff + \ No newline at end of file diff --git a/CalDavSynchronizer/Globalization/StringResources.ru-RU.resx b/CalDavSynchronizer/Globalization/StringResources.ru-RU.resx index d72ed1bd..b51382fb 100644 --- a/CalDavSynchronizer/Globalization/StringResources.ru-RU.resx +++ b/CalDavSynchronizer/Globalization/StringResources.ru-RU.resx @@ -1342,4 +1342,7 @@ Установите флаг общедоступный MS Outlook по умолчанию вместо PUBLIC + + удаленный + \ No newline at end of file diff --git a/CalDavSynchronizer/Properties/Resources.Designer.cs b/CalDavSynchronizer/Properties/Resources.Designer.cs index ae58a5ee..24733bb3 100644 --- a/CalDavSynchronizer/Properties/Resources.Designer.cs +++ b/CalDavSynchronizer/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace CalDavSynchronizer.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -70,6 +70,26 @@ internal static System.Drawing.Bitmap About { } } + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon AddressbookReadOnly { + get { + object obj = ResourceManager.GetObject("AddressbookReadOnly", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon AddressbookReadWrite { + get { + object obj = ResourceManager.GetObject("AddressbookReadWrite", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). /// @@ -90,6 +110,26 @@ internal static System.Drawing.Bitmap ApplicationLogoLarge { } } + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon CalendarReadOnly { + get { + object obj = ResourceManager.GetObject("CalendarReadOnly", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon CalendarReadWrite { + get { + object obj = ResourceManager.GetObject("CalendarReadWrite", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// @@ -149,5 +189,25 @@ internal static System.Drawing.Bitmap SyncReport { return ((System.Drawing.Bitmap)(obj)); } } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon TasklistReadOnly { + get { + object obj = ResourceManager.GetObject("TasklistReadOnly", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon TasklistReadWrite { + get { + object obj = ResourceManager.GetObject("TasklistReadWrite", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } } } diff --git a/CalDavSynchronizer/Properties/Resources.resx b/CalDavSynchronizer/Properties/Resources.resx index 85e8dde9..a6a60188 100644 --- a/CalDavSynchronizer/Properties/Resources.resx +++ b/CalDavSynchronizer/Properties/Resources.resx @@ -145,4 +145,22 @@ ..\Resources\ApplicationLogoLarge.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + ..\Resources\AddressbookReadOnly.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\AddressbookReadWrite.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\CalendarReadOnly.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\CalendarReadWrite.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\TasklistReadOnly.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\TasklistReadWrite.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + \ No newline at end of file diff --git a/CalDavSynchronizer/Resources/AddressbookReadOnly.gfie b/CalDavSynchronizer/Resources/AddressbookReadOnly.gfie new file mode 100644 index 00000000..0a1b15e6 Binary files /dev/null and b/CalDavSynchronizer/Resources/AddressbookReadOnly.gfie differ diff --git a/CalDavSynchronizer/Resources/AddressbookReadOnly.ico b/CalDavSynchronizer/Resources/AddressbookReadOnly.ico new file mode 100644 index 00000000..93829e55 Binary files /dev/null and b/CalDavSynchronizer/Resources/AddressbookReadOnly.ico differ diff --git a/CalDavSynchronizer/Resources/AddressbookReadWrite.gfie b/CalDavSynchronizer/Resources/AddressbookReadWrite.gfie new file mode 100644 index 00000000..6b74ebd0 Binary files /dev/null and b/CalDavSynchronizer/Resources/AddressbookReadWrite.gfie differ diff --git a/CalDavSynchronizer/Resources/AddressbookReadWrite.ico b/CalDavSynchronizer/Resources/AddressbookReadWrite.ico new file mode 100644 index 00000000..b9d1c9f5 Binary files /dev/null and b/CalDavSynchronizer/Resources/AddressbookReadWrite.ico differ diff --git a/CalDavSynchronizer/Resources/CalendarReadOnly.gfie b/CalDavSynchronizer/Resources/CalendarReadOnly.gfie new file mode 100644 index 00000000..6e764eb5 Binary files /dev/null and b/CalDavSynchronizer/Resources/CalendarReadOnly.gfie differ diff --git a/CalDavSynchronizer/Resources/CalendarReadOnly.ico b/CalDavSynchronizer/Resources/CalendarReadOnly.ico new file mode 100644 index 00000000..d38127c8 Binary files /dev/null and b/CalDavSynchronizer/Resources/CalendarReadOnly.ico differ diff --git a/CalDavSynchronizer/Resources/CalendarReadWrite.gfie b/CalDavSynchronizer/Resources/CalendarReadWrite.gfie new file mode 100644 index 00000000..4dfeddcb Binary files /dev/null and b/CalDavSynchronizer/Resources/CalendarReadWrite.gfie differ diff --git a/CalDavSynchronizer/Resources/CalendarReadWrite.ico b/CalDavSynchronizer/Resources/CalendarReadWrite.ico new file mode 100644 index 00000000..b371ffab Binary files /dev/null and b/CalDavSynchronizer/Resources/CalendarReadWrite.ico differ diff --git a/CalDavSynchronizer/Resources/TasklistReadOnly.gfie b/CalDavSynchronizer/Resources/TasklistReadOnly.gfie new file mode 100644 index 00000000..bdd56967 Binary files /dev/null and b/CalDavSynchronizer/Resources/TasklistReadOnly.gfie differ diff --git a/CalDavSynchronizer/Resources/TasklistReadOnly.ico b/CalDavSynchronizer/Resources/TasklistReadOnly.ico new file mode 100644 index 00000000..52c4eac1 Binary files /dev/null and b/CalDavSynchronizer/Resources/TasklistReadOnly.ico differ diff --git a/CalDavSynchronizer/Resources/TasklistReadWrite.gfie b/CalDavSynchronizer/Resources/TasklistReadWrite.gfie new file mode 100644 index 00000000..0b68db73 Binary files /dev/null and b/CalDavSynchronizer/Resources/TasklistReadWrite.gfie differ diff --git a/CalDavSynchronizer/Resources/TasklistReadWrite.ico b/CalDavSynchronizer/Resources/TasklistReadWrite.ico new file mode 100644 index 00000000..1c5e1a96 Binary files /dev/null and b/CalDavSynchronizer/Resources/TasklistReadWrite.ico differ diff --git a/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/KolabMultipleOptionsTemplateViewModel.cs b/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/KolabMultipleOptionsTemplateViewModel.cs index 3a7f1474..4fa26ad6 100644 --- a/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/KolabMultipleOptionsTemplateViewModel.cs +++ b/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/KolabMultipleOptionsTemplateViewModel.cs @@ -23,6 +23,8 @@ using System.Net; using System.Reflection; using System.Text; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Input; using CalDavSynchronizer.Contracts; @@ -39,7 +41,11 @@ using Microsoft.Office.Interop.Outlook; using System.Text.RegularExpressions; using CalDavSynchronizer.Globalization; +using CalDavSynchronizer.Properties; using Exception = System.Exception; +using Microsoft.Win32; +using System.Drawing; +using System.Runtime.InteropServices; namespace CalDavSynchronizer.Ui.Options.BulkOptions.ViewModels { @@ -86,7 +92,7 @@ public KolabMultipleOptionsTemplateViewModel ( _discoverResourcesCommand = new DelegateCommandWithoutCanExecuteDelegation (_ => { ComponentContainer.EnsureSynchronizationContext(); - DiscoverResourcesAsync(); + DiscoverResourcesCommandAsync(); }); _mergeResourcesCommand = new DelegateCommandWithoutCanExecuteDelegation(_ => @@ -171,21 +177,80 @@ private async void MergeResourcesAsync() } } - private async void DiscoverResourcesAsync () + private async void DiscoverResourcesCommandAsync() + { + await DiscoverResourcesAsync(); + } + + private GenericComObjectWrapper findOrCreateFolder(GenericComObjectWrapper ParentFolder, String NewFolderName, object NewFolderType) + { + GenericComObjectWrapper newFolder = null; + String NewFolderNameSafe = NewFolderName.Replace('.', '_'); + try + { + // Use existing folder if it does exist. Start with raw name. + // If this fails, fall back to name with dots replaced by underscores. + s_logger.Debug($"Try to use an existing folder for '{NewFolderName}'"); + try + { + newFolder = new GenericComObjectWrapper(ParentFolder.Inner.Folders[NewFolderName] as Folder); + } + catch + { + newFolder = new GenericComObjectWrapper(ParentFolder.Inner.Folders[NewFolderNameSafe] as Folder); + } + } + catch + { + // No matching folder found, so create missing folder. Start with raw name. + // If this fails, fall back to name with dots replaced by underscores. + s_logger.Debug($"Could not find/use an existing folder for '{NewFolderName}'"); + s_logger.Info($"Create new folder for '{NewFolderName}'"); + try + { + newFolder = new GenericComObjectWrapper(ParentFolder.Inner.Folders.Add(NewFolderName, NewFolderType) as Folder); + // Make sure it has not been renamed to "name (this computer only)" + s_logger.Debug($"Make sure the folder is called '{NewFolderName}'"); + newFolder.Inner.Name = NewFolderName; + } + catch + { + try + { + newFolder = new GenericComObjectWrapper(ParentFolder.Inner.Folders.Add(NewFolderNameSafe, NewFolderType) as Folder); + // Make sure it has not been renamed to "name (this computer only)" + s_logger.Debug($"Make sure the folder is called '{NewFolderNameSafe}'"); + newFolder.Inner.Name = NewFolderNameSafe; + } + catch (Exception ex) + { + // No folder found and unable to create new one. Log as error. + s_logger.Error($"Could not create folder '{NewFolderName}'.", ex); + } + } + } + return newFolder; + } + + public async Task DiscoverResourcesAsync() { _discoverResourcesCommand.SetCanExecute (false); + ServerResources serverResources = new ServerResources(); try { - var serverResources = await _serverSettingsViewModel.GetServerResources (); + s_logger.Debug("Get CalDAV/CardDAV data from Server"); + serverResources = await _serverSettingsViewModel.GetServerResources (); + if (!serverResources.ContainsResources) + return serverResources; var calendars = serverResources.Calendars.Select (c => new CalendarDataViewModel (c)).ToArray(); var addressBooks = serverResources.AddressBooks.Select (a => new AddressBookDataViewModel (a)).ToArray(); var taskLists = serverResources.TaskLists.Select (d => new TaskListDataViewModel (d)).ToArray(); + var existingOptions = (_parent as OptionsCollectionViewModel).Options; if (OnlyAddNewUrls) { - // Exclude all resourcres that have already been configured - var options = (_parent as OptionsCollectionViewModel).Options; - var configuredUrls = new HashSet (options.Select(o => o.Model.CalenderUrl)); + s_logger.Debug("Exclude all server resources that have already been configured"); + var configuredUrls = new HashSet (existingOptions.Select(o => o.Model.CalenderUrl)); calendars = calendars.Where(c => !configuredUrls.Contains(c.Uri.ToString())).ToArray(); addressBooks = addressBooks.Where(c => !configuredUrls.Contains(c.Uri.ToString())).ToArray(); taskLists = taskLists.Where(c => !configuredUrls.Contains(c.Id)).ToArray(); @@ -193,29 +258,36 @@ private async void DiscoverResourcesAsync () // --- Create folders if requested and required if (AutoCreateOutlookFolders) { + s_logger.Debug("Auto-create outlook folders"); // https://docs.microsoft.com/en-us/visualstudio/vsto/how-to-programmatically-create-a-custom-calendar // https://msdn.microsoft.com/en-us/library/office/ff184655.aspx // Get Outlook's default calendar folder (this is where we create the Kolab folders) GenericComObjectWrapper defaultCalendarFolder = new GenericComObjectWrapper (Globals.ThisAddIn.Application.Session.GetDefaultFolder(OlDefaultFolders.olFolderCalendar) as Folder); - // Find all Kolab calendars that are not yet synced to an outlook folder + // Detect wether we have a sync option that syncs the default Outlook calendar + bool defaultCalendarFolderIsSynced = existingOptions.Any(o => o.Model.SelectedFolderOrNull?.EntryId == defaultCalendarFolder.Inner.EntryID); + + // Create and assign all Kolab calendars that are not yet synced to an outlook folder foreach (var resource in calendars.Where(c => c.SelectedFolder == null)) { string newCalendarName = resource.Name + " (" + Name + ")"; GenericComObjectWrapper newCalendarFolder = null; - try + + if (resource.Model.IsDefault && !defaultCalendarFolderIsSynced) { - // Use existing folder if it does exist - newCalendarFolder = new GenericComObjectWrapper (defaultCalendarFolder.Inner.Folders[newCalendarName] as Folder); + s_logger.Info($"Sync Calendar '{newCalendarName}' with default outlook calendar"); + newCalendarFolder = defaultCalendarFolder; } - catch + else { - // Create missing folder - newCalendarFolder = new GenericComObjectWrapper (defaultCalendarFolder.Inner.Folders.Add(newCalendarName, OlDefaultFolders.olFolderCalendar) as Folder); - // Make sure it has not been renamed to "name (this computer only)" - newCalendarFolder.Inner.Name = newCalendarName; + s_logger.Debug($"Find folder for calendar '{newCalendarName}'"); + newCalendarFolder = findOrCreateFolder(defaultCalendarFolder, newCalendarName, OlDefaultFolders.olFolderCalendar); } // use the selected folder for syncing with kolab - resource.SelectedFolder = new OutlookFolderDescriptor (newCalendarFolder.Inner.EntryID, newCalendarFolder.Inner.StoreID, newCalendarFolder.Inner.DefaultItemType, newCalendarFolder.Inner.Name, 0); + if (newCalendarFolder != null) + { + s_logger.Info($"Use calendar folder '{newCalendarFolder.Inner.Name}' in Sync setting"); + resource.SelectedFolder = new OutlookFolderDescriptor(newCalendarFolder.Inner.EntryID, newCalendarFolder.Inner.StoreID, newCalendarFolder.Inner.DefaultItemType, newCalendarFolder.Inner.Name, 0); + } } // Create and assign all Kolab address books that are not yet synced to an outlook folder @@ -223,40 +295,65 @@ private async void DiscoverResourcesAsync () foreach (var resource in addressBooks.Where(c => c.SelectedFolder == null)) { string newAddressBookName = resource.Name + " (" + Name + ")"; - GenericComObjectWrapper newAddressBookFolder = null; - try + s_logger.Debug($"Find folder for address book '{newAddressBookName}'"); + GenericComObjectWrapper newAddressBookFolder = findOrCreateFolder(defaultAddressBookFolder, newAddressBookName, OlDefaultFolders.olFolderContacts); + if (newAddressBookFolder != null) { - newAddressBookFolder = new GenericComObjectWrapper(defaultAddressBookFolder.Inner.Folders[newAddressBookName] as Folder); + newAddressBookFolder.Inner.ShowAsOutlookAB = true; + // Special handling for GAL delivered by CardDAV: set as default address list + if (resource.Uri.Segments.Last() == "ldap-directory/") + { + var _session = Globals.ThisAddIn.Application.Session; + foreach (AddressList ali in _session.AddressLists) + { + GenericComObjectWrapper al = new GenericComObjectWrapper(ali); + if (al.Inner.Name == newAddressBookName) + { + // We need to set it in the registry, as there does not seem to exist an appropriate API + // http://www.ericwoodford.com/2016/06/set-default-outlook-address-book-script.html + string regPath = + @"Software\Microsoft\Office\" + Globals.ThisAddIn.Application.Version.Split(new char[] { '.' })[0] + @".0" + + @"\Outlook\Profiles\" + _session.CurrentProfileName + + @"\9207f3e0a3b11019908b08002b2a56c2"; // Outlook default address key + var key = Registry.CurrentUser.OpenSubKey(regPath, true); + if (key != null) + { + s_logger.Info($"Configure LDAP GAL '{newAddressBookName}' as default address book."); + // Turn ID into byte array + byte[] bytes = new byte[al.Inner.ID.Length / 2]; + for (int i = 0; i < al.Inner.ID.Length; i += 2) + bytes[i / 2] = Convert.ToByte(al.Inner.ID.Substring(i, 2), 16); + // Set Outlook default address book subKey + key.SetValue("01023d06", bytes); + } + } + } + } + s_logger.Debug($"Use address book folder '{newAddressBookFolder.Inner.Name}' in Sync setting"); + resource.SelectedFolder = new OutlookFolderDescriptor(newAddressBookFolder.Inner.EntryID, newAddressBookFolder.Inner.StoreID, newAddressBookFolder.Inner.DefaultItemType, newAddressBookFolder.Inner.Name, 0); } - catch - { - newAddressBookFolder = new GenericComObjectWrapper (defaultAddressBookFolder.Inner.Folders.Add(newAddressBookName, OlDefaultFolders.olFolderContacts) as Folder); - newAddressBookFolder.Inner.Name = newAddressBookName; - } - resource.SelectedFolder = new OutlookFolderDescriptor (newAddressBookFolder.Inner.EntryID, newAddressBookFolder.Inner.StoreID, newAddressBookFolder.Inner.DefaultItemType, newAddressBookFolder.Inner.Name, 0); } - // Create and assign all Kolab address books that are not yet synced to an outlook folder + // Create and assign all Kolab task lists that are not yet synced to an outlook folder GenericComObjectWrapper defaultTaskListsFolder = new GenericComObjectWrapper (Globals.ThisAddIn.Application.Session.GetDefaultFolder(OlDefaultFolders.olFolderTasks) as Folder); foreach (var resource in taskLists.Where(c => c.SelectedFolder == null)) { string newTaskListName = resource.Name + " (" + Name + ")"; - GenericComObjectWrapper newTaskListFolder = null; - try - { - newTaskListFolder = new GenericComObjectWrapper (defaultTaskListsFolder.Inner.Folders[newTaskListName] as Folder); - } - catch + s_logger.Debug($"Find folder for task list '{newTaskListName}'"); + GenericComObjectWrapper newTaskListFolder = findOrCreateFolder(defaultTaskListsFolder, newTaskListName, OlDefaultFolders.olFolderTasks); + if (newTaskListFolder != null) { - newTaskListFolder = new GenericComObjectWrapper (defaultTaskListsFolder.Inner.Folders.Add(newTaskListName, OlDefaultFolders.olFolderTasks) as Folder); - newTaskListFolder.Inner.Name = newTaskListName; + s_logger.Info($"Use task list folder '{newTaskListFolder.Inner.Name}' in Sync setting"); + resource.SelectedFolder = new OutlookFolderDescriptor(newTaskListFolder.Inner.EntryID, newTaskListFolder.Inner.StoreID, newTaskListFolder.Inner.DefaultItemType, newTaskListFolder.Inner.Name, 0); } - resource.SelectedFolder = new OutlookFolderDescriptor(newTaskListFolder.Inner.EntryID, newTaskListFolder.Inner.StoreID, newTaskListFolder.Inner.DefaultItemType, newTaskListFolder.Inner.Name, 0); } } + using (var selectResourcesForm = SelectResourceForm.CreateForFolderAssignment(_optionTasks, ConnectionTests.ResourceType.Calendar, calendars, addressBooks, taskLists)) { - if (selectResourcesForm.ShowDialog() == System.Windows.Forms.DialogResult.OK) + // Create and add new sync profiles + s_logger.Debug("Create and add all new sync profiles"); + if (AutoConfigure || selectResourcesForm.ShowDialog() == System.Windows.Forms.DialogResult.OK) { var optionList = new List(); @@ -264,6 +361,32 @@ private async void DiscoverResourcesAsync () { var options = CreateOptions (resource); _serverSettingsViewModel.SetResourceUrl (options, resource.Model); + GenericComObjectWrapper folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(resource.SelectedFolder.EntryId) as Folder); + if (resource.Model.ReadOnly) + { + options.SynchronizationMode = SynchronizationMode.ReplicateServerIntoOutlook; + folder.Inner.Description = Strings.Get($"Read-only calendar") + $" »{options.Name}«:\n" + Strings.Get($"local changes made in Outlook are discarded and replaced by data from the server."); + try + { + // Setting the icon might fail if we sync with the default Outlook calendar folder + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.CalendarReadOnly) as stdole.StdPicture); + } + catch { } + } + else + { + options.SynchronizationMode = SynchronizationMode.MergeInBothDirections; + folder.Inner.Description = Strings.Get($"Read-write calendar") + $" »{options.Name}«:\n" + Strings.Get($"local changes made in Outlook and remote changes from the server are merged."); + try + { + // Setting the icon might fail if we sync with the default Outlook calendar folder + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.CalendarReadWrite) as stdole.StdPicture); + } + catch { } + } optionList.Add (options); } @@ -271,6 +394,22 @@ private async void DiscoverResourcesAsync () { var options = CreateOptions (resource); _serverSettingsViewModel.SetResourceUrl (options, resource.Model); + GenericComObjectWrapper folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(resource.SelectedFolder.EntryId) as Folder); + if (resource.Model.ReadOnly) + { + options.SynchronizationMode = SynchronizationMode.ReplicateServerIntoOutlook; + folder.Inner.Description = Strings.Get($"Read-only address book") + $" »{options.Name}«:\n" + Strings.Get($"local changes made in Outlook are discarded and replaced by data from the server."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.AddressbookReadOnly) as stdole.StdPicture); + } + else + { + options.SynchronizationMode = SynchronizationMode.MergeInBothDirections; + folder.Inner.Description = Strings.Get($"Read-write address book") + $" »{options.Name}«:\n" + Strings.Get($"local changes made in Outlook and remote changes from the server are merged."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.AddressbookReadWrite) as stdole.StdPicture); + } optionList.Add (options); } @@ -278,6 +417,22 @@ private async void DiscoverResourcesAsync () { var options = CreateOptions (resource); _serverSettingsViewModel.SetResourceUrl (options, resource.Model); + GenericComObjectWrapper folder = new GenericComObjectWrapper( + Globals.ThisAddIn.Application.Session.GetFolderFromID(resource.SelectedFolder.EntryId) as Folder); + if (resource.Model.ReadOnly) + { + options.SynchronizationMode = SynchronizationMode.ReplicateServerIntoOutlook; + folder.Inner.Description = Strings.Get($"Read-only task list") + $" »{options.Name}«:\n" + Strings.Get($"local changes made in Outlook are discarded and replaced by data from the server."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.TasklistReadOnly) as stdole.StdPicture); + } + else + { + options.SynchronizationMode = SynchronizationMode.MergeInBothDirections; + folder.Inner.Description = Strings.Get($"Read-write task list") + $" »{options.Name}«:\n" + Strings.Get($"local changes made in Outlook and remote changes from the server are merged."); + folder.Inner.SetCustomIcon( + PictureDispConverter.ToIPictureDisp(Resources.TasklistReadWrite) as stdole.StdPicture); + } optionList.Add (options); } @@ -289,15 +444,16 @@ private async void DiscoverResourcesAsync () catch (Exception x) { s_logger.Error ("Exception while DiscoverResourcesAsync.", x); - string message = null; + /*string message = null; for (Exception ex = x; ex != null; ex = ex.InnerException) message += ex.Message + Environment.NewLine; - MessageBox.Show (message, OptionTasks.ConnectionTestCaption); + MessageBox.Show (message, OptionTasks.ConnectionTestCaption);*/ } finally { _discoverResourcesCommand.SetCanExecute (true); } + return serverResources; } private OptionsModel CreateOptions (ResourceDataViewModelBase resource) @@ -325,7 +481,7 @@ private OptionsModel CreateOptionsWithCategory (CalendarDataViewModel resource) return options; } - private void SelectFolder() +private void SelectFolder() { var folder = _optionTasks.PickFolderOrNull(); if (folder != null && folder.DefaultItemType == OlItemType.olAppointmentItem) @@ -364,6 +520,7 @@ private set public bool IsActive { get; set; } public bool SupportsIsActive { get; } = false; public bool AutoCreateOutlookFolders { get; set; } = false; + public bool AutoConfigure { get; set; } = false; public bool OnlyAddNewUrls { get; set; } = false; public IEnumerable Items { get; } IEnumerable ITreeNodeViewModel.Items => Items; @@ -397,4 +554,80 @@ public string Name public IViewOptions ViewOptions { get; } public OptionsModel Model => _prototypeModel; } + + // See https://msdn.microsoft.com/en-us/VBA/Outlook-VBA/articles/folder-setcustomicon-method-outlook + public static class PictureDispConverter + { + // IPictureDisp GUID. + public static Guid iPictureDispGuid = typeof(stdole.IPictureDisp).GUID; + + // Converts an icon into an IPictureDisp. + public static stdole.IPictureDisp ToIPictureDisp(Icon icon) + { + PICTDESC.Icon pictIcon = new PICTDESC.Icon(icon); + return PictureDispConverter.OleCreatePictureIndirect(pictIcon, ref iPictureDispGuid, true); + } + + // Converts an image into an IPictureDisp. + public static stdole.IPictureDisp ToIPictureDisp(Image image) + { + Bitmap bitmap = (image is Bitmap) ? (Bitmap)image : new Bitmap(image); + PICTDESC.Bitmap pictBit = new PICTDESC.Bitmap(bitmap); + return PictureDispConverter.OleCreatePictureIndirect(pictBit, ref iPictureDispGuid, true); + } + + [DllImport("OleAut32.dll", EntryPoint = "OleCreatePictureIndirect", ExactSpelling = true, + PreserveSig = false)] + private static extern stdole.IPictureDisp OleCreatePictureIndirect( + [MarshalAs(UnmanagedType.AsAny)] object picdesc, ref Guid iid, bool fOwn); + + private readonly static HandleCollector handleCollector = + new HandleCollector("Icon handles", 1000); + + // WINFORMS COMMENT: + // PICTDESC is a union in native, so we'll just + // define different ones for the different types + // the "unused" fields are there to make it the right + // size, since the struct in native is as big as the biggest + // union. + private static class PICTDESC + { + // Picture Types + public const short PICTYPE_UNINITIALIZED = -1; + public const short PICTYPE_NONE = 0; + public const short PICTYPE_BITMAP = 1; + public const short PICTYPE_METAFILE = 2; + public const short PICTYPE_ICON = 3; + public const short PICTYPE_ENHMETAFILE = 4; + + [StructLayout(LayoutKind.Sequential)] + public class Icon + { + internal int cbSizeOfStruct = Marshal.SizeOf(typeof(PICTDESC.Icon)); + internal int picType = PICTDESC.PICTYPE_ICON; + internal IntPtr hicon = IntPtr.Zero; + internal int unused1 = 0; + internal int unused2 = 0; + + internal Icon(System.Drawing.Icon icon) + { + this.hicon = icon.ToBitmap().GetHicon(); + } + } + + [StructLayout(LayoutKind.Sequential)] + public class Bitmap + { + internal int cbSizeOfStruct = Marshal.SizeOf(typeof(PICTDESC.Bitmap)); + internal int picType = PICTDESC.PICTYPE_BITMAP; + internal IntPtr hbitmap = IntPtr.Zero; + internal IntPtr hpal = IntPtr.Zero; + internal int unused = 0; + internal Bitmap(System.Drawing.Bitmap bitmap) + { + this.hbitmap = bitmap.GetHbitmap(); + } + } + } + } } \ No newline at end of file diff --git a/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerResources.cs b/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerResources.cs index 8296506e..eee47b19 100644 --- a/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerResources.cs +++ b/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerResources.cs @@ -44,6 +44,9 @@ public ServerResources ( TaskLists = taskLists; } - public bool ContainsResources => Calendars.Count > 0 || AddressBooks.Count > 0 || TaskLists.Count > 0; + public bool ContainsResources => + (Calendars != null && Calendars.Count > 0) || + (AddressBooks != null && AddressBooks.Count > 0) || + (TaskLists != null && TaskLists.Count > 0); } } \ No newline at end of file diff --git a/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerSettingsTemplateViewModel.cs b/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerSettingsTemplateViewModel.cs index 926de740..72bd5f72 100644 --- a/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerSettingsTemplateViewModel.cs +++ b/CalDavSynchronizer/Ui/Options/BulkOptions/ViewModels/ServerSettingsTemplateViewModel.cs @@ -106,6 +106,7 @@ public async Task GetServerResources () if (string.IsNullOrEmpty (CalenderUrl) && !string.IsNullOrEmpty (EmailAddress)) { + s_logger.Debug("Auto-detect CaldAV/CardDAV URL"); bool success; caldavUrlString = OptionTasks.DoSrvLookup (EmailAddress, OlItemType.olAppointmentItem, out success); carddavUrlString = OptionTasks.DoSrvLookup (EmailAddress, OlItemType.olContactItem, out success); @@ -127,6 +128,7 @@ public async Task GetServerResources () var calDavDataAccess = new CalDavDataAccess (caldavUrl, webDavClientCaldav); var cardDavDataAccess = new CardDavDataAccess (carddavUrl, webDavClientCarddav, string.Empty, contentType => true); + s_logger.Debug("Get User's resources"); return await GetUserResources (calDavDataAccess, cardDavDataAccess); } diff --git a/CalDavSynchronizer/Ui/Options/ViewModels/OptionsCollectionViewModel.cs b/CalDavSynchronizer/Ui/Options/ViewModels/OptionsCollectionViewModel.cs index 9ec04a51..a55c75da 100644 --- a/CalDavSynchronizer/Ui/Options/ViewModels/OptionsCollectionViewModel.cs +++ b/CalDavSynchronizer/Ui/Options/ViewModels/OptionsCollectionViewModel.cs @@ -301,7 +301,15 @@ private void AddMultiple () ShowProfile(viewModel.Model.Id); } } - + + public IOptionsViewModel AddMultipleHeadless (IProfileType type) + { + var profileModelFactoryFactory = _profileModelFactoriesByType[type]; + var viewModel = profileModelFactoryFactory.CreateTemplateViewModel(); + _options.Add(viewModel); + return viewModel; + } + private IProfileType QueryProfileType() { return _uiService.QueryProfileType(_profileTypeRegistry.AllTypes);