323 lines
14 KiB
C#
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|