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

515 lines
20 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Duplicati.Proprietary.Office365.SourceItems;
namespace Duplicati.Proprietary.Office365;
partial class RestoreProvider
{
internal PlannerApiImpl PlannerApi => new PlannerApiImpl(_apiHelper);
internal class PlannerApiImpl(APIHelper provider)
{
public async Task<GraphPlannerBucket> CreatePlannerBucketAsync(
string planId,
string name,
string? orderHint,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var url = $"{baseUrl}/v1.0/planner/buckets";
var body = new
{
name,
planId,
orderHint
};
var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = JsonContent.Create(body, options: options);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
await using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var created = await JsonSerializer.DeserializeAsync<GraphPlannerBucket>(respStream, cancellationToken: ct).ConfigureAwait(false);
if (created is null || string.IsNullOrWhiteSpace(created.Id))
throw new InvalidOperationException("Graph did not return the created bucket id.");
return created;
}
static readonly IReadOnlySet<string> AllowedPlannerTaskPatchValues =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"description",
"previewType",
"references",
"checklist"
};
public async Task<GraphPlannerTask> CreatePlannerTaskAsync(
string planId,
string? bucketId,
string title,
DateTimeOffset? startDateTime,
DateTimeOffset? dueDateTime,
int? percentComplete,
int? priority,
JsonElement? assignments,
JsonElement? appliedCategories,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var url = $"{baseUrl}/v1.0/planner/tasks";
var body = new
{
planId,
bucketId,
title,
startDateTime,
dueDateTime,
percentComplete,
priority,
assignments,
appliedCategories
};
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Content = JsonContent.Create(body, options: new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
return req;
}
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
await using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var created = await JsonSerializer.DeserializeAsync<GraphPlannerTask>(respStream, cancellationToken: ct).ConfigureAwait(false);
if (created is null || string.IsNullOrWhiteSpace(created.Id))
throw new InvalidOperationException("Graph did not return the created task id.");
return created;
}
private static void WriteSanitizedPlannerChecklist(Utf8JsonWriter jw, JsonElement checklist)
{
// checklist is an object: { "<key>": { ...plannerChecklistItem... }, ... }
jw.WriteStartObject();
foreach (var entry in checklist.EnumerateObject())
{
if (entry.Value.ValueKind != JsonValueKind.Object)
continue;
jw.WritePropertyName(entry.Name);
jw.WriteStartObject();
jw.WriteString("@odata.type", "#microsoft.graph.plannerChecklistItem");
// title
if (entry.Value.TryGetProperty("title", out var title) && title.ValueKind != JsonValueKind.Null)
{
jw.WritePropertyName("title");
title.WriteTo(jw);
}
// isChecked
if (entry.Value.TryGetProperty("isChecked", out var isChecked) &&
(isChecked.ValueKind == JsonValueKind.True || isChecked.ValueKind == JsonValueKind.False))
{
jw.WriteBoolean("isChecked", isChecked.GetBoolean());
}
// Intentionally NOT writing:
// @odata.type, orderHint, lastModifiedDateTime, lastModifiedBy, etc.
jw.WriteEndObject();
}
jw.WriteEndObject();
}
private static void WriteSanitizedPlannerReferences(Utf8JsonWriter jw, JsonElement references)
{
// references is an object: { "<url>": { ...plannerExternalReference... }, ... }
jw.WriteStartObject();
foreach (var entry in references.EnumerateObject())
{
if (entry.Value.ValueKind != JsonValueKind.Object)
continue;
jw.WritePropertyName(entry.Name); // key is the URL
jw.WriteStartObject();
// alias (string)
if (entry.Value.TryGetProperty("alias", out var alias) && alias.ValueKind != JsonValueKind.Null)
{
jw.WritePropertyName("alias");
alias.WriteTo(jw);
}
// type (string)
if (entry.Value.TryGetProperty("type", out var type) && type.ValueKind != JsonValueKind.Null)
{
jw.WritePropertyName("type");
type.WriteTo(jw);
}
// previewPriority (string) - optional; keep only if non-null
if (entry.Value.TryGetProperty("previewPriority", out var previewPriority) &&
previewPriority.ValueKind != JsonValueKind.Null)
{
jw.WritePropertyName("previewPriority");
previewPriority.WriteTo(jw);
}
// Intentionally omit:
// @odata.type, lastModifiedDateTime, lastModifiedBy
jw.WriteEndObject();
}
jw.WriteEndObject();
}
private static void WriteSanitizedPlannerAssignments(Utf8JsonWriter jw, JsonElement assignments)
{
// assignments is an object: { "<userId>": { ...plannerAssignment... }, ... }
// For restore, the minimal valid shape is: { "<userId>": {} }
jw.WriteStartObject();
foreach (var entry in assignments.EnumerateObject())
{
// Key must be the userId (GUID). Value can be {} for assignment creation.
jw.WritePropertyName(entry.Name);
jw.WriteStartObject();
// Intentionally omit:
// @odata.type, assignedDateTime, assignedBy, orderHint
jw.WriteEndObject();
}
jw.WriteEndObject();
}
public async Task UpdatePlannerTaskDetailsAsync(
string taskId,
Stream detailsStream,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var url = $"{baseUrl}/v1.0/planner/tasks/{Uri.EscapeDataString(taskId)}/details";
// 1) Get ETag
async Task<HttpRequestMessage> getRequestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
return req;
}
string etag;
using (var getResp = await provider.SendWithRetryShortAsync(getRequestFactory, ct).ConfigureAwait(false))
{
await APIHelper.EnsureOfficeApiSuccessAsync(getResp, ct).ConfigureAwait(false);
etag = getResp.Headers.ETag?.Tag
?? throw new InvalidOperationException("ETag missing from planner task details response.");
}
// 2) Parse backup JSON
if (detailsStream.CanSeek)
detailsStream.Position = 0;
using var doc = await JsonDocument.ParseAsync(detailsStream, cancellationToken: ct).ConfigureAwait(false);
var root = doc.RootElement;
// 3) Build sanitized PATCH body
using var ms = new MemoryStream();
using (var jw = new Utf8JsonWriter(ms))
{
jw.WriteStartObject();
if (root.TryGetProperty("description", out var description) &&
description.ValueKind != JsonValueKind.Null)
{
jw.WritePropertyName("description");
description.WriteTo(jw);
}
if (root.TryGetProperty("previewType", out var previewType) &&
previewType.ValueKind != JsonValueKind.Null)
{
jw.WritePropertyName("previewType");
previewType.WriteTo(jw);
}
if (root.TryGetProperty("references", out var references) &&
references.ValueKind == JsonValueKind.Object)
{
jw.WritePropertyName("references");
WriteSanitizedPlannerReferences(jw, references);
}
if (root.TryGetProperty("checklist", out var checklist) &&
checklist.ValueKind == JsonValueKind.Object)
{
jw.WritePropertyName("checklist");
WriteSanitizedPlannerChecklist(jw, checklist);
}
if (root.TryGetProperty("assignments", out var assignments) &&
assignments.ValueKind == JsonValueKind.Object)
{
jw.WritePropertyName("assignments");
WriteSanitizedPlannerAssignments(jw, assignments);
}
jw.WriteEndObject();
jw.Flush();
}
if (ms.Length <= 2) // "{}")
return;
async Task<HttpRequestMessage> patchRequestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Headers.IfMatch.Add(new EntityTagHeaderValue(etag));
ms.Position = 0;
req.Content = new StreamContent(ms);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return req;
}
using var patchResp = await provider.SendWithRetryShortAsync(patchRequestFactory, ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(patchResp, ct).ConfigureAwait(false);
}
public async Task<List<GraphPlannerPlan>> GetGroupPlansAsync(string groupId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/groups/{group}/planner/plans";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new 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<GraphPlannerPlan>>(stream, cancellationToken: ct).ConfigureAwait(false);
return page?.Value ?? [];
}
public async Task<GraphPlannerPlan> CreatePlanAsync(string groupId, string title, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var url = $"{baseUrl}/v1.0/planner/plans";
var body = new
{
owner = groupId,
title
};
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, new 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);
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var created = await JsonSerializer.DeserializeAsync<GraphPlannerPlan>(stream, cancellationToken: ct).ConfigureAwait(false);
if (created is null || string.IsNullOrWhiteSpace(created.Id))
throw new InvalidOperationException("Graph did not return the created plan id.");
return created;
}
public async Task<List<GraphPlannerBucket>> GetPlannerBucketsAsync(string planId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var plan = Uri.EscapeDataString(planId);
var url = $"{baseUrl}/v1.0/planner/plans/{plan}/buckets";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new 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<GraphPlannerBucket>>(stream, cancellationToken: ct).ConfigureAwait(false);
return page?.Value ?? [];
}
public async Task<List<GraphPlannerTask>> GetPlannerTasksAsync(
string planId,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var plan = Uri.EscapeDataString(planId);
var url = $"{baseUrl}/v1.0/planner/plans/{plan}/tasks";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new 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<GraphPlannerTask>>(stream, cancellationToken: ct).ConfigureAwait(false);
return page?.Value ?? [];
}
}
private PlannerRestoreHelper? _plannerRestoreHelper = null;
internal PlannerRestoreHelper PlannerRestore => _plannerRestoreHelper ??= new PlannerRestoreHelper(this);
internal class PlannerRestoreHelper(RestoreProvider Provider)
{
private string? _targetPlanId = null;
private bool _hasLoadedPlanId = false;
public async Task<string?> GetTargetPlanId(CancellationToken cancel)
{
if (_hasLoadedPlanId)
return _targetPlanId;
_hasLoadedPlanId = true;
var target = Provider.RestoreTarget;
if (target == null)
return null;
if (target.Type == SourceItemType.Planner)
{
_targetPlanId = target.Metadata.GetValueOrDefault("o365:Id");
}
else if (target.Type == SourceItemType.Group)
{
var groupId = target.Metadata.GetValueOrDefault("o365:Id");
if (!string.IsNullOrWhiteSpace(groupId))
{
// Check if "Restored" plan exists
var plans = await Provider.PlannerApi.GetGroupPlansAsync(groupId, cancel);
var restoredPlan = plans.FirstOrDefault(p => p.Title == "Restored");
if (restoredPlan != null)
{
_targetPlanId = restoredPlan.Id;
}
else
{
// Create "Restored" plan
var newPlan = await Provider.PlannerApi.CreatePlanAsync(groupId, "Restored", cancel);
_targetPlanId = newPlan.Id;
}
}
}
return _targetPlanId;
}
private string? _targetBucketId = null;
private bool _hasLoadedBucketId = false;
public async Task<string?> GetTargetBucketId(CancellationToken cancel)
{
if (_hasLoadedBucketId)
return _targetBucketId;
_hasLoadedBucketId = true;
var planId = await GetTargetPlanId(cancel);
if (string.IsNullOrWhiteSpace(planId))
return null;
// Check if "Restored" bucket exists
var buckets = await Provider.PlannerApi.GetPlannerBucketsAsync(planId, cancel);
var restoredBucket = buckets.FirstOrDefault(b => b.Name == "Restored");
if (restoredBucket != null)
{
_targetBucketId = restoredBucket.Id;
}
else
{
// Create "Restored" bucket
var newBucket = await Provider.PlannerApi.CreatePlannerBucketAsync(planId, "Restored", null, cancel);
_targetBucketId = newBucket.Id;
}
return _targetBucketId;
}
}
}
internal sealed class GraphPlannerTaskDetails
{
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("previewType")]
public string? PreviewType { get; set; }
[JsonPropertyName("references")]
public object? References { get; set; }
[JsonPropertyName("checklist")]
public object? Checklist { get; set; }
}