Files
duplicati/proprietary/Office365/RestoreProvider/RestoreProvider.Calendar.cs
T

422 lines
18 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Net.Http.Json;
using System.Text.Json;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Logging;
using Duplicati.Proprietary.Office365.SourceItems;
namespace Duplicati.Proprietary.Office365;
partial class RestoreProvider
{
internal CalendarApiImpl CalendarApi => new CalendarApiImpl(_apiHelper);
private CalendarRestoreHelper? _calendarRestoreHelper = null;
internal CalendarRestoreHelper CalendarRestore => _calendarRestoreHelper ??= new CalendarRestoreHelper(this);
internal class CalendarApiImpl(APIHelper provider)
{
public async Task RestoreCalendarEventToCalendarAsync(
string userId,
string targetCalendarId,
Stream eventJsonStream,
CancellationToken cancellationToken)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var calendar = Uri.EscapeDataString(targetCalendarId);
// Create event in a specific calendar:
// POST /users/{id}/calendars/{id}/events (application/json event body)
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{calendar}/events";
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
{
var req = new HttpRequestMessage(HttpMethod.Post, new System.Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
// Important: create new content per attempt
eventJsonStream.Position = 0;
req.Content = new StreamContent(eventJsonStream);
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
cancellationToken).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
}
public async Task<GraphCalendar> CreateCalendarAsync(
string userId,
string name,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var url = $"{baseUrl}/v1.0/users/{user}/calendars";
var body = new GraphCreateCalendarRequest { Name = name };
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
=> new HttpRequestMessage(HttpMethod.Post, new System.Uri(url))
{
Headers =
{
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
},
Content = JsonContent.Create(body)
};
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var created = await JsonSerializer.DeserializeAsync<GraphCalendar>(respStream, cancellationToken: ct).ConfigureAwait(false);
if (created is null || string.IsNullOrWhiteSpace(created.Id))
throw new InvalidOperationException("Graph did not return the created calendar id.");
return created;
}
public async Task<GraphCalendar?> GetCalendarByNameAsync(
string userId,
string name,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var filterName = name.Replace("'", "''");
var url = $"{baseUrl}/v1.0/users/{user}/calendars?$filter=name eq '{filterName}'&$top=1";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new System.Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var result = await JsonSerializer.DeserializeAsync<GraphPage<GraphCalendar>>(stream, cancellationToken: ct).ConfigureAwait(false);
return result?.Value?.FirstOrDefault();
}
public async Task<GraphEvent> CreateCalendarEventAsync(
string userId,
string calendarId,
GraphEvent eventItem,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var calendar = Uri.EscapeDataString(calendarId);
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{calendar}/events";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
=> new HttpRequestMessage(HttpMethod.Post, new System.Uri(url))
{
Headers =
{
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
},
Content = JsonContent.Create(eventItem)
};
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<GraphEvent>(respStream, cancellationToken: ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize created event");
}
public async Task<GraphEvent> UpdateCalendarEventAsync(
string userId,
string eventId,
GraphEvent eventUpdate,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var url = $"{baseUrl}/v1.0/users/{user}/events/{eventId}";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
=> new HttpRequestMessage(HttpMethod.Patch, new System.Uri(url))
{
Headers =
{
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
},
Content = JsonContent.Create(eventUpdate)
};
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<GraphEvent>(respStream, cancellationToken: ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize updated event");
}
public async Task<List<GraphEvent>> GetCalendarEventInstancesAsync(
string userId,
string eventId,
DateTimeOffset start,
DateTimeOffset end,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
// Format dates as ISO 8601
var startStr = start.ToGraphTimeString();
var endStr = end.ToGraphTimeString();
var url = $"{baseUrl}/v1.0/users/{user}/events/{eventId}/instances?startDateTime={startStr}&endDateTime={endStr}";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new System.Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var page = await JsonSerializer.DeserializeAsync<GraphPage<GraphEvent>>(stream, cancellationToken: ct).ConfigureAwait(false);
return page?.Value ?? [];
}
public async Task<List<GraphEvent>> FindEventsAsync(
string userIdOrUpn,
string calendarId,
GraphEvent original,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userIdOrUpn);
var calendar = Uri.EscapeDataString(calendarId);
// Use a day-ish window to survive all-day/timezone normalization
var startHint = original.GetStartUtcHint() ?? DateTimeOffset.UtcNow;
var from = startHint.Date.AddHours(-2).ToGraphTimeString();
var to = startHint.Date.AddDays(1).AddHours(2).ToGraphTimeString();
var select = "id,iCalUId,subject,start,end,isAllDay,seriesMasterId,type,organizer,location";
var url =
$"{baseUrl}/v1.0/users/{user}/calendars/{calendar}/calendarView" +
$"?startDateTime={Uri.EscapeDataString(from)}" +
$"&endDateTime={Uri.EscapeDataString(to)}" +
$"&$select={Uri.EscapeDataString(select)}" +
$"&$top={APIHelper.GENERAL_PAGE_SIZE}";
var results = new List<GraphEvent>();
await foreach (var ev in provider.GetAllGraphItemsAsync<GraphEvent>(url, ct).ConfigureAwait(false))
{
// 1) Exact iCalUId match (best-effort)
if (!string.IsNullOrEmpty(original.ICalUId) &&
string.Equals(ev.ICalUId, original.ICalUId, StringComparison.Ordinal))
{
results.Add(ev);
continue;
}
// 2) Recurring instances: match by seriesMasterId + originalStart
if (!string.IsNullOrEmpty(original.SeriesMasterId) &&
string.Equals(ev.SeriesMasterId, original.SeriesMasterId, StringComparison.Ordinal) &&
original.OriginalStart.HasValue &&
ev.OriginalStart.HasValue &&
Math.Abs((ev.OriginalStart.Value - original.OriginalStart.Value).TotalMinutes) < 1)
{
results.Add(ev);
continue;
}
// 3) Series master
if (original.Type == "seriesMaster" &&
ev.Type == "seriesMaster" &&
!string.IsNullOrEmpty(original.SeriesMasterId) &&
string.Equals(ev.SeriesMasterId, original.SeriesMasterId, StringComparison.Ordinal))
{
results.Add(ev);
continue;
}
// 4) Last-resort single-instance fallback
if (original.Type == "singleInstance" &&
ev.Type == "singleInstance" &&
string.Equals(ev.Subject?.Trim(), original.Subject?.Trim(), StringComparison.OrdinalIgnoreCase) &&
ev.IsAllDay == original.IsAllDay)
{
results.Add(ev);
}
}
return results;
}
public async Task AddAttachmentAsync(
string userId,
string calendarId,
string eventId,
string name,
string contentType,
Stream contentStream,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var user = Uri.EscapeDataString(userId);
var cal = Uri.EscapeDataString(calendarId);
var ev = Uri.EscapeDataString(eventId);
// Check size
if (contentStream.Length > 3 * 1024 * 1024) // 3MB limit for simple upload
{
// Large file upload
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}/attachments/createUploadSession";
var sessionBody = new { AttachmentItem = new { attachmentType = "file", name = name, size = contentStream.Length } };
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, new System.Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = JsonContent.Create(sessionBody);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var session = await JsonSerializer.DeserializeAsync<GraphUploadSession>(respStream, cancellationToken: ct).ConfigureAwait(false);
if (session?.UploadUrl != null)
{
await provider.UploadFileToSessionAsync(session.UploadUrl, contentStream, ct).ConfigureAwait(false);
}
}
else
{
// Simple upload
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}/attachments";
using var ms = new MemoryStream();
await contentStream.CopyToAsync(ms, ct);
var bytes = ms.ToArray();
var base64 = Convert.ToBase64String(bytes);
var body = new
{
ODataType = "#microsoft.graph.fileAttachment",
Name = name,
ContentType = contentType,
ContentBytes = base64
};
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, new System.Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = JsonContent.Create(body);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
}
}
}
internal class CalendarRestoreHelper(RestoreProvider Provider)
{
private string? _targetUserId = null;
private string? _targetCalendarId = null;
private bool _hasLoadedTargetInfo = false;
public async Task<(string? UserId, string? CalendarId)> GetUserIdAndCalendarTarget(CancellationToken cancel)
{
if (_hasLoadedTargetInfo)
{
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetCalendarId))
return (null, null);
return (_targetUserId!, _targetCalendarId!);
}
var target = Provider.RestoreTarget;
if (target == null)
throw new InvalidOperationException("Restore target is not set");
if (target.Type == SourceItemType.User)
{
_targetUserId = target.Metadata.GetValueOrDefault("o365:Id");
if (!string.IsNullOrWhiteSpace(_targetUserId))
_targetCalendarId = await GetDefaultRestoreTargetCalendar(_targetUserId, cancel);
}
else if (target.Type == SourceItemType.UserCalendar || target.Type == SourceItemType.Calendar)
{
_targetUserId = target.Path.TrimStart(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar).Skip(1).FirstOrDefault();
_targetCalendarId = target.Metadata.GetValueOrDefault("o365:Id");
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring calendar events.");
}
_hasLoadedTargetInfo = true;
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetCalendarId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsMissingIds", null, $"Missing target userId or calendarId for restoring calendar events.");
return (null, null);
}
return (_targetUserId, _targetCalendarId);
}
private async Task<string> GetDefaultRestoreTargetCalendar(string userId, CancellationToken cancel)
{
const string RESTORED_CALENDAR_NAME = "Restored";
var existing = await Provider.CalendarApi.GetCalendarByNameAsync(userId, RESTORED_CALENDAR_NAME, cancel);
if (existing != null)
return existing.Id;
var created = await Provider.CalendarApi.CreateCalendarAsync(userId, RESTORED_CALENDAR_NAME, cancel);
return created.Id;
}
}
}