Calendar - mail mapping.
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Entities.Mail;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a calendar invitation mail item to a persisted calendar event.
|
||||||
|
/// </summary>
|
||||||
|
public class MailInvitationCalendarMapping
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MailCopy.Id value of the invitation mail.
|
||||||
|
/// </summary>
|
||||||
|
public string MailCopyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// iCalendar UID extracted from invitation MIME/ICS content.
|
||||||
|
/// </summary>
|
||||||
|
public string InvitationUid { get; set; }
|
||||||
|
|
||||||
|
public Guid CalendarId { get; set; }
|
||||||
|
public Guid CalendarItemId { get; set; }
|
||||||
|
public string CalendarRemoteEventId { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -48,9 +48,22 @@ public class HtmlPreviewVisitor : MimeVisitor
|
|||||||
|
|
||||||
protected override void VisitMultipartAlternative(MultipartAlternative alternative)
|
protected override void VisitMultipartAlternative(MultipartAlternative alternative)
|
||||||
{
|
{
|
||||||
// walk the multipart/alternative children backwards from greatest level of faithfulness to the least faithful
|
// Prefer rich body alternatives first, and only fall back to calendar text if nothing else exists.
|
||||||
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
|
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
|
||||||
|
{
|
||||||
|
if (IsCalendarText(alternative[i]))
|
||||||
|
continue;
|
||||||
|
|
||||||
alternative[i].Accept(this);
|
alternative[i].Accept(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
|
||||||
|
{
|
||||||
|
if (!IsCalendarText(alternative[i]))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
alternative[i].Accept(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void VisitMultipartRelated(MultipartRelated related)
|
protected override void VisitMultipartRelated(MultipartRelated related)
|
||||||
@@ -241,6 +254,10 @@ public class HtmlPreviewVisitor : MimeVisitor
|
|||||||
Body = converter.Convert(entity.Text);
|
Body = converter.Convert(entity.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsCalendarText(MimeEntity entity)
|
||||||
|
=> entity is TextPart textPart &&
|
||||||
|
textPart.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
protected override void VisitTnefPart(TnefPart entity)
|
protected override void VisitTnefPart(TnefPart entity)
|
||||||
{
|
{
|
||||||
// extract any attachments in the MS-TNEF part
|
// extract any attachments in the MS-TNEF part
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Extensions;
|
||||||
|
|
||||||
|
public static class CalendarInvitationExtensions
|
||||||
|
{
|
||||||
|
public static string ExtractInvitationUid(this MimeMessage message)
|
||||||
|
{
|
||||||
|
if (message == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var icsContent = GetCalendarContent(message);
|
||||||
|
if (string.IsNullOrWhiteSpace(icsContent))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unfolded = UnfoldIcs(icsContent);
|
||||||
|
var veventSection = ExtractFirstVEventSection(unfolded);
|
||||||
|
if (string.IsNullOrWhiteSpace(veventSection))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryReadIcsProperty(veventSection, "UID", out var uid)
|
||||||
|
? uid
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCalendarContent(MimeMessage message)
|
||||||
|
{
|
||||||
|
var textPart = message.BodyParts
|
||||||
|
.OfType<TextPart>()
|
||||||
|
.FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
if (textPart != null)
|
||||||
|
{
|
||||||
|
return textPart.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimePart = message.BodyParts
|
||||||
|
.OfType<MimePart>()
|
||||||
|
.FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
if (mimePart == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
mimePart.Content.DecodeTo(stream);
|
||||||
|
var bytes = stream.ToArray();
|
||||||
|
if (bytes.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var charset = mimePart.ContentType?.Charset;
|
||||||
|
var encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset);
|
||||||
|
return encoding.GetString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnfoldIcs(string content)
|
||||||
|
=> content
|
||||||
|
.Replace("\r\n ", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("\r\n\t", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("\n ", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("\n\t", string.Empty, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static string ExtractFirstVEventSection(string ics)
|
||||||
|
{
|
||||||
|
const string beginVevent = "BEGIN:VEVENT";
|
||||||
|
const string endVevent = "END:VEVENT";
|
||||||
|
|
||||||
|
var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (beginIndex < 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (endIndex < 0)
|
||||||
|
{
|
||||||
|
return ics[beginIndex..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadIcsProperty(string icsSection, string propertyName, out string value)
|
||||||
|
{
|
||||||
|
value = string.Empty;
|
||||||
|
var lines = icsSection.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
foreach (var rawLine in lines)
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colonIndex = line.IndexOf(':');
|
||||||
|
if (colonIndex <= 0 || colonIndex >= line.Length - 1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = line[(colonIndex + 1)..].Trim();
|
||||||
|
return !string.IsNullOrWhiteSpace(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ public interface IDefaultChangeProcessor
|
|||||||
|
|
||||||
Task DeleteCalendarItemAsync(Guid calendarItemId);
|
Task DeleteCalendarItemAsync(Guid calendarItemId);
|
||||||
Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId);
|
Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId);
|
||||||
|
Task<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId);
|
||||||
|
|
||||||
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
|
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||||
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||||
@@ -54,6 +55,8 @@ public interface IDefaultChangeProcessor
|
|||||||
Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
|
Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
|
||||||
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
|
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
|
||||||
Task DeleteUserMailCacheAsync(Guid accountId);
|
Task DeleteUserMailCacheAsync(Guid accountId);
|
||||||
|
Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
|
||||||
|
Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether the mail exists in the folder.
|
/// Checks whether the mail exists in the folder.
|
||||||
@@ -199,6 +202,9 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
|||||||
public Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
public Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
||||||
=> CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId);
|
=> CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId);
|
||||||
|
|
||||||
|
public Task<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId)
|
||||||
|
=> CalendarService.GetCalendarItemAsync(calendarId, remoteEventId);
|
||||||
|
|
||||||
public Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
|
public Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
|
||||||
=> CalendarService.DeleteAccountCalendarAsync(accountCalendar);
|
=> CalendarService.DeleteAccountCalendarAsync(accountCalendar);
|
||||||
|
|
||||||
@@ -217,6 +223,43 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
|||||||
await AccountService.DeleteAccountMailCacheAsync(accountId, AccountCacheResetReason.ExpiredCache).ConfigureAwait(false);
|
await AccountService.DeleteAccountMailCacheAsync(accountId, AccountCacheResetReason.ExpiredCache).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping)
|
||||||
|
{
|
||||||
|
if (mapping == null || mapping.AccountId == Guid.Empty || string.IsNullOrWhiteSpace(mapping.MailCopyId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var existing = await Connection.Table<MailInvitationCalendarMapping>()
|
||||||
|
.FirstOrDefaultAsync(x => x.AccountId == mapping.AccountId && x.MailCopyId == mapping.MailCopyId)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
if (mapping.Id == Guid.Empty)
|
||||||
|
mapping.Id = Guid.NewGuid();
|
||||||
|
|
||||||
|
mapping.UpdatedAtUtc = DateTime.UtcNow;
|
||||||
|
await Connection.InsertAsync(mapping, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.InvitationUid = mapping.InvitationUid;
|
||||||
|
existing.CalendarId = mapping.CalendarId;
|
||||||
|
existing.CalendarItemId = mapping.CalendarItemId;
|
||||||
|
existing.CalendarRemoteEventId = mapping.CalendarRemoteEventId;
|
||||||
|
existing.UpdatedAtUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await Connection.UpdateAsync(existing, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId)
|
||||||
|
{
|
||||||
|
if (accountId == Guid.Empty || string.IsNullOrWhiteSpace(mailCopyId))
|
||||||
|
return Task.FromResult<MailInvitationCalendarMapping>(null);
|
||||||
|
|
||||||
|
return Connection.Table<MailInvitationCalendarMapping>()
|
||||||
|
.FirstOrDefaultAsync(x => x.AccountId == accountId && x.MailCopyId == mailCopyId);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<bool> IsMailExistsInFolderAsync(string messageId, Guid folderId)
|
public Task<bool> IsMailExistsInFolderAsync(string messageId, Guid folderId)
|
||||||
=> MailService.IsMailExistsAsync(messageId, folderId);
|
=> MailService.IsMailExistsAsync(messageId, folderId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1965,12 +1965,35 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
// Create base MailCopy from metadata only - NO MIME download
|
// Create base MailCopy from metadata only - NO MIME download
|
||||||
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||||
|
|
||||||
|
// Initial sync metadata flow does not include MIME, but calendar invitations need MIME
|
||||||
|
// for date rendering and invitation-to-calendar mapping.
|
||||||
|
if (mimeMessage == null && baseMailCopy?.ItemType == MailItemType.CalendarInvitation && !string.IsNullOrEmpty(message?.Id))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rawRequest = _gmailService.Users.Messages.Get("me", message.Id);
|
||||||
|
rawRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
|
||||||
|
|
||||||
|
var rawMessage = await rawRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!string.IsNullOrEmpty(rawMessage?.Raw))
|
||||||
|
{
|
||||||
|
mimeMessage = rawMessage.GetGmailMimeMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Failed to fetch raw MIME for calendar invitation {MessageId}", message.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mimeMessage != null)
|
if (mimeMessage != null)
|
||||||
{
|
{
|
||||||
// Raw responses don't include metadata headers. Backfill important fields from MIME.
|
// Raw responses don't include metadata headers. Backfill important fields from MIME.
|
||||||
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
|
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await TryMapCalendarInvitationAsync(baseMailCopy, mimeMessage, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var extractedContacts = ExtractContactsFromGmailMessage(message, mimeMessage);
|
var extractedContacts = ExtractContactsFromGmailMessage(message, mimeMessage);
|
||||||
|
|
||||||
// Check for local draft mapping using X-Wino-Draft-Id header.
|
// Check for local draft mapping using X-Wino-Draft-Id header.
|
||||||
@@ -2055,6 +2078,59 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
return packageList;
|
return packageList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TryMapCalendarInvitationAsync(MailCopy baseMailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (baseMailCopy == null || baseMailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var invitationUid = mimeMessage.ExtractInvitationUid();
|
||||||
|
if (string.IsNullOrWhiteSpace(invitationUid))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var calendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||||
|
if (calendars == null || calendars.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var calendar in calendars)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var listRequest = _calendarService.Events.List(calendar.RemoteCalendarId);
|
||||||
|
listRequest.ICalUID = invitationUid;
|
||||||
|
listRequest.MaxResults = 1;
|
||||||
|
listRequest.SingleEvents = false;
|
||||||
|
|
||||||
|
var listResponse = await listRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var matchedEvent = listResponse?.Items?.FirstOrDefault();
|
||||||
|
if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await _gmailChangeProcessor.ManageCalendarEventAsync(matchedEvent, calendar, Account).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var localCalendarItem = await _gmailChangeProcessor.GetCalendarItemAsync(calendar.Id, matchedEvent.Id).ConfigureAwait(false);
|
||||||
|
if (localCalendarItem == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _gmailChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = Account.Id,
|
||||||
|
MailCopyId = baseMailCopy.Id,
|
||||||
|
InvitationUid = invitationUid,
|
||||||
|
CalendarId = calendar.Id,
|
||||||
|
CalendarItemId = localCalendarItem.Id,
|
||||||
|
CalendarRemoteEventId = matchedEvent.Id
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Failed to map Gmail calendar invitation mail {MailCopyId} for calendar {CalendarId}", baseMailCopy.Id, calendar.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Calendar Operations
|
#region Calendar Operations
|
||||||
|
|||||||
@@ -380,8 +380,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
if (!mailExists)
|
if (!mailExists)
|
||||||
{
|
{
|
||||||
// For drafts, download MIME during initial sync like delta sync.
|
// For drafts and calendar invitations, download MIME during initial sync like delta sync.
|
||||||
if (folder.SpecialFolderType == SpecialFolderType.Draft)
|
var itemType = Account.IsCalendarAccessGranted ? message.GetMailItemType() : MailItemType.Mail;
|
||||||
|
if (folder.SpecialFolderType == SpecialFolderType.Draft || itemType == MailItemType.CalendarInvitation)
|
||||||
{
|
{
|
||||||
var draftPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
|
var draftPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -1874,6 +1875,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
// If draft mapping was successful, mailCopy will be null
|
// If draft mapping was successful, mailCopy will be null
|
||||||
if (mailCopy == null) return null;
|
if (mailCopy == null) return null;
|
||||||
|
|
||||||
|
await TryMapCalendarInvitationAsync(mailCopy, mimeMessage, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Outlook messages can only be assigned to 1 folder at a time.
|
// Outlook messages can only be assigned to 1 folder at a time.
|
||||||
// Therefore we don't need to create multiple copies of the same message for different folders.
|
// Therefore we don't need to create multiple copies of the same message for different folders.
|
||||||
var contacts = ExtractContactsFromOutlookMessage(message);
|
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||||
@@ -1882,6 +1885,74 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
return [package];
|
return [package];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var invitationUid = mimeMessage.ExtractInvitationUid();
|
||||||
|
if (string.IsNullOrWhiteSpace(invitationUid))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var calendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||||
|
if (calendars == null || calendars.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string escapedUid = invitationUid.Replace("'", "''", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
foreach (var calendar in calendars)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var eventsResponse = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events
|
||||||
|
.GetAsync(requestConfiguration =>
|
||||||
|
{
|
||||||
|
requestConfiguration.QueryParameters.Filter = $"iCalUId eq '{escapedUid}'";
|
||||||
|
requestConfiguration.QueryParameters.Select = ["id"];
|
||||||
|
requestConfiguration.QueryParameters.Top = 1;
|
||||||
|
}, cancellationToken: cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var matchedEvent = eventsResponse?.Value?.FirstOrDefault();
|
||||||
|
if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[matchedEvent.Id]
|
||||||
|
.GetAsync(requestConfiguration =>
|
||||||
|
{
|
||||||
|
requestConfiguration.QueryParameters.Expand = ["attachments($select=id,name,contentType,size,isInline)"];
|
||||||
|
}, cancellationToken: cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (fullEvent == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var localCalendarItem = await _outlookChangeProcessor.GetCalendarItemAsync(calendar.Id, fullEvent.Id).ConfigureAwait(false);
|
||||||
|
if (localCalendarItem == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _outlookChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = Account.Id,
|
||||||
|
MailCopyId = mailCopy.Id,
|
||||||
|
InvitationUid = invitationUid,
|
||||||
|
CalendarId = calendar.Id,
|
||||||
|
CalendarItemId = localCalendarItem.Id,
|
||||||
|
CalendarRemoteEventId = fullEvent.Id
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Failed to map Outlook calendar invitation mail {MailCopyId} for calendar {CalendarId}", mailCopy.Id, calendar.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
|
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="Wino.Controls.CalendarMailItemDisplayInformationControl"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:controls="using:Wino.Controls"
|
||||||
|
xmlns:enums="using:Wino.Core.Domain.Enums"
|
||||||
|
xmlns:helpers="using:Wino.Helpers"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch">
|
||||||
|
|
||||||
|
<Grid x:DefaultBindMode="OneWay">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<controls:MailItemDisplayInformationControl
|
||||||
|
x:Name="BaseMailControl"
|
||||||
|
ActionItem="{x:Bind MailItem, Mode=OneWay}"
|
||||||
|
DisplayMode="{x:Bind DisplayMode, Mode=OneWay}"
|
||||||
|
HoverActionExecuted="BaseMailControlHoverActionExecuted"
|
||||||
|
MailItemInformation="{x:Bind MailItem, Mode=OneWay}"
|
||||||
|
Prefer24HourTimeFormat="{x:Bind Prefer24HourTimeFormat, Mode=OneWay}"
|
||||||
|
ShowPreviewText="False" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
x:Name="EventDateText"
|
||||||
|
Grid.Row="1"
|
||||||
|
Margin="53,0,12,8"
|
||||||
|
FontSize="12"
|
||||||
|
Opacity="0.75"
|
||||||
|
Text="{x:Bind EventDateRangeText, Mode=OneWay}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<VisualStateManager.VisualStateGroups>
|
||||||
|
<VisualStateGroup x:Name="SizingStates">
|
||||||
|
<VisualState x:Name="Compact">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Target="EventDateText.Margin" Value="53,0,12,6" />
|
||||||
|
<Setter Target="EventDateText.FontSize" Value="11" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
<VisualState.StateTriggers>
|
||||||
|
<StateTrigger IsActive="{x:Bind helpers:XamlHelpers.ObjectEquals(DisplayMode, enums:MailListDisplayMode.Compact), Mode=OneWay}" />
|
||||||
|
</VisualState.StateTriggers>
|
||||||
|
</VisualState>
|
||||||
|
|
||||||
|
<VisualState x:Name="Medium">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Target="EventDateText.Margin" Value="53,0,12,8" />
|
||||||
|
<Setter Target="EventDateText.FontSize" Value="12" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
<VisualState.StateTriggers>
|
||||||
|
<StateTrigger IsActive="{x:Bind helpers:XamlHelpers.ObjectEquals(DisplayMode, enums:MailListDisplayMode.Medium), Mode=OneWay}" />
|
||||||
|
</VisualState.StateTriggers>
|
||||||
|
</VisualState>
|
||||||
|
|
||||||
|
<VisualState x:Name="Spacious">
|
||||||
|
<VisualState.Setters>
|
||||||
|
<Setter Target="EventDateText.Margin" Value="59,0,12,10" />
|
||||||
|
<Setter Target="EventDateText.FontSize" Value="12" />
|
||||||
|
</VisualState.Setters>
|
||||||
|
<VisualState.StateTriggers>
|
||||||
|
<StateTrigger IsActive="{x:Bind helpers:XamlHelpers.ObjectEquals(DisplayMode, enums:MailListDisplayMode.Spacious), Mode=OneWay}" />
|
||||||
|
</VisualState.StateTriggers>
|
||||||
|
</VisualState>
|
||||||
|
</VisualStateGroup>
|
||||||
|
</VisualStateManager.VisualStateGroups>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.WinUI;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using MimeKit;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
|
using Wino.Helpers;
|
||||||
|
using Wino.Mail.ViewModels.Data;
|
||||||
|
using Wino.Mail.WinUI;
|
||||||
|
|
||||||
|
namespace Wino.Controls;
|
||||||
|
|
||||||
|
public sealed partial class CalendarMailItemDisplayInformationControl : UserControl
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<Guid, string> EventDateRangeCache = [];
|
||||||
|
|
||||||
|
private readonly IMimeFileService _mimeFileService;
|
||||||
|
private readonly IPreferencesService _preferencesService;
|
||||||
|
private CancellationTokenSource? _loadingCts;
|
||||||
|
|
||||||
|
[GeneratedDependencyProperty]
|
||||||
|
public partial MailItemViewModel? MailItem { get; set; }
|
||||||
|
|
||||||
|
[GeneratedDependencyProperty(DefaultValue = MailListDisplayMode.Spacious)]
|
||||||
|
public partial MailListDisplayMode DisplayMode { get; set; }
|
||||||
|
|
||||||
|
[GeneratedDependencyProperty(DefaultValue = false)]
|
||||||
|
public partial bool Prefer24HourTimeFormat { get; set; }
|
||||||
|
|
||||||
|
[GeneratedDependencyProperty(DefaultValue = "")]
|
||||||
|
public partial string EventDateRangeText { get; set; }
|
||||||
|
|
||||||
|
public event EventHandler<MailOperationPreperationRequest>? HoverActionExecuted;
|
||||||
|
|
||||||
|
public CalendarMailItemDisplayInformationControl()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_mimeFileService = App.Current.Services.GetRequiredService<IMimeFileService>();
|
||||||
|
_preferencesService = App.Current.Services.GetRequiredService<IPreferencesService>();
|
||||||
|
|
||||||
|
DisplayMode = _preferencesService.MailItemDisplayMode;
|
||||||
|
Prefer24HourTimeFormat = _preferencesService.Prefer24HourTimeFormat;
|
||||||
|
|
||||||
|
Unloaded += OnControlUnloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnMailItemPropertyChanged(DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = LoadEventDateRangeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadEventDateRangeAsync()
|
||||||
|
{
|
||||||
|
_loadingCts?.Cancel();
|
||||||
|
_loadingCts?.Dispose();
|
||||||
|
_loadingCts = new CancellationTokenSource();
|
||||||
|
var token = _loadingCts.Token;
|
||||||
|
|
||||||
|
if (MailItem?.MailCopy == null)
|
||||||
|
{
|
||||||
|
EventDateRangeText = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EventDateRangeCache.TryGetValue(MailItem.MailCopy.FileId, out var cachedValue))
|
||||||
|
{
|
||||||
|
EventDateRangeText = cachedValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var accountId = MailItem.MailCopy.AssignedAccount?.Id;
|
||||||
|
if (accountId == null || accountId == Guid.Empty)
|
||||||
|
{
|
||||||
|
EventDateRangeText = Translator.UnknownDateHeader;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId.Value, MailItem.MailCopy.FileId);
|
||||||
|
if (!isMimeExists)
|
||||||
|
{
|
||||||
|
EventDateRangeText = Translator.UnknownDateHeader;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeInfo = await _mimeFileService.GetMimeMessageInformationAsync(MailItem.MailCopy.FileId, accountId.Value, token);
|
||||||
|
if (mimeInfo == null)
|
||||||
|
{
|
||||||
|
EventDateRangeText = Translator.UnknownDateHeader;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderedDateRange = ExtractCalendarDateRange(mimeInfo.MimeMessage);
|
||||||
|
|
||||||
|
EventDateRangeText = string.IsNullOrWhiteSpace(renderedDateRange) ? Translator.UnknownDateHeader : renderedDateRange;
|
||||||
|
EventDateRangeCache.TryAdd(MailItem.MailCopy.FileId, EventDateRangeText);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Ignore; newer bind request superseded this one.
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
EventDateRangeText = Translator.UnknownDateHeader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BaseMailControlHoverActionExecuted(object sender, MailOperationPreperationRequest e)
|
||||||
|
=> HoverActionExecuted?.Invoke(this, e);
|
||||||
|
|
||||||
|
private string ExtractCalendarDateRange(MimeMessage message)
|
||||||
|
{
|
||||||
|
var calendarContent = GetCalendarContent(message);
|
||||||
|
if (string.IsNullOrWhiteSpace(calendarContent))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unfoldedIcs = UnfoldIcs(calendarContent);
|
||||||
|
var eventSection = ExtractFirstVEventSection(unfoldedIcs);
|
||||||
|
|
||||||
|
if (!TryReadIcsDateValue(eventSection, "DTSTART", out var dtStartValue, out var dtStartTzId))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParseIcsDateTime(dtStartValue, dtStartTzId, out var startLocal, out var isAllDay))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime endLocal = startLocal;
|
||||||
|
if (TryReadIcsDateValue(eventSection, "DTEND", out var dtEndValue, out var dtEndTzId))
|
||||||
|
{
|
||||||
|
if (!TryParseIcsDateTime(dtEndValue, dtEndTzId, out endLocal, out var endIsAllDay))
|
||||||
|
{
|
||||||
|
endLocal = startLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAllDay = isAllDay && endIsAllDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FormatDisplayDateRange(startLocal, endLocal, isAllDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCalendarContent(MimeMessage message)
|
||||||
|
{
|
||||||
|
var calendarTextPart = message.BodyParts
|
||||||
|
.OfType<TextPart>()
|
||||||
|
.FirstOrDefault(x => x.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
if (calendarTextPart != null)
|
||||||
|
{
|
||||||
|
return calendarTextPart.Text ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendarMimePart = message.BodyParts
|
||||||
|
.OfType<MimePart>()
|
||||||
|
.FirstOrDefault(x => x.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
if (calendarMimePart == null)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
calendarMimePart.Content.DecodeTo(stream);
|
||||||
|
var contentBytes = stream.ToArray();
|
||||||
|
|
||||||
|
if (contentBytes.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var charset = calendarMimePart.ContentType?.Charset;
|
||||||
|
var encoding = string.IsNullOrWhiteSpace(charset) ? System.Text.Encoding.UTF8 : System.Text.Encoding.GetEncoding(charset);
|
||||||
|
return encoding.GetString(contentBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnfoldIcs(string content)
|
||||||
|
=> content
|
||||||
|
.Replace("\r\n ", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("\r\n\t", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("\n ", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("\n\t", string.Empty, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static string ExtractFirstVEventSection(string ics)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ics))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string beginVevent = "BEGIN:VEVENT";
|
||||||
|
const string endVevent = "END:VEVENT";
|
||||||
|
|
||||||
|
var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (beginIndex < 0)
|
||||||
|
{
|
||||||
|
return ics;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (endIndex < 0)
|
||||||
|
{
|
||||||
|
return ics[beginIndex..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadIcsDateValue(string ics, string propertyName, out string value, out string timeZoneId)
|
||||||
|
{
|
||||||
|
value = string.Empty;
|
||||||
|
timeZoneId = string.Empty;
|
||||||
|
|
||||||
|
var lines = ics.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var rawLine in lines)
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colonIndex = line.IndexOf(':');
|
||||||
|
if (colonIndex <= 0 || colonIndex == line.Length - 1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameterSection = line[..colonIndex];
|
||||||
|
value = line[(colonIndex + 1)..].Trim();
|
||||||
|
|
||||||
|
var paramsSplit = parameterSection.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var parameter in paramsSplit.Skip(1))
|
||||||
|
{
|
||||||
|
var eqIndex = parameter.IndexOf('=');
|
||||||
|
if (eqIndex <= 0 || eqIndex == parameter.Length - 1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = parameter[..eqIndex].Trim();
|
||||||
|
if (!name.Equals("TZID", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeZoneId = parameter[(eqIndex + 1)..].Trim().Trim('"');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseIcsDateTime(string rawValue, string timeZoneId, out DateTime localDateTime, out bool isAllDay)
|
||||||
|
{
|
||||||
|
localDateTime = default;
|
||||||
|
isAllDay = false;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(rawValue))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = rawValue.Trim();
|
||||||
|
|
||||||
|
if (value.Length == 8 && DateTime.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
|
||||||
|
{
|
||||||
|
localDateTime = dateOnly.Date;
|
||||||
|
isAllDay = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isUtc = value.EndsWith("Z", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (isUtc)
|
||||||
|
{
|
||||||
|
value = value[..^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
|
||||||
|
if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUtc)
|
||||||
|
{
|
||||||
|
localDateTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Utc).ToLocalTime();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(timeZoneId) && TryFindTimeZone(timeZoneId, out var sourceZone))
|
||||||
|
{
|
||||||
|
var unspecifiedTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Unspecified);
|
||||||
|
localDateTime = TimeZoneInfo.ConvertTime(unspecifiedTime, sourceZone, TimeZoneInfo.Local);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
localDateTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Local);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFindTimeZone(string timeZoneId, out TimeZoneInfo timeZone)
|
||||||
|
{
|
||||||
|
timeZone = null!;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneId, out var windowsId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
timeZone = TimeZoneInfo.FindSystemTimeZoneById(windowsId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatDisplayDateRange(DateTime startLocal, DateTime endLocal, bool isAllDay)
|
||||||
|
{
|
||||||
|
if (endLocal < startLocal)
|
||||||
|
{
|
||||||
|
endLocal = startLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
var culture = CultureInfo.DefaultThreadCurrentUICulture;
|
||||||
|
if (isAllDay)
|
||||||
|
{
|
||||||
|
var adjustedEnd = endLocal.Date > startLocal.Date ? endLocal.Date.AddDays(-1) : startLocal.Date;
|
||||||
|
|
||||||
|
if (adjustedEnd.Date > startLocal.Date)
|
||||||
|
{
|
||||||
|
return $"{startLocal.ToString("d", culture)} - {adjustedEnd.ToString("d", culture)} ({Translator.CalendarItemAllDay})";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{startLocal.ToString("d", culture)} ({Translator.CalendarItemAllDay})";
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeFormat = Prefer24HourTimeFormat ? "HH:mm" : "h:mm tt";
|
||||||
|
|
||||||
|
if (startLocal.Date == endLocal.Date)
|
||||||
|
{
|
||||||
|
return $"{startLocal.ToString($"ddd, MMM d {timeFormat}", culture)} - {endLocal.ToString(timeFormat, culture)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{startLocal.ToString($"ddd, MMM d {timeFormat}", culture)} - {endLocal.ToString($"ddd, MMM d {timeFormat}", culture)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnControlUnloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_loadingCts?.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,27 +100,13 @@
|
|||||||
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<!-- Calendar Mail Item Template (Placeholder for calendar invitations/responses) -->
|
<!-- Calendar Mail Item Template -->
|
||||||
<DataTemplate x:Key="CalendarMailItemTemplate" x:DataType="viewModelData:MailItemViewModel">
|
<DataTemplate x:Key="CalendarMailItemTemplate" x:DataType="viewModelData:MailItemViewModel">
|
||||||
<Grid>
|
<controls:CalendarMailItemDisplayInformationControl
|
||||||
<TextBlock Text="Calendar invitation" />
|
|
||||||
</Grid>
|
|
||||||
<!--<controls:MailItemDisplayInformationControl
|
|
||||||
x:DefaultBindMode="OneWay"
|
x:DefaultBindMode="OneWay"
|
||||||
ActionItem="{x:Bind}"
|
|
||||||
Base64ContactPicture="{x:Bind MailCopy.SenderContact.Base64ContactPicture, Mode=OneWay, TargetNullValue=''}"
|
|
||||||
ContextRequested="MailItemContextRequested"
|
ContextRequested="MailItemContextRequested"
|
||||||
CreationDate="{x:Bind CreationDate}"
|
|
||||||
FromAddress="{x:Bind FromAddress}"
|
|
||||||
FromName="{x:Bind FromName}"
|
|
||||||
HasAttachments="{x:Bind HasAttachments, Mode=OneWay}"
|
|
||||||
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
|
||||||
IsDraft="{x:Bind IsDraft, Mode=OneWay}"
|
MailItem="{x:Bind}" />
|
||||||
IsFlagged="{x:Bind IsFlagged, Mode=OneWay}"
|
|
||||||
IsRead="{x:Bind IsRead, Mode=OneWay}"
|
|
||||||
IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}"
|
|
||||||
PreviewText="{x:Bind PreviewText, Mode=OneWay}"
|
|
||||||
Subject="{x:Bind Subject, Mode=OneWay}" />-->
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<listview:WinoMailItemTemplateSelector
|
<listview:WinoMailItemTemplateSelector
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ public class DatabaseService : IDatabaseService
|
|||||||
Connection.CreateTableAsync<CalendarEventAttendee>(),
|
Connection.CreateTableAsync<CalendarEventAttendee>(),
|
||||||
Connection.CreateTableAsync<CalendarItem>(),
|
Connection.CreateTableAsync<CalendarItem>(),
|
||||||
Connection.CreateTableAsync<CalendarAttachment>(),
|
Connection.CreateTableAsync<CalendarAttachment>(),
|
||||||
Connection.CreateTableAsync<Reminder>()
|
Connection.CreateTableAsync<Reminder>(),
|
||||||
|
Connection.CreateTableAsync<MailInvitationCalendarMapping>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user