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

323 lines
14 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Text.Json;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Logging;
using Duplicati.Proprietary.GoogleWorkspace.SourceItems;
using Google.Apis.Calendar.v3.Data;
namespace Duplicati.Proprietary.GoogleWorkspace;
partial class RestoreProvider
{
private CalendarRestoreHelper? _calendarRestoreHelper = null;
internal CalendarRestoreHelper CalendarRestore => _calendarRestoreHelper ??= new CalendarRestoreHelper(this);
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("gsuite:Id");
if (!string.IsNullOrWhiteSpace(_targetUserId))
_targetCalendarId = await GetDefaultRestoreTargetCalendar(_targetUserId, cancel);
}
else if (target.Type == SourceItemType.UserCalendar)
{
_targetUserId = target.Path.TrimStart(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar).Skip(1).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(_targetUserId))
_targetCalendarId = await GetDefaultRestoreTargetCalendar(_targetUserId, cancel);
}
else if (target.Type == SourceItemType.Calendar)
{
_targetUserId = target.Metadata.GetValueOrDefault("gsuite:UserId");
if (string.IsNullOrWhiteSpace(_targetUserId))
_targetUserId = target.Path.TrimStart(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar).Skip(1).FirstOrDefault();
_targetCalendarId = target.Metadata.GetValueOrDefault("gsuite:Id");
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarInvalidTargetType", 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, "RestoreCalendarMissingIds", 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 calendarService = Provider._apiHelper.GetCalendarService(userId);
// Check if calendar already exists
var calendars = await calendarService.CalendarList.List().ExecuteAsync(cancel);
var existingCalendar = calendars.Items?.FirstOrDefault(c =>
c.Summary?.Equals(RESTORED_CALENDAR_NAME, StringComparison.OrdinalIgnoreCase) == true);
if (existingCalendar != null)
return existingCalendar.Id;
// Create new calendar
var newCalendar = new Calendar
{
Summary = RESTORED_CALENDAR_NAME,
TimeZone = "UTC"
};
var createdCalendar = await calendarService.Calendars.Insert(newCalendar).ExecuteAsync(cancel);
return createdCalendar.Id;
}
public async Task<string?> CreateEvent(string userId, string calendarId, Event eventItem, CancellationToken cancel)
{
var calendarService = Provider._apiHelper.GetCalendarService(userId);
// Check for duplicates by iCalUID if available
if (!Provider._ignoreExisting && !string.IsNullOrWhiteSpace(eventItem.ICalUID))
{
var existingEvents = await calendarService.Events.List(calendarId).ExecuteAsync(cancel);
var duplicate = existingEvents.Items?.FirstOrDefault(e =>
e.ICalUID?.Equals(eventItem.ICalUID, StringComparison.Ordinal) == true);
if (duplicate != null)
{
Log.WriteInformationMessage(LOGTAG, "CreateEventSkipDuplicate", $"Event with ICalUID {eventItem.ICalUID} already exists, skipping.");
return duplicate.Id;
}
}
// Clean up properties that shouldn't be sent on creation
eventItem.Id = null;
eventItem.ETag = null;
eventItem.HtmlLink = null;
eventItem.CreatedRaw = null;
eventItem.UpdatedRaw = null;
var createdEvent = await calendarService.Events.Insert(eventItem, calendarId).ExecuteAsync(cancel);
return createdEvent.Id;
}
public async Task AddAttachment(string userId, string calendarId, string eventId, string fileName, Stream contentStream, string mimeType, CancellationToken cancel)
{
var calendarService = Provider._apiHelper.GetCalendarService(userId);
// Google Calendar API doesn't support direct attachment uploads
// Attachments need to be stored in Drive and linked
// For now, we log a warning
Log.WriteWarningMessage(LOGTAG, "CalendarAttachmentsNotSupported", null, $"Calendar attachments are not directly supported in Google Calendar API. File: {fileName}");
}
public async Task RestoreAcls(string userId, string calendarId, Stream aclStream, CancellationToken cancel)
{
var calendarService = Provider._apiHelper.GetCalendarService(userId);
// Deserialize the ACL list
Acl? acl;
try
{
acl = await JsonSerializer.DeserializeAsync<Acl>(aclStream, GoogleApiJsonDeserializer.Options, cancellationToken: cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarAclInvalid", ex, $"Invalid ACL data for calendar {calendarId}");
return;
}
if (acl?.Items == null || acl.Items.Count == 0)
return;
foreach (var aclRule in acl.Items)
{
try
{
// Skip owner permissions (can't change ownership easily)
if (aclRule.Role == "owner")
continue;
// Create a new ACL rule without the ID and ETag (these are assigned by Google)
var newAclRule = new AclRule
{
Role = aclRule.Role,
Scope = aclRule.Scope != null ? new AclRule.ScopeData
{
Type = aclRule.Scope.Type,
Value = aclRule.Scope.Value
} : null
};
await calendarService.Acl.Insert(newAclRule, calendarId).ExecuteAsync(cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarAclFailed", ex, $"Failed to restore ACL rule with role {aclRule.Role} for calendar {calendarId}");
}
}
}
}
private async Task RestoreCalendarEvents(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var events = GetMetadataByType(SourceItemType.CalendarEvent);
if (events.Count == 0)
return;
(var userId, var calendarId) = await CalendarRestore.GetUserIdAndCalendarTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(calendarId))
return;
// Group attachments by event path
var attachments = GetMetadataByType(SourceItemType.CalendarEventAttachment)
.GroupBy(k => Util.AppendDirSeparator(Path.GetDirectoryName(k.Key.TrimEnd(Path.DirectorySeparatorChar)) ?? ""))
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var eventItem in events)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = eventItem.Key;
var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
var icsPath = SystemIO.IO_OS.PathCombine(originalPath, "event.ics");
var contentEntry = _temporaryFiles.GetValueOrDefault(contentPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsMissingContent", null, $"Missing content for event {originalPath}, skipping.");
continue;
}
Event? calendarEvent;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
calendarEvent = await JsonSerializer.DeserializeAsync<Event>(contentStream, cancellationToken: cancel);
}
if (calendarEvent == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsInvalidContent", null, $"Invalid content for event {originalPath}, skipping.");
continue;
}
var newEventId = await CalendarRestore.CreateEvent(userId, calendarId, calendarEvent, cancel);
// Restore attachments if any
var eventPathWithSep = Util.AppendDirSeparator(originalPath);
if (newEventId != null && attachments.TryGetValue(eventPathWithSep, out var eventAttachments))
{
foreach (var att in eventAttachments)
{
try
{
var attPath = att.Key;
var attMetadata = att.Value;
var name = attMetadata.GetValueOrDefault("gsuite:Name") ?? "attachment";
var mimeType = attMetadata.GetValueOrDefault("gsuite:MimeType") ?? "application/octet-stream";
if (_temporaryFiles.TryRemove(attPath, out var attContent))
{
using var attStream = SystemIO.IO_OS.FileOpenRead(attContent);
await CalendarRestore.AddAttachment(userId, calendarId, newEventId, name, attStream, mimeType, cancel);
_metadata.TryRemove(attPath, out _);
attContent?.Dispose();
}
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarAttachmentFailed", ex, $"Failed to restore attachment for event {originalPath}");
}
}
}
_metadata.TryRemove(originalPath, out _);
_metadata.TryRemove(contentPath, out _);
_temporaryFiles.TryRemove(contentPath, out var cFile);
cFile?.Dispose();
// We do not use the ICS file for restore, so we can remove it
_metadata.TryRemove(icsPath, out _);
_temporaryFiles.TryRemove(icsPath, out var iFile);
iFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreCalendarEventsFailed", ex, $"Failed to restore event {eventItem.Key}");
}
}
}
private async Task RestoreCalendarAcls(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var aclEntries = GetMetadataByType(SourceItemType.CalendarEventACL);
if (aclEntries.Count == 0)
return;
(var userId, var calendarId) = await CalendarRestore.GetUserIdAndCalendarTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(calendarId))
return;
foreach (var aclEntry in aclEntries)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = aclEntry.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarAclsMissingContent", null, $"Missing content for ACL {originalPath}, skipping.");
continue;
}
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
await CalendarRestore.RestoreAcls(userId, calendarId, contentStream, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreCalendarAclsFailed", ex, $"Failed to restore ACLs for {aclEntry.Key}");
}
}
}
}