diff --git a/.gitignore b/.gitignore index d1be2afd..55a2d688 100755 --- a/.gitignore +++ b/.gitignore @@ -65,8 +65,8 @@ SuiteCRMAddIn/Documentation/latex/ *.pcapng -*.vsp +*.xlsx *.psess -*.xlsx +*.vsp diff --git a/Doxyfile b/Doxyfile index 866b13bf..6802dea4 100644 --- a/Doxyfile +++ b/Doxyfile @@ -38,7 +38,7 @@ PROJECT_NAME = "SuiteCRM Outlook Add-in" # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 3.0.11.0 +PROJECT_NUMBER = 3.0.12.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/SuiteCRMAddIn/BusinessLogic/AppointmentSyncState.cs b/SuiteCRMAddIn/BusinessLogic/AppointmentSyncState.cs index ee4795aa..95959af0 100644 --- a/SuiteCRMAddIn/BusinessLogic/AppointmentSyncState.cs +++ b/SuiteCRMAddIn/BusinessLogic/AppointmentSyncState.cs @@ -29,6 +29,8 @@ namespace SuiteCRMAddIn.BusinessLogic using Outlook = Microsoft.Office.Interop.Outlook; using System; using System.Collections.Generic; + using System.Runtime.InteropServices; + using SuiteCRMClient.Logging; public class AppointmentSyncState: SyncState { @@ -36,6 +38,16 @@ public AppointmentSyncState() { } + /// + /// When we're asked for the CrmType the underlying object may have ceased to + /// exist - so cache it! + /// + private string crmType; + + + /// + /// The CRM type of the item I represent. + /// public override string CrmType { get @@ -45,10 +57,17 @@ public override string CrmType switch (olItem.MeetingStatus) { case Outlook.OlMeetingStatus.olNonMeeting: - return AppointmentSyncing.AltCrmModule; + crmType = AppointmentSyncing.AltCrmModule; + break; default: - return AppointmentSyncing.CrmModule; + crmType = AppointmentSyncing.CrmModule; + break; } + return crmType; + } + catch (COMException) + { + return crmType; } catch (Exception) { @@ -87,51 +106,6 @@ public override void DeleteItem() this.OutlookItem.Delete(); } - - /// - /// An appointment may be changed because its recipients have changed. - /// - /// True if the underlying item has changed, or its recipients have changed. - protected override bool ReallyChanged() - { - var result = base.ReallyChanged(); - - ProtoItem older = this.Cache as ProtoAppointment; - - if (older != null) - { - var currentAddresses = new HashSet(); - var olderAddresses = new List(); - olderAddresses.AddRange(((ProtoAppointment)older).RecipientAddresses); - - foreach (Outlook.Recipient recipient in olItem.Recipients) - { - currentAddresses.Add(recipient.GetSmtpAddress()); - } - if (currentAddresses.Count == olderAddresses.Count) - { - var sorted = new List(); - sorted.AddRange(currentAddresses); - sorted.Sort(); - - for (int i = 0; i < sorted.Count; i++) - { - if (sorted[i] != olderAddresses[i]) - { - result = true; - break; - } - } - } - else - { - result = true; - } - } - - return result; - } - /// /// Construct a JSON-serialisable representation of my appointment item. /// diff --git a/SuiteCRMAddIn/BusinessLogic/AppointmentSyncing.cs b/SuiteCRMAddIn/BusinessLogic/AppointmentSyncing.cs index e159ab48..da4e4433 100644 --- a/SuiteCRMAddIn/BusinessLogic/AppointmentSyncing.cs +++ b/SuiteCRMAddIn/BusinessLogic/AppointmentSyncing.cs @@ -80,6 +80,32 @@ public AppointmentSyncing(string name, SyncContext context) this.fetchQueryPrefix = "assigned_user_id = '{0}'"; } + /// + /// Get the id of the record with the specified `smtpAddress` in the module with the specified `moduleName`. + /// + /// The SMTP email address to be sought. + /// The name of the module in which to seek it. + /// The corresponding id, if present, else the empty string. + public string GetInviteeIdBySmtpAddress(string smtpAddress, string moduleName) + { + StringBuilder bob = new StringBuilder( $"({moduleName.ToLower()}.id in ") + .Append( $"(select eabr.bean_id from email_addr_bean_rel eabr ") + .Append( $"INNER JOIN email_addresses ea on eabr.email_address_id = ea.id ") + .Append( $"where eabr.bean_module = '{moduleName}' ") + .Append( $"and ea.email_address LIKE '%{RestAPIWrapper.MySqlEscape(smtpAddress)}%'))"); + + string query = bob.ToString(); + + Log.Debug($"AppointmentSyncing.GetID: query = `{query}`"); + + string[] fields = { "id" }; + EntryList _result = RestAPIWrapper.GetEntryList(moduleName, query, Properties.Settings.Default.SyncMaxRecords, "date_entered DESC", 0, false, fields); + + return _result.result_count > 0 ? + RestAPIWrapper.GetValueByKey(_result.entry_list[0], "id") : + string.Empty; + } + override public Outlook.MAPIFolder GetDefaultFolder() { return Application.Session.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderCalendar); @@ -87,6 +113,7 @@ override public Outlook.MAPIFolder GetDefaultFolder() public override SyncDirection.Direction Direction => Properties.Settings.Default.SyncCalendar; + protected override void SaveItem(Outlook.AppointmentItem olItem) { try @@ -131,21 +158,11 @@ public override string DefaultCrmModule } } - /// - /// The actual transmission lock object of this synchroniser. - /// - private object txLock = new object(); /// - /// Allow my parent class to access my transmission lock object. + /// Prefix for meetings which have been canceled. /// - protected override object TransmissionLock - { - get - { - return txLock; - } - } + private static readonly string CanceledPrefix = "CANCELED"; /// @@ -199,10 +216,18 @@ private int CheckMeetingAcceptances() { Outlook.AppointmentItem item = state.OutlookItem; - if (item.UserProperties[OrganiserPropertyName]?.Value == RestAPIWrapper.GetUserId() && - item.Start > DateTime.Now) + try { - result += AddOrUpdateMeetingAcceptanceFromOutlookToCRM(item); + if (item.UserProperties[OrganiserPropertyName]?.Value == RestAPIWrapper.GetUserId() && + item.Start > DateTime.Now) + { + result += AddOrUpdateMeetingAcceptanceFromOutlookToCRM(item); + } + } + catch (COMException comx) + { + Log.Error($"Item with CRMid {state.CrmEntryId} appears to be invalid (HResult {comx.HResult})", comx); + this.HandleItemMissingFromOutlook(state); } } @@ -210,6 +235,19 @@ private int CheckMeetingAcceptances() } + /// + /// Check meeting acceptances for the invitees of this `meeting`. + /// + /// The meeting. + /// the number of valid acceptance statuses found. + public int UpdateMeetingAcceptances(Outlook.MeetingItem meeting) + { + return meeting == null ? + 0 : + this.AddOrUpdateMeetingAcceptanceFromOutlookToCRM(meeting.GetAssociatedAppointment(false)); + } + + /// /// Set the meeting acceptance status, in CRM, of all invitees to this meeting from /// their acceptance status in Outlook. @@ -258,16 +296,18 @@ private int AddOrUpdateMeetingAcceptanceFromOutlookToCRM(Outlook.AppointmentItem return count; } - private void AddOrUpdateMeetingRecipientsFromOutlookToCrm(Outlook.AppointmentItem olItem, string meetingId) + private void AddMeetingRecipientsFromOutlookToCrm(Outlook.AppointmentItem olItem, string meetingId) { LogItemAction(olItem, "AppointmentSyncing.AddMeetingRecipientsFromOutlookToCrm"); foreach (Outlook.Recipient recipient in olItem.Recipients) { var smtpAddress = recipient.GetSmtpAddress(); - Log.Debug($"recipientName= {recipient.Name}, recipient= {smtpAddress}"); + Log.Info($"recepientName= {recipient.Name}, recepient= {smtpAddress}"); + + List resolutions = this.ResolveRecipient(olItem, recipient); - foreach (AddressResolutionData resolution in this.ResolveRecipient(olItem, recipient)) + foreach (AddressResolutionData resolution in resolutions) { SetCrmRelationshipFromOutlook(meetingId, resolution); } @@ -287,7 +327,7 @@ private List ResolveRecipient(Outlook.AppointmentItem olI List result = new List(); var smtpAddress = recipient.GetSmtpAddress(); - Log.Debug($"recipientName= {recipient.Name}, recipientAddress= {smtpAddress}"); + Log.Info($"recepientName= {recipient.Name}, recepient= {smtpAddress}"); if (this.meetingRecipientsCache.ContainsKey(smtpAddress)) { @@ -296,23 +336,36 @@ private List ResolveRecipient(Outlook.AppointmentItem olI else { string meetingId = olItem.UserProperties[AppointmentSyncing.CrmIdPropertyName]?.Value; - Dictionary> moduleIds = new Dictionary>(); + Dictionary moduleIds = new Dictionary(); if (!string.IsNullOrEmpty(meetingId)) { foreach (string moduleName in new string[] { "Leads", "Users", ContactSyncing.CrmModule }) { - var resolutions = ResolveRecipientWithinModule(smtpAddress, moduleName); - if (resolutions.Count() > 0) + string moduleId = this.GetInviteeIdBySmtpAddress(smtpAddress, moduleName); + if (!string.IsNullOrEmpty(moduleId)) { - moduleIds[moduleName] = resolutions.Select(x => x.moduleId); - result.AddRange(resolutions); + moduleIds[moduleName] = moduleId; + AddressResolutionData data = new AddressResolutionData(moduleName, meetingId, smtpAddress); + this.CacheAddressResolutionData(data); + result.Add(data); } } if (moduleIds.ContainsKey(ContactSyncing.CrmModule)) { - result.AddRange(ResolveRecipientAccounts(smtpAddress, meetingId, moduleIds[ContactSyncing.CrmModule])); + string accountId = RestAPIWrapper.GetRelationship( + ContactSyncing.CrmModule, + moduleIds[ContactSyncing.CrmModule], + "accounts"); + + if (!string.IsNullOrWhiteSpace(accountId) && + SetCrmRelationshipFromOutlook(meetingId, "Accounts", accountId)) + { + var data = new AddressResolutionData("Accounts", accountId, smtpAddress); + this.CacheAddressResolutionData(data); + result.Add(data); + } } } } @@ -320,74 +373,37 @@ private List ResolveRecipient(Outlook.AppointmentItem olI return result; } - - /// - /// Resolve all entries within the Accounts module which are linked via a contact to this SMTP address - /// - /// The SMTP address to seek. - /// The id of the meeting whose recipients are to be resolved. - /// The ids of contacts who are recipients of this meeting. - /// A list of resolutions of entries within the Accounts module which are related to this SMTP address. - private List ResolveRecipientAccounts(string smtpAddress, string meetingId, IEnumerable contactIds) + private bool TryAddRecipientInModule(string moduleName, string meetingId, Outlook.Recipient recipient) { - List result = new List(); + bool result; + string id = SetCrmRelationshipFromOutlook(meetingId, recipient, moduleName); - foreach (string resolution in contactIds) + if (!string.IsNullOrWhiteSpace(id)) { - string accountId = RestAPIWrapper.GetRelationship( - ContactSyncing.CrmModule, - resolution, - "accounts"); + string smtpAddress = recipient.GetSmtpAddress(); + + this.CacheAddressResolutionData( + new AddressResolutionData(moduleName, id, smtpAddress)); + + string accountId = RestAPIWrapper.GetRelationship(ContactSyncing.CrmModule, id, "accounts"); if (!string.IsNullOrWhiteSpace(accountId) && SetCrmRelationshipFromOutlook(meetingId, "Accounts", accountId)) { - var data = new AddressResolutionData("Accounts", accountId, smtpAddress); - this.CacheAddressResolutionData(data); - result.Add(data); + this.CacheAddressResolutionData( + new AddressResolutionData("Accounts", accountId, smtpAddress)); } + + result = true; + } + else + { + result = false; } return result; } - /// - /// Resolve all entries within this named module which match this smtpAddress - /// - /// The SMTP address to seek - /// The name of the module in which to seek it. - /// A list of resolutions of entries within the named module which match this SMTP address. - private IEnumerable ResolveRecipientWithinModule(string smtpAddress, string moduleName) - { - return this.ResolveSmtpAddressIdsWithinModule(smtpAddress, moduleName) - .Select(x => this.CacheAddressResolutionData(new AddressResolutionData(moduleName, x, smtpAddress))); - } - - - /// - /// Get the id of the record with the specified `smtpAddress` in the module with the specified `moduleName`. - /// - /// The SMTP email address to be sought. - /// The name of the module in which to seek it. - /// The corresponding id, if present, else the empty string. - private IEnumerable ResolveSmtpAddressIdsWithinModule(string smtpAddress, string moduleName) - { - StringBuilder bob = new StringBuilder($"({moduleName.ToLower()}.id in ") - .Append($"(select eabr.bean_id from email_addr_bean_rel eabr ") - .Append($"INNER JOIN email_addresses ea on eabr.email_address_id = ea.id ") - .Append($"where eabr.bean_module = '{moduleName}' ") - .Append($"and ea.email_address LIKE '%{RestAPIWrapper.MySqlEscape(smtpAddress)}%'))"); - - string query = bob.ToString(); - - Log.Debug($"AppointmentSyncing.GetID: query = `{query}`"); - - string[] fields = { "id" }; - EntryList entries = RestAPIWrapper.GetEntryList(moduleName, query, Properties.Settings.Default.SyncMaxRecords, "date_entered DESC", 0, false, fields); - - return entries.entry_list.Select(x => RestAPIWrapper.GetValueByKey(x, "id")); - } - /// /// If a meeting was created in another Outlook we should NOT sync it with CRM because if we do we'll create @@ -539,10 +555,21 @@ protected override void EnsureSynchronisationPropertiesForOutlookItem(Outlook.Ap internal override string AddOrUpdateItemFromOutlookToCrm(SyncState syncState) { Outlook.AppointmentItem olItem = syncState.OutlookItem; - Outlook.UserProperty olPropertyType = olItem.UserProperties[TypePropertyName]; - var itemType = olPropertyType != null ? olPropertyType.Value.ToString() : this.DefaultCrmModule; + Outlook.UserProperty olPropertyType = null; + + try + { + olPropertyType = olItem.UserProperties[TypePropertyName]; + + var itemType = olPropertyType != null ? olPropertyType.Value.ToString() : this.DefaultCrmModule; - return this.AddOrUpdateItemFromOutlookToCrm(syncState, itemType, syncState.CrmEntryId); + return this.AddOrUpdateItemFromOutlookToCrm(syncState, itemType, syncState.CrmEntryId); + } + catch (COMException) + { + this.HandleItemMissingFromOutlook(syncState); + return syncState.CrmEntryId; + } } /// @@ -579,17 +606,21 @@ internal override string AddOrUpdateItemFromOutlookToCrm(SyncStateThe CRM id of the object created or modified. protected override string ConstructAndDespatchCrmItem(Outlook.AppointmentItem olItem, string crmType, string entryId) { - string result; - - try - { - result = RestAPIWrapper.SetEntry(new ProtoAppointment(olItem).AsNameValues(entryId), crmType); - } - catch (Exception fail) - { - Log.Error($"Failed to set entry for appointment {olItem.GlobalAppointmentID} `{olItem.Subject}`", fail); - result = string.Empty; - } - - return result; + return RestAPIWrapper.SetEntryUnsafe(new ProtoAppointment(olItem).AsNameValues(entryId), crmType); } @@ -645,7 +664,7 @@ private void DeleteFromCrm(Outlook.AppointmentItem olItem) if (syncStateForItem != null) { this.RemoveFromCrm(syncStateForItem); - RemoveItemSyncState(syncStateForItem); + this.RemoveItemSyncState(syncStateForItem); } } } @@ -713,6 +732,7 @@ internal override void LogItemAction(Outlook.AppointmentItem olItem, string mess .Append($"\n\tOrganiser : {olItem.Organizer}") .Append($"\n\tOutlook User: {clsGlobals.GetCurrentUsername()}") .Append($"\n\tRecipients :\n"); + foreach (Outlook.Recipient recipient in olItem.Recipients) { bob.Append($"\t\t{recipient.Name}: {recipient.GetSmtpAddress()} - ({recipient.MeetingResponseStatus})\n"); @@ -757,7 +777,6 @@ internal override void LogItemAction(Outlook.AppointmentItem olItem, string mess } else { - result = matches[0]; this.Log.Warn($"Howlaround detected? Appointment '{crmItem.GetValueAsString("name")}' offered with id {crmItem.GetValueAsString("id")}, expected {matches[0].CrmEntryId}, {matches.Count} duplicates"); } } @@ -776,7 +795,6 @@ internal override void LogItemAction(Outlook.AppointmentItem olItem, string mess foreach (var record in list.records) { var data = record.data.AsDictionary(); - result.OutlookItem.EnsureRecipient(data["email1"].ToString()); try { this.CacheAddressResolutionData(list.name, record); @@ -793,27 +811,100 @@ internal override void LogItemAction(Outlook.AppointmentItem olItem, string mess return result; } + internal override void HandleItemMissingFromOutlook(SyncState syncState) + { + if (syncState.CrmType == AppointmentSyncing.CrmModule) + { + /* typically, when this method is called, the Outlook Item will already be invalid, and if it is not, + * it may become invalid during the execution of this method. So this method CANNOT depend on any + * values taken from the Outlook item. */ + EntryList entries = RestAPIWrapper.GetEntryList( + syncState.CrmType, $"id = {syncState.CrmEntryId}", + Properties.Settings.Default.SyncMaxRecords, + "date_entered DESC", 0, false, null); + + if (entries.entry_list.Count() > 0) + { + this.HandleItemMissingFromOutlook(entries.entry_list[0], syncState, syncState.CrmType); + } + } + } + + + /// + /// Override: we get notified of a removal, for a Meeting item, when the meeting is + /// cancelled. We do NOT want to remove such an item; instead, we want to update it. + /// + /// + protected override void RemoveFromCrm(SyncState state) + { + if (state.CrmType == AppointmentSyncing.CrmModule) + { + this.AddOrUpdateItemFromOutlookToCrm((SyncState)state); + } + else + { + base.RemoveFromCrm(state); + } + } + + + /// + /// Typically, when handling an item missing from outlook, the outlook item is missing and so can't + /// be relied on; treat this record as representing the current, known state of the item. + /// + /// A record fetched from CRM representing the current state of the item. + /// The sync state representing the item. + /// The name/key of the CRM module in which the item exists. + private void HandleItemMissingFromOutlook(EntryValue record, SyncState syncState, string crmModule) + { + try + { + if (record.GetValueAsDateTime("date_start") > DateTime.Now && crmModule == AppointmentSyncing.CrmModule) + { + /* meeting in the future: mark it as canceled, do not delete it */ + record.GetBinding("status").value = "NotHeld"; + + string description = record.GetValue("description").ToString(); + if (!description.StartsWith(AppointmentSyncing.CanceledPrefix)) + { + record.GetBinding("description").value = $"{AppointmentSyncing.CanceledPrefix}: {description}"; + RestAPIWrapper.SetEntry(record.nameValueList, crmModule); + } + } + else + { + /* meeting in the past: just delete it */ + this.RemoveFromCrm(syncState); + this.RemoveItemSyncState(syncState); + } + } + catch (Exception any) + { + /* what could possibly go wrong? */ + this.Log.Error($"Failed in HandleItemMissingFromOutlook for CRM Id {syncState.CrmEntryId}", any); + } + } + /// /// Add an address resolution composed from this module name and record to the cache. /// /// The name of the module in which the record was found /// The record. - /// The resolution data cached. - private AddressResolutionData CacheAddressResolutionData(string moduleName, LinkRecord record) + private void CacheAddressResolutionData(string moduleName, LinkRecord record) { Dictionary data = record.data.AsDictionary(); string smtpAddress = data[AddressResolutionData.EmailAddressFieldName].ToString(); AddressResolutionData resolution = new AddressResolutionData(moduleName, data); - return CacheAddressResolutionData(resolution); + CacheAddressResolutionData(resolution); } /// /// Add this resolution to the cache. /// /// The resolution to add. - /// The resolution data cached. - private AddressResolutionData CacheAddressResolutionData(AddressResolutionData resolution) + private void CacheAddressResolutionData(AddressResolutionData resolution) { List resolutions; @@ -832,8 +923,6 @@ private AddressResolutionData CacheAddressResolutionData(AddressResolutionData r } Log.Debug($"Successfully cached recipient {resolution.emailAddress} => {resolution.moduleName}, {resolution.moduleId}."); - - return resolution; } protected override bool IsMatch(Outlook.AppointmentItem olItem, EntryValue crmItem) @@ -898,11 +987,14 @@ private static void RemoveSynchronisationPropertyFromOutlookItem(Outlook.Appoint /// The outlook recipient representing the person to link with. /// the name of the module we're seeking to link with. /// True if a relationship was created. - private IEnumerable SetCrmRelationshipFromOutlook(string meetingId, Outlook.Recipient recipient, string foreignModule) + private string SetCrmRelationshipFromOutlook(string meetingId, Outlook.Recipient recipient, string foreignModule) { - IEnumerable foreignIds = ResolveSmtpAddressIdsWithinModule(recipient.GetSmtpAddress(), foreignModule); + string foreignId = GetInviteeIdBySmtpAddress(recipient.GetSmtpAddress(), foreignModule); - return foreignIds.Where(x => !string.IsNullOrWhiteSpace(x) && SetCrmRelationshipFromOutlook(meetingId, foreignModule, x)); + return !string.IsNullOrWhiteSpace(foreignId) && + SetCrmRelationshipFromOutlook(meetingId, foreignModule, foreignId) ? + foreignId : + string.Empty; } @@ -952,6 +1044,11 @@ private void SetRecipients(Outlook.AppointmentItem olItem, string sMeetingID, st try { olItem.MeetingStatus = Outlook.OlMeetingStatus.olMeeting; + int iCount = olItem.Recipients.Count; + for (int iItr = 1; iItr <= iCount; iItr++) + { + olItem.Recipients.Remove(1); + } string[] invitee_categories = { "users", ContactSyncing.CrmModule, "leads" }; foreach (string invitee_category in invitee_categories) @@ -959,13 +1056,18 @@ private void SetRecipients(Outlook.AppointmentItem olItem, string sMeetingID, st EntryValue[] relationships = RestAPIWrapper.GetRelationships(sModule, sMeetingID, invitee_category, new string[] { "id", "email1", "phone_work" }); if (relationships != null) { + foreach (var relationship in relationships) { + string phone_work = relationship.GetValueAsString("phone_work"); string email1 = relationship.GetValueAsString("email1"); + string identifier = (sModule == AppointmentSyncing.CrmModule) || string.IsNullOrWhiteSpace(phone_work) ? + email1 : + $"{email1} : {phone_work}"; - if (!String.IsNullOrWhiteSpace(email1)) + if (!String.IsNullOrWhiteSpace(identifier)) { - olItem.EnsureRecipient(email1); + olItem.Recipients.Add(identifier); } } } @@ -1000,10 +1102,21 @@ private bool ShouldDeleteFromCrm(Outlook.AppointmentItem olItem) private bool ShouldDespatchToCrm(Outlook.AppointmentItem olItem) { var syncConfigured = SyncDirection.AllowOutbound(Properties.Settings.Default.SyncCalendar); + string organiser = olItem.Organizer; + var currentUser = Application.Session.CurrentUser; + var exchangeUser = currentUser.AddressEntry.GetExchangeUser(); + var currentUserName = exchangeUser == null ? + Application.Session.CurrentUser.Name: + exchangeUser.Name; + string crmId = olItem.UserProperties[CrmIdPropertyName]?.Value; return olItem != null && syncConfigured && olItem.Sensitivity == Outlook.OlSensitivity.olNormal && + /* If there is a valid crmId it's arrived via CRM and is therefore safe to save to CRM; + * if the current user is the organiser, AND there's no valid CRM id, then it's a new one + * that the current user made, and we should save it to CRM. */ + (!string.IsNullOrEmpty(crmId) || currentUserName == organiser) && /* Microsoft Conferencing Add-in creates temporary items with names which start * 'PLEASE IGNORE' - we should not sync these. */ !olItem.Subject.StartsWith(MSConfTmpSubjectPrefix); diff --git a/SuiteCRMAddIn/BusinessLogic/ContactSyncState.cs b/SuiteCRMAddIn/BusinessLogic/ContactSyncState.cs index 5b2f3196..dda4b7e3 100644 --- a/SuiteCRMAddIn/BusinessLogic/ContactSyncState.cs +++ b/SuiteCRMAddIn/BusinessLogic/ContactSyncState.cs @@ -26,6 +26,8 @@ namespace SuiteCRMAddIn.BusinessLogic using SuiteCRMAddIn.ProtoItems; using Extensions; using Outlook = Microsoft.Office.Interop.Outlook; + using System.Runtime.InteropServices; + using SuiteCRMClient.Logging; /// /// A SyncState for Contact items. @@ -54,6 +56,7 @@ public override string Description } } + /// /// Don't actually delete contact items from Outlook; instead, mark them private so they /// don't get copied back to CRM. diff --git a/SuiteCRMAddIn/BusinessLogic/ContactSyncing.cs b/SuiteCRMAddIn/BusinessLogic/ContactSyncing.cs index 44a0dbb4..b56ddca2 100644 --- a/SuiteCRMAddIn/BusinessLogic/ContactSyncing.cs +++ b/SuiteCRMAddIn/BusinessLogic/ContactSyncing.cs @@ -51,21 +51,6 @@ public ContactSyncing(string name, SyncContext context) public override SyncDirection.Direction Direction => Properties.Settings.Default.SyncContacts; - /// - /// The actual transmission lock object of this synchroniser. - /// - private object txLock = new object(); - - /// - /// Allow my parent class to access my transmission lock object. - /// - protected override object TransmissionLock - { - get - { - return txLock; - } - } public override string DefaultCrmModule { diff --git a/SuiteCRMAddIn/BusinessLogic/EmailArchiving.cs b/SuiteCRMAddIn/BusinessLogic/EmailArchiving.cs index 4b46292b..01b1bc1d 100644 --- a/SuiteCRMAddIn/BusinessLogic/EmailArchiving.cs +++ b/SuiteCRMAddIn/BusinessLogic/EmailArchiving.cs @@ -54,6 +54,11 @@ public class EmailArchiving : RepeatingProcess /// public const string EmailDateFormat = "yyyy-MM-dd HH:mm:ss"; + /// + /// The modules to which we'll try to save if no more specific list of modules is specified. + /// + public static readonly List defaultModuleKeys = new List() { ContactSyncing.CrmModule, "Leads" }; + public EmailArchiving(string name, ILogger log) : base(name, log) { } @@ -86,34 +91,101 @@ private bool FolderShouldBeAutoArchived(string folderEntryId) => Properties.Settings.Default.AutoArchiveFolders?.Contains(folderEntryId) ?? false; private void ArchiveFolderItems(Outlook.Folder objFolder, DateTime minReceivedDateTime) + { + this.ArchiveFolderItems(objFolder, minReceivedDateTime, defaultModuleKeys); + } + + + /// + /// Archive items in the specified folder which are email items, and which have been + /// received since the specified date. + /// + /// + /// I don't understand all of this. I particularly don't understand why we ever call it + /// on folders whose content are not mail items. + /// + /// The folder to archive. + /// The date to search from. + /// The keys of the modules to which we'll seek to relate the archived item. + private void ArchiveFolderItems(Outlook.Folder folder, DateTime minReceivedDateTime, IEnumerable moduleKeys) { try { - var unreadEmails = objFolder.Items.Restrict( - $"[ReceivedTime] >= \'{minReceivedDateTime.AddDays(-1):yyyy-MM-dd HH:mm}\'"); + /* safe but undesirable fallback - if we cannot identify a property to restrict by, + * search the whole folder */ + Outlook.Items candidateItems = folder.Items; - for (int i = 1; i <= unreadEmails.Count; i++) + if (folder.DefaultItemType == Outlook.OlItemType.olMailItem) { - var olItem = unreadEmails[i] as Outlook.MailItem; - if (olItem != null) + foreach (string property in new string[] { "ReceivedTime", "LastModificationTime" }) { try { - olItem.Archive(EmailArchiveReason.Inbound); + candidateItems = folder.Items.Restrict( + $"[{property}] >= \'{minReceivedDateTime.AddDays(-1):yyyy-MM-dd HH:mm}\'"); + break; } - catch (Exception any) + catch (COMException) { - Log.Error($"Failed to archive email '{olItem.Subject}' from '{olItem.GetSenderSMTPAddress()}", any); + Log.Warn($"EmailArchiving.ArchiveFolderItems; Items in folder {folder.Name} do not have a {property} property"); + } + } + + + foreach (var candidate in candidateItems) + { + var comType = Microsoft.VisualBasic.Information.TypeName(candidate); + + + switch (comType) + { + case "MailItem": + ArchiveMailItem(candidate, moduleKeys); + break; + case "MeetingItem": + case "ReportItem": + Log.Debug($"EmailArchiving.ArchiveFolderItems; candidate is a '{comType}', we don't archive these"); + break; + default: + Log.Debug($"EmailArchiving.ArchiveFolderItems; candidate is a '{comType}', don't know how to archive these"); + break; } } } + else + { + Log.Debug($"EmailArchiving.ArchiveFolderItems; Folder {folder.Name} does not contain mail items, not archiving"); + } } catch (Exception ex) { - Log.Error($"EmailArchiving.ArchiveFolderItems; folder {objFolder.Name}:", ex); + Log.Error($"EmailArchiving.ArchiveFolderItems; folder {folder.Name}:", ex); } } + + /// + /// Archive an item believed to be an Outlook.MailItem. + /// + /// The item to archive. + /// Keys of module(s) to relate the item to. + private void ArchiveMailItem(object item, IEnumerable moduleKeys) + { + var olItem = item as Outlook.MailItem; + if (olItem != null) + { + try + { + olItem.Archive(EmailArchiveReason.Inbound, moduleKeys.Select(x => new CrmEntity(x, null))); + } + catch (Exception any) + { + Log.Error($"EmailArchiving.ArchiveFolderItems; Failed to archive MailItem '{olItem.Subject}' from '{olItem.GetSenderSMTPAddress()}", any); + } + } + } + + public void ProcessEligibleNewMailItem(Outlook.MailItem olItem, EmailArchiveReason reason, string excludedEmails = "") { var parentFolder = olItem.Parent as Outlook.Folder; @@ -125,7 +197,7 @@ public void ProcessEligibleNewMailItem(Outlook.MailItem olItem, EmailArchiveReas if (EmailShouldBeArchived(reason, parentFolder.Store)) { - olItem.Archive(reason, excludedEmails); + olItem.Archive(reason, defaultModuleKeys.Select(x => new CrmEntity(x, null)), excludedEmails); } else { @@ -209,36 +281,7 @@ private void GetMailFoldersHelper(Outlook.Folders objInpFolders, IList selectedCrmEntities, EmailArchiveReason reason) { - var result = olItem.Archive(reason); - if (result.IsSuccess) - { - var warnings = CreateEmailRelationshipsWithEntities(result.EmailId, selectedCrmEntities); - result = ArchiveResult.Success( - result.EmailId, - result.Problems == null ? - warnings : - result.Problems.Concat(warnings)); - } - - return result; - } - - private IList CreateEmailRelationshipsWithEntities(string crmMailId, IEnumerable selectedCrmEntities) - { - var failures = new List(); - foreach (CrmEntity entity in selectedCrmEntities) - { - try - { - CreateEmailRelationshipOrFail(crmMailId, entity); - } - catch (System.Exception failure) - { - Log.Error("CreateEmailRelationshipsWithEntities", failure); - failures.Add(failure); - } - } - return failures; + return olItem.Archive(reason, selectedCrmEntities); } private void SaveMailItemIfNecessary(Outlook.MailItem olItem, EmailArchiveReason reason) diff --git a/SuiteCRMAddIn/BusinessLogic/RepeatingProcess.cs b/SuiteCRMAddIn/BusinessLogic/RepeatingProcess.cs index 69c8240e..e59d8a01 100644 --- a/SuiteCRMAddIn/BusinessLogic/RepeatingProcess.cs +++ b/SuiteCRMAddIn/BusinessLogic/RepeatingProcess.cs @@ -78,10 +78,19 @@ public abstract class RepeatingProcess /// When my last run ccompleted. /// /// - /// Initialised to 'max value', so that at startup we won't mistakenly + /// Initialised to 'now', so that at startup we won't mistakenly /// believe that things have happened after it. /// - private DateTime lastIterationCompleted = DateTime.MaxValue; + private DateTime lastIterationCompleted = DateTime.Now; + + /// + /// When the preceding run completed. + /// + /// + /// Initialised to 'min value' so that things that have happened since the last + /// time Outlook was running don't get missed. + /// + private DateTime previousIterationCompleted = DateTime.MinValue; public RepeatingProcess(string name, ILogger log) { @@ -98,6 +107,14 @@ protected DateTime LastRunCompleted get { return this.lastIterationCompleted; } } + /// + /// When the iteration prior to my last run completed. + /// + protected DateTime PreviousRunCompleted + { + get { return this.previousIterationCompleted; } + } + /// /// True if I should be active, else false. /// @@ -142,6 +159,7 @@ private async void PerformRepeatedly() /* deal with any pending Windows messages, which we don't need to know about */ System.Windows.Forms.Application.DoEvents(); + this.previousIterationCompleted = this.lastIterationCompleted; this.lastIterationCompleted = DateTime.UtcNow; if (this.state == RunState.Running) diff --git a/SuiteCRMAddIn/BusinessLogic/SyncState.cs b/SuiteCRMAddIn/BusinessLogic/SyncState.cs index 87118f02..cbf8ac40 100644 --- a/SuiteCRMAddIn/BusinessLogic/SyncState.cs +++ b/SuiteCRMAddIn/BusinessLogic/SyncState.cs @@ -69,20 +69,23 @@ public bool IsDeletedInOutlook { get { + bool result; if (_wasDeleted) return true; // TODO: Make this logic more robust. Perhaps check HRESULT of COMException? try { // Has the side-effect of throwing an exception if the item has been deleted: var entryId = OutlookItemEntryId; - return false; + result = false; } catch (COMException com) { - Globals.ThisAddIn.Log.Debug($"Object has probably been deleted: {com.ErrorCode}, {com.Message}"); + Globals.ThisAddIn.Log.Debug($"Object has probably been deleted: {com.ErrorCode}, {com.Message}; HResult {com.HResult}"); _wasDeleted = true; - return true; + result = true; } + + return result; } } diff --git a/SuiteCRMAddIn/BusinessLogic/SyncState{1}.cs b/SuiteCRMAddIn/BusinessLogic/SyncState{1}.cs index aaeccb74..3bf5215b 100644 --- a/SuiteCRMAddIn/BusinessLogic/SyncState{1}.cs +++ b/SuiteCRMAddIn/BusinessLogic/SyncState{1}.cs @@ -25,6 +25,7 @@ namespace SuiteCRMAddIn.BusinessLogic using SuiteCRMAddIn.ProtoItems; using SuiteCRMClient.Logging; using System; + using System.Runtime.InteropServices; /// /// The sync state of an item of the specified type. diff --git a/SuiteCRMAddIn/BusinessLogic/Synchroniser.cs b/SuiteCRMAddIn/BusinessLogic/Synchroniser.cs index b4817c18..fbe6954d 100644 --- a/SuiteCRMAddIn/BusinessLogic/Synchroniser.cs +++ b/SuiteCRMAddIn/BusinessLogic/Synchroniser.cs @@ -70,6 +70,16 @@ public abstract class Synchroniser : RepeatingProcess, IDisposa /// protected object enqueueingLock = new object(); + /// + /// A lock on the creation of new objects in Outlook. + /// + protected object creationLock = new object(); + + /// + /// The actual transmission lock object of this synchroniser. + /// + protected object transmissionLock = new object(); + /// /// The prefix for the fetch query, used in FetchRecordsFromCrm, q.v. /// @@ -226,10 +236,17 @@ public int ItemsCount /// We need to prevent two simultaneous transmissions of the same object, so it's probably unsafe /// to have two threads transmitting contact items at the same time. But there's no reason why /// we should not transmit contact items and task items at the same time, for example. So each - /// Synchorniser subclass will have its own transmission lock. + /// Synchroniser instance will have its own transmission lock. /// /// A transmission lock. - protected abstract object TransmissionLock { get; } + protected object TransmissionLock + { + get + { + return transmissionLock; + } + } + /// /// Get a date stamp for midnight five days ago (why?). @@ -314,6 +331,7 @@ protected void ResolveUnmatchedItems(IEnumerable> ite var toDeleteFromOutlook = itemsCopy.Where(a => a.ExistedInCrm && a.CrmType == crmModule).ToList(); var toCreateOnCrmServer = itemsCopy.Where(a => !a.ExistedInCrm && a.CrmType == crmModule).ToList(); + var missingFromOutlook = itemsCopy.Where(a => a.ExistedInCrm && a.IsDeletedInOutlook && a.CrmType == crmModule).ToList(); foreach (var syncState in toDeleteFromOutlook) { @@ -322,10 +340,23 @@ protected void ResolveUnmatchedItems(IEnumerable> ite foreach (var syncState in toCreateOnCrmServer) { - AddOrUpdateItemFromOutlookToCrm(syncState, crmModule); + AddOrUpdateItemFromOutlookToCrm(syncState); } } + + /// + /// Deal with an item which used to exist in Outlook but which no longer does. + /// The default behaviour is to remove it from CRM. + /// + /// The dangling syncState of the missing item. + internal virtual void HandleItemMissingFromOutlook(SyncState syncState) + { + this.RemoveFromCrm(syncState); + this.RemoveItemSyncState(syncState); + } + + /// /// Perform all the necessary checking before adding or updating an item on CRM. /// diff --git a/SuiteCRMAddIn/BusinessLogic/TaskSyncState.cs b/SuiteCRMAddIn/BusinessLogic/TaskSyncState.cs index 33114bdc..bc03897b 100644 --- a/SuiteCRMAddIn/BusinessLogic/TaskSyncState.cs +++ b/SuiteCRMAddIn/BusinessLogic/TaskSyncState.cs @@ -26,6 +26,7 @@ namespace SuiteCRMAddIn.BusinessLogic using ProtoItems; using Extensions; using Outlook = Microsoft.Office.Interop.Outlook; + using System.Runtime.InteropServices; /// /// A SyncState for Contact items. @@ -52,6 +53,7 @@ public override string Description } } + public override void DeleteItem() { this.OutlookItem.Delete(); diff --git a/SuiteCRMAddIn/BusinessLogic/TaskSyncing.cs b/SuiteCRMAddIn/BusinessLogic/TaskSyncing.cs index ac45f790..4acb4083 100644 --- a/SuiteCRMAddIn/BusinessLogic/TaskSyncing.cs +++ b/SuiteCRMAddIn/BusinessLogic/TaskSyncing.cs @@ -47,21 +47,6 @@ public TaskSyncing(string name, SyncContext context) this.fetchQueryPrefix = string.Empty; } - /// - /// The actual transmission lock object of this synchroniser. - /// - private object txLock = new object(); - - /// - /// Allow my parent class to access my transmission lock object. - /// - protected override object TransmissionLock - { - get - { - return txLock; - } - } public override string DefaultCrmModule { @@ -182,14 +167,32 @@ protected override bool ShouldAddOrUpdateItemFromCrmToOutlook(Outlook.MAPIFolder Log.Debug($"TaskSyncing.AddOrUpdateItemFromCrmToOutlook\n\tSubject: {crmItem.GetValueAsString("name")}\n\tCurrent user id {RestAPIWrapper.GetUserId()}\n\tAssigned user id: {crmItem.GetValueAsString("assigned_user_id")}"); - DateTime dateStart = crmItem.GetValueAsDateTime("date_start"); - DateTime dateDue = crmItem.GetValueAsDateTime("date_due"); - string timeStart = ExtractTime(dateStart); - string timeDue = ExtractTime(dateDue); - var syncState = this.GetExistingSyncState(crmItem); if (syncState == null) + { + result = MaybeAddNewItemFromCrmToOutlook(tasksFolder, crmItem); + } + else + { + result = UpdateExistingOutlookItemFromCrm(crmItem, syncState); + } + + return result; + } + + + /// + /// Item creation really ought to happen within the context of a lock, in order to prevent duplicate creation. + /// + /// The folder in which the item should be created. + /// The CRM item it will represent. + /// A syncstate whose Outlook item is the Outlook item representing this crmItem. + private SyncState MaybeAddNewItemFromCrmToOutlook(Outlook.MAPIFolder tasksFolder, EntryValue crmItem) + { + SyncState result; + + lock (creationLock) { /* check for howlaround */ var matches = this.FindMatches(crmItem); @@ -197,17 +200,14 @@ protected override bool ShouldAddOrUpdateItemFromCrmToOutlook(Outlook.MAPIFolder if (matches.Count == 0) { /* didn't find it, so add it to Outlook */ - result = AddNewItemFromCrmToOutlook(tasksFolder, crmItem, dateStart, dateDue, timeStart, timeDue); + result = AddNewItemFromCrmToOutlook(tasksFolder, crmItem); } else { this.Log.Warn($"Howlaround detected? Task '{crmItem.GetValueAsString("name")}' offered with id {crmItem.GetValueAsString("id")}, expected {matches[0].CrmEntryId}, {matches.Count} duplicates"); + result = matches[0]; } } - else - { - result = UpdateExistingOutlookItemFromCrm(crmItem, dateStart, dateDue, timeStart, timeDue, syncState); - } return result; } @@ -220,7 +220,7 @@ private static string ExtractTime(DateTime dateStart) .ToString(@"hh\:mm"); } - private SyncState UpdateExistingOutlookItemFromCrm(EntryValue crmItem, DateTime? date_start, DateTime? date_due, string time_start, string time_due, SyncState syncStateForItem) + private SyncState UpdateExistingOutlookItemFromCrm(EntryValue crmItem, SyncState syncStateForItem) { if (!syncStateForItem.IsDeletedInOutlook) { @@ -229,17 +229,22 @@ private static string ExtractTime(DateTime dateStart) if (oProp.Value != crmItem.GetValueAsString("date_modified")) { - SetOutlookItemPropertiesFromCrmItem(crmItem, date_start, date_due, time_start, time_due, olItem); + SetOutlookItemPropertiesFromCrmItem(crmItem, olItem); } syncStateForItem.OModifiedDate = DateTime.ParseExact(crmItem.GetValueAsString("date_modified"), "yyyy-MM-dd HH:mm:ss", null); } return syncStateForItem; } - private void SetOutlookItemPropertiesFromCrmItem(EntryValue crmItem, DateTime? dateStart, DateTime? dateDue, string timeStart, string timeDue, Outlook.TaskItem olItem) + private void SetOutlookItemPropertiesFromCrmItem(EntryValue crmItem, Outlook.TaskItem olItem) { try { + DateTime dateStart = crmItem.GetValueAsDateTime("date_start"); + DateTime dateDue = crmItem.GetValueAsDateTime("date_due"); + string timeStart = ExtractTime(dateStart); + string timeDue = ExtractTime(dateDue); + olItem.Subject = crmItem.GetValueAsString("name"); olItem.StartDate = MaybeChangeDate(dateStart, olItem.StartDate, "olItem.StartDate"); @@ -287,14 +292,14 @@ private DateTime MaybeChangeDate(DateTime? newValue, DateTime oldValue, string n return result; } - private SyncState AddNewItemFromCrmToOutlook(Outlook.MAPIFolder tasksFolder, EntryValue crmItem, DateTime? date_start, DateTime? date_due, string time_start, string time_due) + private SyncState AddNewItemFromCrmToOutlook(Outlook.MAPIFolder tasksFolder, EntryValue crmItem) { Outlook.TaskItem olItem = tasksFolder.Items.Add(Outlook.OlItemType.olTaskItem); TaskSyncState newState = null; try { - this.SetOutlookItemPropertiesFromCrmItem(crmItem, date_start, date_due, time_start, time_due, olItem); + this.SetOutlookItemPropertiesFromCrmItem(crmItem, olItem); this.AddOrGetSyncState(olItem); } diff --git a/SuiteCRMAddIn/Daemon/TransmitUpdateAction.cs b/SuiteCRMAddIn/Daemon/TransmitUpdateAction.cs index 7125d692..674b8613 100644 --- a/SuiteCRMAddIn/Daemon/TransmitUpdateAction.cs +++ b/SuiteCRMAddIn/Daemon/TransmitUpdateAction.cs @@ -24,7 +24,10 @@ namespace SuiteCRMAddIn.Daemon { using BusinessLogic; using Exceptions; + using SuiteCRMClient.Logging; using System.Net; + using System.Runtime.InteropServices; + using Outlook = Microsoft.Office.Interop.Outlook; /// /// An action to transmit to the server an item which is not a new item, but @@ -43,6 +46,7 @@ public class TransmitUpdateAction : AbstractDaemonAction /// private SyncState state; + /// /// Create a new instance of the TrensmitUpdateItem class, wrapping this state. /// @@ -53,6 +57,28 @@ public TransmitUpdateAction(Synchroniser synchroniser, SyncStat state.SetQueued(); this.synchroniser = synchroniser; this.state = state; + + SyncState meeting = state as SyncState; + + if (meeting != null) + { + ILogger log = Globals.ThisAddIn.Log; + try + { + switch (meeting.OutlookItem.MeetingStatus) { + case Outlook.OlMeetingStatus.olMeetingCanceled: + log.Info($"TransmitUpdateAction: registered meeting {state.Description} cancelled"); + break; + case Microsoft.Office.Interop.Outlook.OlMeetingStatus.olMeetingReceivedAndCanceled: + log.Info($"TransmitUpdateAction: registered meeting {state.Description} received and cancelled"); + break; + } + } + catch (COMException comx) + { + log.Error($"Item missing? HResult = {comx.HResult}", comx); + } + } } @@ -60,7 +86,15 @@ public override string Description { get { - return $"{this.GetType().Name} ({state.CrmType} {state.CrmEntryId})"; + try + { + return $"{this.GetType().Name} ({state.CrmType} {state.Description})"; + } + catch (COMException comx) + { + Globals.ThisAddIn.Log.Error($"Item missing? HResult = {comx.HResult}", comx); + return $"{this.GetType().Name} ({state.CrmType} - possibly cancelled meeting?"; + } } } @@ -69,7 +103,16 @@ public override string Perform() { try { - synchroniser.AddOrUpdateItemFromOutlookToCrm(state); + try + { + var id = state.CrmEntryId; + synchroniser.AddOrUpdateItemFromOutlookToCrm(state); + } + catch (COMException comx) + { + Globals.ThisAddIn.Log.Error($"Item missing? HResult = {comx.HResult}", comx); + synchroniser.HandleItemMissingFromOutlook(state); + } return "Synced."; } diff --git a/SuiteCRMAddIn/Dialogs/SettingsDialog.cs b/SuiteCRMAddIn/Dialogs/SettingsDialog.cs index 7a2b6170..5fa4074d 100644 --- a/SuiteCRMAddIn/Dialogs/SettingsDialog.cs +++ b/SuiteCRMAddIn/Dialogs/SettingsDialog.cs @@ -244,13 +244,8 @@ private void btnTestLogin_Click(object sender, EventArgs e) { try { - if (txtURL.Text.EndsWith(@"/")) - { - } - else - { - txtURL.Text = txtURL.Text + "/"; - } + this.CheckUrlChanged(false); + if (SafelyGetText(txtLDAPAuthenticationKey) == string.Empty) { txtLDAPAuthenticationKey.Text = null; @@ -337,7 +332,7 @@ private void btnSave_Click(object sender, EventArgs e) try { - CheckUrlChanged(); + CheckUrlChanged(true); string LDAPAuthenticationKey = SafelyGetText(txtLDAPAuthenticationKey); if (LDAPAuthenticationKey == string.Empty) @@ -387,18 +382,22 @@ private void btnSave_Click(object sender, EventArgs e) /// /// Check whether the URL has changed; if it has, offer to clear down existing CRM ids. /// - private void CheckUrlChanged() + /// + /// If true and the URL has changed, offer to clear the CRM ids. + /// + private void CheckUrlChanged(bool offerToClearCRMIds) { var newUrl = SafelyGetText(txtURL); - if (newUrl != oldUrl) + if (!newUrl.EndsWith(@"/")) { - new ClearCrmIdsDialog(this.Log).ShowDialog(); + txtURL.Text = newUrl + "/"; + newUrl = SafelyGetText(txtURL); } - if (!newUrl.EndsWith(@"/")) + if (offerToClearCRMIds && newUrl != oldUrl) { - txtURL.Text = newUrl + "/"; + new ClearCrmIdsDialog(this.Log).ShowDialog(); } } diff --git a/SuiteCRMAddIn/Extensions/MailItemExtensions.cs b/SuiteCRMAddIn/Extensions/MailItemExtensions.cs index 250bda0e..60c0f7ea 100644 --- a/SuiteCRMAddIn/Extensions/MailItemExtensions.cs +++ b/SuiteCRMAddIn/Extensions/MailItemExtensions.cs @@ -22,11 +22,14 @@ */ namespace SuiteCRMAddIn.Extensions { + using BusinessLogic; using Exceptions; using SuiteCRMClient; using SuiteCRMClient.Email; using SuiteCRMClient.Logging; using System; + using System.Collections.Generic; + using System.Linq; using System.Runtime.InteropServices; using TidyManaged; using Outlook = Microsoft.Office.Interop.Outlook; @@ -167,22 +170,28 @@ public static ArchiveableEmail AsArchiveable(this Outlook.MailItem olItem, Email mailArchive.From = olItem.GetSenderSMTPAddress(); mailArchive.To = string.Empty; - Log.Info($"EmailArchiving.SerialiseEmailObject: serialising mail {olItem.Subject} dated {olItem.SentOn}."); + Log.Info($"MailItemExtension.AsArchiveable: serialising mail {olItem.Subject} dated {olItem.SentOn}."); foreach (Outlook.Recipient recipient in olItem.Recipients) { string address = recipient.GetSmtpAddress(); - if (mailArchive.To == string.Empty) + switch (recipient.Type) { - mailArchive.To = address; - } - else - { - mailArchive.To += ";" + address; + case (int)Outlook.OlMailRecipientType.olCC: + mailArchive.CC = ExtendRecipientField(mailArchive.CC, address); + break; + case (int)Outlook.OlMailRecipientType.olBCC: + // unlikely to happen and in any case we don't store these + break; + default: + mailArchive.To = ExtendRecipientField(mailArchive.To, address); + break; } } + mailArchive.CC = olItem.CC; + mailArchive.OutlookId = olItem.EnsureEntryID(); mailArchive.Subject = olItem.Subject; mailArchive.Sent = olItem.ArchiveTime(reason); @@ -208,6 +217,11 @@ public static ArchiveableEmail AsArchiveable(this Outlook.MailItem olItem, Email return mailArchive; } + private static string ExtendRecipientField(string fieldContent, string address) + { + return string.IsNullOrEmpty(fieldContent) ? address : $"{fieldContent};{address}"; + } + /// /// The "HTML" which Outlook generates is diabolically bad, and CMS frequently chokes on it. @@ -340,21 +354,27 @@ public static DateTime ArchiveTime(this Outlook.MailItem olItem, EmailArchiveRea return result; } + public static ArchiveResult Archive(this Outlook.MailItem olItem, EmailArchiveReason reason) + { + return Archive(olItem, reason, EmailArchiving.defaultModuleKeys.Select(x => new CrmEntity(x, null))); + } /// /// Archive this email item to CRM. /// /// The email item to archive. /// The reason it is being archived. + /// Keys (standardised names) of modules to search. + /// email address(es) which should not be linked. /// A result object indicating success or failure. - public static ArchiveResult Archive(this Outlook.MailItem olItem, EmailArchiveReason reason, string excludedEmails = "") + public static ArchiveResult Archive(this Outlook.MailItem olItem, EmailArchiveReason reason, IEnumerable moduleKeys, string excludedEmails = "") { ArchiveResult result; Outlook.UserProperty olProperty = olItem.UserProperties[CrmIdPropertyName]; if (olProperty == null) { - result = olItem.AsArchiveable(reason).Save(excludedEmails); + result = olItem.AsArchiveable(reason).Save(moduleKeys, excludedEmails); if (result.IsSuccess) { diff --git a/SuiteCRMAddIn/SuiteCRMAddIn.csproj b/SuiteCRMAddIn/SuiteCRMAddIn.csproj index 12ee0733..7b1f4f1b 100644 --- a/SuiteCRMAddIn/SuiteCRMAddIn.csproj +++ b/SuiteCRMAddIn/SuiteCRMAddIn.csproj @@ -144,6 +144,7 @@ ..\packages\log4net.2.0.8\lib\net45-full\log4net.dll True + ..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll True @@ -554,4 +555,4 @@ - \ No newline at end of file + diff --git a/SuiteCRMClient/Email/ArchiveableEmail.cs b/SuiteCRMClient/Email/ArchiveableEmail.cs index 04fa1478..7ead8e03 100644 --- a/SuiteCRMClient/Email/ArchiveableEmail.cs +++ b/SuiteCRMClient/Email/ArchiveableEmail.cs @@ -23,8 +23,10 @@ namespace SuiteCRMClient.Email { using System; + using System.Linq; using System.Collections.Generic; using SuiteCRMClient.Logging; + using RESTObjects; /// /// A representation of an email which may be archived to CRM. @@ -71,29 +73,32 @@ public ArchiveableEmail(UserSession SuiteCRMUserSession, ILogger log) } /// - /// Get contact ids of all email addresses in my From, To, or CC fields which - /// are known to CRM and not included in these excluded addresses. + /// Get related ids from the modules with these moduleKeys, of all email addresses + /// in my From, To, or CC fields which are known to CRM and not included in these + /// excluded addresses. /// + /// Keys (standardised names) of modules to search. /// A string containing zero or many email /// addresses for which contact ids should not be returned. - /// A list of strings representing CRM contact ids. - public List GetValidContactIDs(string contatenatedExcludedAddresses = "") + /// A list of pairs strings representing CRM module keys and contact ids. + public IEnumerable GetRelatedIds(IEnumerable moduleKeys, string contatenatedExcludedAddresses = "") { - return GetValidContactIds(ConstructAddressList(contatenatedExcludedAddresses.ToUpper())); + return GetRelatedIds(moduleKeys, ConstructAddressList(contatenatedExcludedAddresses.ToUpper())); } /// /// Get contact ids of all email addresses in my From, To, or CC fields which /// are known to CRM and not included in these excluded addresses. /// - /// email addresses for which contact ids should + /// Keys (standardised names) of modules to search. + /// email addresses for which related ids should /// not be returned. - /// A list of strings representing CRM contact ids. - private List GetValidContactIds(List excludedAddresses) + /// A list of strings representing CRM ids. + private IEnumerable GetRelatedIds(IEnumerable moduleKeys, IEnumerable excludedAddresses) { RestAPIWrapper.EnsureLoggedIn(SuiteCRMUserSession); - List result = new List(); + List result = new List(); List checkedAddresses = new List(); try @@ -102,12 +107,18 @@ private List GetValidContactIds(List excludedAddresses) { if (!checkedAddresses.Contains(address) && !excludedAddresses.Contains(address.ToUpper())) { - var contactReturn = SuiteCRMUserSession.RestServer.GetCrmResponse("get_entry_list", - ConstructGetContactIdByAddressPacket(address)); + RESTObjects.IdsOnly contacts = null; - if (contactReturn.entry_list != null && contactReturn.entry_list.Count > 0) + foreach (string moduleKey in moduleKeys) { - result.Add(contactReturn.entry_list[0].id); + contacts = SuiteCRMUserSession.RestServer.GetCrmResponse("get_entry_list", + ConstructGetContactIdByAddressPacket(address, moduleKey)); + + + if (contacts.entry_list != null && contacts.entry_list.Count > 0) + { + result.AddRange(contacts.entry_list.Select(x => new CrmEntity(moduleKey, x.id))); + } } } checkedAddresses.Add(address); @@ -134,36 +145,45 @@ private List GetValidContactIds(List excludedAddresses) private static List ConstructAddressList(string contatenatedAddresses) { List addresses = new List(); - addresses.AddRange(contatenatedAddresses.Split(',', ';', '\n', ':', ' ', '\t')); + addresses.AddRange(contatenatedAddresses + .Split(',', ';', '\n', '\r', ':', ' ', '\t') + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim())); return addresses; } - private object ConstructGetContactIdByAddressPacket(string address) + private object ConstructGetContactIdByAddressPacket(string address, string moduleKey) { + string tableName = ModuleToTableResolver.GetTableName(moduleKey); return new { - @session = SuiteCRMUserSession.id, - @module_name = "Contacts", - @query = GetContactIDQuery(address), - @order_by = "", - @offset = 0, - @select_fields = new string[] { "id" }, - @max_results = 1 + session = SuiteCRMUserSession.id, + module_name = tableName, + query = GetContactIDQuery(address, tableName), + order_by = "", + offset = 0, + select_fields = new string[] { "id" }, + max_results = 1, + deleted = false, + favorites = false }; } - private string GetContactIDQuery(string strEmail) + private string GetContactIDQuery(string address, string tableName) { - return "contacts.id in (SELECT eabr.bean_id FROM email_addr_bean_rel eabr JOIN email_addresses ea ON (ea.id = eabr.email_address_id) WHERE eabr.deleted=0 and ea.email_address = '" + strEmail + "')"; + return $"{tableName.ToLower()}.id in (SELECT eabr.bean_id FROM email_addr_bean_rel eabr JOIN email_addresses ea ON (ea.id = eabr.email_address_id) WHERE eabr.deleted=0 and ea.email_address = '{address}')"; } /// /// Save my email to CRM, if it relates to any valid contacts. /// /// Emails of contacts with which it should not be related. - public ArchiveResult Save(string excludedEmails = "") + /// Keys (standardised names) of modules to search. + public ArchiveResult Save(IEnumerable relatedRecords, string excludedEmails = "") { - return Save(GetValidContactIDs(excludedEmails)); + IEnumerable withIds = relatedRecords.Where(x => !string.IsNullOrEmpty(x.EntityId)); + IEnumerable foundIds = GetRelatedIds(relatedRecords.Where(x => string.IsNullOrEmpty(x.EntityId)).Select(x => x.ModuleName), excludedEmails); + return Save(withIds.Union(foundIds)); } @@ -175,12 +195,12 @@ public ArchiveResult Save(string excludedEmails = "") /// trying first with the HTML body, and if that failed trying again with it empty. The other did not. /// I have no idea whether there is a benefit of this two-attempt strategy. /// - /// The contact ids to link with. - public ArchiveResult Save(List crmContactIds) + /// CRM module names/ids of records to which I should be related. + public ArchiveResult Save(IEnumerable relatedRecords) { ArchiveResult result; - if (crmContactIds.Count == 0) + if (relatedRecords.Count() == 0) { result = ArchiveResult.Failure( new[] { new Exception("Found no related entities in CRM to link with") }); @@ -189,7 +209,7 @@ public ArchiveResult Save(List crmContactIds) { try { - result = TrySave(crmContactIds, this.HTMLBody, null); + result = TrySave(relatedRecords, this.HTMLBody, null); } catch (Exception firstFail) { @@ -197,7 +217,7 @@ public ArchiveResult Save(List crmContactIds) try { - result = TrySave(crmContactIds, string.Empty, new[] { firstFail }); + result = TrySave(relatedRecords, string.Empty, new[] { firstFail }); } catch (Exception secondFail) { @@ -214,64 +234,60 @@ public ArchiveResult Save(List crmContactIds) /// /// Attempt to save me given these contact Ids and this HTML body, taking note of these previous failures. /// - /// CRM ids of contacts to which I should be related. + /// CRM module names/ids of records to which I should be related. /// The HTML body with which I should be saved. /// Any previous failures in attempting to save me. /// An archive result object describing the outcome of this attempt. - private ArchiveResult TrySave(List contactIds, string htmlBody, Exception[] fails) + private ArchiveResult TrySave(IEnumerable relatedRecords, string htmlBody, Exception[] fails) { var restServer = SuiteCRMUserSession.RestServer; var emailResult = restServer.GetCrmResponse("set_entry", ConstructPacket(htmlBody)); ArchiveResult result = ArchiveResult.Success(emailResult.id, fails); - SaveContacts(contactIds, emailResult); - - SaveAttachments(emailResult); - - return result; - } - - - /// - /// Save my attachments to CRM, and relate them to this emailResult. - /// - /// A result object obtained by archiving me to CRM. - private void SaveAttachments(RESTObjects.SetEntryResult emailResult) - { - foreach (ArchiveableAttachment attachment in Attachments) + if (result.IsSuccess) { - try - { - BindAttachmentInCrm(emailResult.id, - TransmitAttachmentPacket(ConstructAttachmentPacket(attachment)).id); - } - catch (Exception any) - { - log.Error($"Failed to bind attachment '{attachment.DisplayName}' to email '{emailResult.id}' in CRM", any); - } + LinkRelatedRecords(relatedRecords, emailResult); + SaveAttachments(emailResult); } + + return result; } /// - /// Relate this email result (presumed to represent me) in CRM to these contact ids. + /// Relate this email result (presumed to represent me) in CRM to these related records. /// - /// The contact ids which should be related to my email result. + /// The records which should be related to my email result. /// An email result (presumed to represent me). - private void SaveContacts(List crmContactIds, RESTObjects.SetEntryResult emailResult) + private void LinkRelatedRecords(IEnumerable relatedRecords, RESTObjects.SetEntryResult emailResult) { var restServer = SuiteCRMUserSession.RestServer; - foreach (string contactId in crmContactIds) + foreach (CrmEntity record in relatedRecords) { try { - restServer.GetCrmResponse("set_relationship", - ConstructContactRelationshipPacket(emailResult.id, contactId)); + var success = RestAPIWrapper.TrySetRelationship( + new SetRelationshipParams + { + module2 = "emails", + module2_id = emailResult.id, + module1 = ModuleToTableResolver.GetTableName(record.ModuleName), + module1_id = record.EntityId, + }, Objective.Email); + + if (success) + { + log.Debug($"Successfully bound {record.ModuleName} '{record.EntityId}' to email '{emailResult.id}' in CRM"); + } + else + { + log.Warn($"Failed to bind {record.ModuleName} '{record.EntityId}' to email '{emailResult.id}' in CRM"); + } } catch (Exception any) { - log.Error($"Failed to bind contact '{contactId}' to email '{emailResult.id}' in CRM", any); + log.Error($"Failed to bind {record.ModuleName} '{record.EntityId}' to email '{emailResult.id}' in CRM", any); } } } @@ -282,19 +298,18 @@ private void SaveContacts(List crmContactIds, RESTObjects.SetEntryResult /// A packet which, when transmitted to CRM, will instantiate my email. private object ConstructPacket(string htmlBody) { - List emailData = new List - { - new RESTObjects.NameValue() {name = "from_addr", value = this.From}, - new RESTObjects.NameValue() {name = "to_addrs", value = this.To.Replace("\n", "")}, - new RESTObjects.NameValue() {name = "name", value = this.Subject}, - new RESTObjects.NameValue() {name = "date_sent", value = this.Sent.ToString(EmailDateFormat)}, - new RESTObjects.NameValue() {name = "description", value = this.Body}, - new RESTObjects.NameValue() {name = "description_html", value = htmlBody}, - new RESTObjects.NameValue() {name = "assigned_user_id", value = RestAPIWrapper.GetUserId()}, - new RESTObjects.NameValue() {name = "status", value = "archived"}, - new RESTObjects.NameValue() {name = "category_id", value = this.Category}, - new RESTObjects.NameValue() {name = "message_id", value = this.OutlookId } - }; + EmailPacket emailData = new EmailPacket(); + + emailData.MaybeAddField("from_addr_name", this.From); + emailData.MaybeAddField("to_addrs_names", this.To, true); + emailData.MaybeAddField("cc_addrs_names", this.CC, true); + emailData.MaybeAddField("name", this.Subject); + emailData.MaybeAddField("date_sent", this.Sent.ToString(EmailDateFormat)); + emailData.MaybeAddField("description", this.Body); + emailData.MaybeAddField("description_html", htmlBody); + emailData.MaybeAddField("assigned_user_id", RestAPIWrapper.GetUserId()); + emailData.MaybeAddField("category_id", this.Category); + emailData.MaybeAddField("message_id", this.OutlookId); return new { @@ -304,59 +319,47 @@ private object ConstructPacket(string htmlBody) }; } - private void BindAttachmentInCrm(string emailId, string attachmentId) - { - //Relate the email and the attachment - SuiteCRMUserSession.RestServer.GetCrmResponse("set_relationship", - ConstructAttachmentRelationshipPacket(emailId, attachmentId)); - } /// - /// Construct a packet representing the relationship between the email represented - /// by this email id and the attachment represented by this attachment id. + /// Save my attachments to CRM, and relate them to this emailResult. /// - /// The id of the email. - /// The id of the attachment. - /// A packet which, when transmitted to CRM, will instantiate this relationship. - private object ConstructAttachmentRelationshipPacket(string emailId, string attachmentId) + /// A result object obtained by archiving me to CRM. + private void SaveAttachments(RESTObjects.SetEntryResult emailResult) { - return ConstructRelationshipPacket(emailId, attachmentId, "Emails", "notes"); + foreach (ArchiveableAttachment attachment in Attachments) + { + try + { + BindAttachmentInCrm(emailResult.id, + TransmitAttachmentPacket(ConstructAttachmentPacket(attachment)).id); + } + catch (Exception any) + { + log.Error($"Failed to bind attachment '{attachment.DisplayName}' to email '{emailResult.id}' in CRM", any); + } + } } - /// - /// Construct a packet representing the relationship between the email represented - /// by this email id and the contact represented by this contact id. - /// - /// The id of the email. - /// The id of the contact. - /// A packet which, when transmitted to CRM, will instantiate this relationship. - private object ConstructContactRelationshipPacket(string emailId, string contactId) - { - return ConstructRelationshipPacket(contactId, emailId, "Contacts", "emails"); - } /// - /// Construct a packet representing the relationship between the object represented - /// by this module id in the module with this module name and the object in the foreign - /// module linked through this link field represented by this foreign id. + /// Relate the email and the attachment /// - /// The id of the record in the named module. - /// The id of the record in the foreign module. - /// The name of the module in which the record is to be created. - /// The name of the link field in the named module which links to the foreign module. - /// A packet which, when transmitted to CRM, will instantiate this relationship. - private object ConstructRelationshipPacket(string moduleId, string foreignId, string moduleName, string linkField) + /// + /// + /// + private bool BindAttachmentInCrm(string emailId, string attachmentId) { - return new - { - session = SuiteCRMUserSession.id, - module_name = moduleName, - module_id = moduleId, - link_field_name = linkField, - related_ids = new string[] { foreignId } - }; + return RestAPIWrapper.TrySetRelationship( + new SetRelationshipParams + { + module2 = "emails", + module2_id = emailId, + module1 = "notes", + module1_id = attachmentId, + }, Objective.Email); } + /// /// Transmit this attachment packet to CRM. /// @@ -402,5 +405,20 @@ private object ConstructAttachmentPacket(ArchiveableAttachment attachment) return attachmentDataWebFormat; } + + private class EmailPacket : List + { + public void MaybeAddField(string fieldName, string fieldValue, bool replaceCRs = false) + { + if (!string.IsNullOrWhiteSpace(fieldValue)) + { + this.Add(new RESTObjects.NameValue() + { + name = fieldName, + value = replaceCRs ? fieldValue.Replace("\n", "") : fieldValue + }); + } + } + } } } diff --git a/SuiteCRMClient/ModuleToTableResolver.cs b/SuiteCRMClient/ModuleToTableResolver.cs new file mode 100644 index 00000000..22f3c912 --- /dev/null +++ b/SuiteCRMClient/ModuleToTableResolver.cs @@ -0,0 +1,65 @@ +/** + * Outlook integration for SuiteCRM. + * @package Outlook integration for SuiteCRM + * @copyright SalesAgility Ltd http://www.salesagility.com + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU LESSER GENERAL PUBLIC LICENCE as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENCE + * along with this program; if not, see http://www.gnu.org/licenses + * or write to the Free Software Foundation,Inc., 51 Franklin Street, + * Fifth Floor, Boston, MA 02110-1301 USA + * + * @author SalesAgility + */ +namespace SuiteCRMClient +{ + using System; + using System.Collections.Generic; + + /// + /// In general the module key for a module (that is, the standard name for the module, generally the + /// same as the American English version of the name) is the singular (i.e. without the terminal 's') + /// of the name of the table in which the module data is stored. There are a few exceptions to this. + /// + public class ModuleToTableResolver + { + /// + /// the overrides to the general rule. This probably ought to be read from a setting at startup. + /// + private static Dictionary overrides = new Dictionary + { + { "Projects", "Project" }, + { "Project", "Project" } + }; + + /// + /// Return the table name which corresponds to this module name. + /// + /// The module name. + /// The corresponding table name. + public static string GetTableName(string moduleName) + { + string result; + + try + { + result = overrides[moduleName]; + } + catch (Exception) + { + result = moduleName.EndsWith("s") ? moduleName : $"{moduleName}s"; + } + + return result; + } + } +} diff --git a/SuiteCRMClient/RESTObjects/EntryValue.cs b/SuiteCRMClient/RESTObjects/EntryValue.cs index 6f3a170b..5191b81a 100644 --- a/SuiteCRMClient/RESTObjects/EntryValue.cs +++ b/SuiteCRMClient/RESTObjects/EntryValue.cs @@ -72,6 +72,16 @@ public object GetValue(string key) return result; } + /// + /// Get the binding for this name within this entry. + /// + /// The name. + /// The binding. + public NameValue GetBinding(string name) + { + return this.nameValueList.GetBinding(name); + } + public RelationshipListElement relationships { get; set; } public string GetValueAsString(string key) diff --git a/SuiteCRMClient/RESTObjects/Contacts.cs b/SuiteCRMClient/RESTObjects/IdsOnly.cs similarity index 89% rename from SuiteCRMClient/RESTObjects/Contacts.cs rename to SuiteCRMClient/RESTObjects/IdsOnly.cs index 4dc1e3cb..17d76aac 100644 --- a/SuiteCRMClient/RESTObjects/Contacts.cs +++ b/SuiteCRMClient/RESTObjects/IdsOnly.cs @@ -25,7 +25,10 @@ namespace SuiteCRMClient.RESTObjects using System.Collections.Generic; using Newtonsoft.Json; - public class Contacts + /// + /// Pulls back just the ids - from records in, essentially, any module with an id. + /// + public class IdsOnly { [JsonProperty("entry_list")] public List entry_list { get; set; } diff --git a/SuiteCRMClient/RESTObjects/NameValueCollection.cs b/SuiteCRMClient/RESTObjects/NameValueCollection.cs index c246f553..d376e064 100644 --- a/SuiteCRMClient/RESTObjects/NameValueCollection.cs +++ b/SuiteCRMClient/RESTObjects/NameValueCollection.cs @@ -64,6 +64,16 @@ public NameValueCollection(JObject data) } } + /// + /// Get the binding for this name within this name-value collection. + /// + /// The name. + /// The binding. + public NameValue GetBinding(string name) + { + return this.Where(x => x.name == name).FirstOrDefault(); + } + /// /// Return my names/values as a dictionary. /// diff --git a/SuiteCRMClient/RestAPIWrapper.cs b/SuiteCRMClient/RestAPIWrapper.cs index 7605d29b..b03d4781 100644 --- a/SuiteCRMClient/RestAPIWrapper.cs +++ b/SuiteCRMClient/RestAPIWrapper.cs @@ -211,9 +211,12 @@ public static string GetUserId(MailAddress mailAddress) public static string GetUserId(string username) { string result = string.Empty; - EntryList list = GetEntryList("Users", $"user_name LIKE '%{MySqlEscape(username)}%'", 0, "id DESC", 0, false, new string[] { "id" }); + + EntryList list = GetEntryList("Users", $"users.user_name LIKE '%{MySqlEscape(username)}%'", 0, "id DESC", 0, false, new string[] { "id" }); - if (list.entry_list.Count() > 0) + if (list != null && + list.entry_list != null && + list.entry_list.Count() > 0) { result = list.entry_list[0].id; } @@ -366,28 +369,20 @@ public static bool SetMeetingAcceptance(string meetingId, string moduleName, str public static string GetRelationship(string MainModule, string ID, string ModuleToFind) { + string result; + try { - EnsureLoggedIn(); - object data = new - { - @session = SuiteCRMUserSession.id, - @module_name = MainModule, - @module_id = ID, - @link_field_name = ModuleToFind, - @related_module_query = "", - @related_fields = new string[] { "id" } - }; - Relationships _result = SuiteCRMUserSession.RestServer.GetCrmResponse("get_relationships", data); - if (_result.entry_list.Length > 0) - return _result.entry_list[0].id; - return ""; + EntryValue[] entries = RestAPIWrapper.GetRelationships(MainModule, ID, ModuleToFind, new string[] { "id" }); + result = entries.Length > 0 ? entries[0].id : string.Empty; } catch (System.Exception) { // Swallow exception(!) - return ""; + result = string.Empty; } + + return result; } public static EntryValue[] GetRelationships(string MainModule, string ID, string ModuleToFind, string[] fields) @@ -402,7 +397,12 @@ public static EntryValue[] GetRelationships(string MainModule, string ID, string @module_id = ID, @link_field_name = ModuleToFind, @related_module_query = "", - @related_fields = fields + @related_fields = fields, + @related_module_link_name_to_fields_array = new object[] { }, + @deleted = false, + @order_by = "", + @offset = 0, + @limit = false }; Relationships _result = SuiteCRMUserSession.RestServer.GetCrmResponse("get_relationships", data); @@ -570,7 +570,8 @@ public static EntryList GetEntryList(string module, string query, int limit, str } : null, @max_results = $"{limit}", - @deleted = GetDeleted + @deleted = GetDeleted, + @favorites = false }; result = SuiteCRMUserSession.RestServer.GetCrmResponse("get_entry_list", data); if (result.error != null) @@ -780,31 +781,36 @@ private static bool StringContainsAll(string target, IEnumerable substri /// public static string[] GetSugarFields(string module) { - string[] strArray = new string[14]; - if (module == null) - { - return strArray; - } - if (module == "Contacts") - { - return new string[] { - "id", "first_name", "last_name", "email1", "phone_work", "phone_home", "title", "department", "primary_address_city", "primary_address_country", "primary_address_postalcode", "primary_address_state", "primary_address_street", "description", "user_sync", "date_modified", - "account_name", "phone_mobile", "phone_fax", "salutation", "sync_contact" - }; - } - if (module == "Tasks") - { - return new string[] { "id", "name", "description", "date_due", "status", "date_modified", "date_start", "priority", "assigned_user_id" }; + string[] result = new string[14]; + + switch (module) + { + case "Calls": + result = new string[] { "id", "name", "description", "date_start", "date_end", + "date_modified", "duration_minutes", "duration_hours" }; + break; + case "Contacts": + result = new string[] {"id", "first_name", "last_name", "email1", "phone_work", + "phone_home", "title", "department", "primary_address_city", "primary_address_country", + "primary_address_postalcode", "primary_address_state", "primary_address_street", + "description", "user_sync", "date_modified", "account_name", "phone_mobile", + "phone_fax", "salutation", "sync_contact" }; + break; + case "Meetings": + result = new string[] { "id", "name", "description", "date_start", "date_end", "location", + "date_modified", "duration_minutes", "duration_hours", "invitees", "assigned_user_id", + "outlook_id" }; + break; + case "Tasks": + result = new string[] { "id", "name", "description", "date_due", "status", "date_modified", + "date_start", "priority", "assigned_user_id" }; + break; + default: + result = new string[14]; + break; } - if (module == "Meetings") - { - return new string[] { "id", "name", "description", "date_start", "date_end", "location", "date_modified", "duration_minutes", "duration_hours", "invitees", "assigned_user_id", "outlook_id" }; - } - if (module == "Calls") - { - return new string[] { "id", "name", "description", "date_start", "date_end", "date_modified", "duration_minutes", "duration_hours" }; - } - return strArray; + + return result; } } } diff --git a/SuiteCRMClient/SuiteCRMClient.csproj b/SuiteCRMClient/SuiteCRMClient.csproj index b88fed13..4ae16086 100644 --- a/SuiteCRMClient/SuiteCRMClient.csproj +++ b/SuiteCRMClient/SuiteCRMClient.csproj @@ -81,6 +81,7 @@ + @@ -96,7 +97,7 @@ - + diff --git a/SuiteCRMOutlookAddIn.sln b/SuiteCRMOutlookAddIn.sln index 1a672167..25eb95b4 100644 --- a/SuiteCRMOutlookAddIn.sln +++ b/SuiteCRMOutlookAddIn.sln @@ -3,10 +3,10 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 VisualStudioVersion = 14.0.25420.1 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuiteCRMClient", "SuiteCRMClient\SuiteCRMClient.csproj", "{383E8EE7-604D-447D-A2C2-C50080D28F69}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuiteCRMAddIn", "SuiteCRMAddIn\SuiteCRMAddIn.csproj", "{8EF51293-040D-4A83-AFC3-9EA8C328B653}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuiteCRMClient", "SuiteCRMClient\SuiteCRMClient.csproj", "{383E8EE7-604D-447D-A2C2-C50080D28F69}" +EndProject Project("{6141683F-8A12-4E36-9623-2EB02B2C2303}") = "SuiteCRMAddInSetup", "SuiteCRMAddInSetup\SuiteCRMAddInSetup.isproj", "{068824B4-9D40-4ACB-B21A-1F0BFA9C0D74}" ProjectSection(ProjectDependencies) = postProject {8EF51293-040D-4A83-AFC3-9EA8C328B653} = {8EF51293-040D-4A83-AFC3-9EA8C328B653} @@ -40,33 +40,6 @@ Global SingleImage|x86 = SingleImage|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Any CPU.ActiveCfg = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Any CPU.Build.0 = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Mixed Platforms.ActiveCfg = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Mixed Platforms.Build.0 = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|x86.ActiveCfg = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Any CPU.Build.0 = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|x86.ActiveCfg = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|x86.Build.0 = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Any CPU.Build.0 = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Mixed Platforms.ActiveCfg = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Mixed Platforms.Build.0 = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|x86.ActiveCfg = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Any CPU.ActiveCfg = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Any CPU.Build.0 = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|x86.ActiveCfg = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|x86.Build.0 = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Any CPU.ActiveCfg = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Any CPU.Build.0 = Release|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Mixed Platforms.ActiveCfg = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Mixed Platforms.Build.0 = Debug|Any CPU - {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|x86.ActiveCfg = Release|Any CPU {8EF51293-040D-4A83-AFC3-9EA8C328B653}.CD_ROM|Any CPU.ActiveCfg = Release|Any CPU {8EF51293-040D-4A83-AFC3-9EA8C328B653}.CD_ROM|Any CPU.Build.0 = Release|Any CPU {8EF51293-040D-4A83-AFC3-9EA8C328B653}.CD_ROM|Mixed Platforms.ActiveCfg = Release|Any CPU @@ -94,6 +67,33 @@ Global {8EF51293-040D-4A83-AFC3-9EA8C328B653}.SingleImage|Mixed Platforms.ActiveCfg = Debug|Any CPU {8EF51293-040D-4A83-AFC3-9EA8C328B653}.SingleImage|Mixed Platforms.Build.0 = Debug|Any CPU {8EF51293-040D-4A83-AFC3-9EA8C328B653}.SingleImage|x86.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Any CPU.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Any CPU.Build.0 = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Mixed Platforms.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|Mixed Platforms.Build.0 = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.CD_ROM|x86.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|x86.ActiveCfg = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Debug|x86.Build.0 = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Any CPU.Build.0 = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Mixed Platforms.ActiveCfg = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|Mixed Platforms.Build.0 = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.DVD-5|x86.ActiveCfg = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Any CPU.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Any CPU.Build.0 = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|x86.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.Release|x86.Build.0 = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Any CPU.ActiveCfg = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Any CPU.Build.0 = Release|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Mixed Platforms.ActiveCfg = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|Mixed Platforms.Build.0 = Debug|Any CPU + {383E8EE7-604D-447D-A2C2-C50080D28F69}.SingleImage|x86.ActiveCfg = Release|Any CPU {068824B4-9D40-4ACB-B21A-1F0BFA9C0D74}.CD_ROM|Any CPU.ActiveCfg = CD_ROM {068824B4-9D40-4ACB-B21A-1F0BFA9C0D74}.CD_ROM|Any CPU.Build.0 = CD_ROM {068824B4-9D40-4ACB-B21A-1F0BFA9C0D74}.CD_ROM|Mixed Platforms.ActiveCfg = CD_ROM