// 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 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 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(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(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}"); } } } }