// Copyright (c) 2026 Duplicati Inc. All rights reserved. using System.Collections.Concurrent; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Logging; using Duplicati.Library.Utility; using Duplicati.Proprietary.GoogleWorkspace.SourceItems; namespace Duplicati.Proprietary.GoogleWorkspace; public partial class RestoreProvider : IRestoreDestinationProviderModule { /// /// Log tag for RestoreProvider /// private static readonly string LOGTAG = Log.LogTagFromType(); /// /// API helper for accessing Google Workspace APIs /// private readonly APIHelper _apiHelper; /// /// Path to restore to /// private readonly string _restorePath; /// /// Whether the overwrite option has been set /// private readonly bool _hasSetOverwriteOption; /// /// Source provider for accessing currently existing items on the destination /// internal SourceProvider SourceProvider { get; init; } /// /// Properties related to the restore target /// /// The path to restore to /// The source provider entry to restore to /// The metadata associated with the restore target /// The type of the restore target internal record RestoreTargetData( string Path, ISourceProviderEntry Entry, Dictionary Metadata, SourceItemType Type); /// /// The restore target data /// internal RestoreTargetData? RestoreTarget { get; private set; } /// /// Temporary files created during the restore process /// private readonly ConcurrentDictionary _temporaryFiles = new(); /// /// Metadata recorded during the restore process /// private readonly ConcurrentDictionary> _metadata = new(); /// /// Map of restored Gmail labels to their corresponding IDs /// private readonly ConcurrentDictionary _restoredLabelMap = new(); /// /// Map of restored Drive folders to their corresponding IDs /// private readonly ConcurrentDictionary _restoredDriveFolderMap = new(); /// /// Map of restored task lists to their corresponding IDs /// private readonly ConcurrentDictionary _restoredTaskListMap = new(); /// /// Map of restored Drive files to their corresponding IDs /// private readonly ConcurrentDictionary _restoredDriveFileMap = new(); /// /// Whether to overwrite existing items /// internal readonly bool _ignoreExisting; /// /// Default constructor for the restore provider. /// Only used for loading metadata about the provider. /// public RestoreProvider() { _apiHelper = null!; _restorePath = null!; SourceProvider = null!; _hasSetOverwriteOption = false; } /// /// Constructs the RestoreProvider with the given URL and options. /// /// The destination URL for the restore operation /// The options for the restore operation public RestoreProvider(string url, Dictionary options) { var uri = new Library.Utility.Uri(url); _restorePath = uri.HostAndPath; var parsedOptions = OptionsHelper.ParseOptions(options); _apiHelper = new APIHelper(parsedOptions, isRestoreOperation: true); _ignoreExisting = Utility.ParseBoolOption(options, OptionsHelper.GOOGLE_IGNORE_EXISTING_OPTION); _hasSetOverwriteOption = Utility.ParseBoolOption(options, "overwrite"); var sourceOpts = new Dictionary(options); sourceOpts["store-metadata-content-in-database"] = "true"; SourceProvider = new SourceProvider("googleworkspace://", "", sourceOpts, true); } /// public string Key => OptionsHelper.ModuleKey; /// public string DisplayName => Strings.Common.DisplayName; /// public string Description => Strings.Common.Description; /// public IList SupportedCommands => OptionsHelper.RestoreSupportedCommands.ToList(); /// public string TargetDestination => _restorePath; /// public Task CreateFolderIfNotExists(string path, CancellationToken cancel) { return Task.FromResult(false); } /// public async Task Initialize(CancellationToken cancel) { if (!_hasSetOverwriteOption) throw new UserInformationException(Strings.RestoreTargetMissingOverwriteOption("overwrite", OptionsHelper.GOOGLE_IGNORE_EXISTING_OPTION), "OverwriteOptionNotSet"); await SourceProvider.Initialize(cancel); var entry = await SourceProvider.GetEntry(_restorePath, isFolder: true, cancel); if (entry == null) throw new UserInformationException(Strings.RestoreTargetNotFound(_restorePath), "RestoreTargetNotFound"); var metadata = await entry.GetMinorMetadata(cancel); var type = metadata.TryGetValue("gsuite:Type", out var typeStr) && Enum.TryParse(typeStr, out var sourceItemType) ? sourceItemType : throw new UserInformationException(Strings.InvalidRestoreTargetType(typeStr), "InvalidRestoreTargetType"); RestoreTarget = new RestoreTargetData(_restorePath, entry, metadata, type); } /// public async Task Test(CancellationToken cancellationToken) { _apiHelper.TestConnection(); // If we have no path, just check that we can connect if (string.IsNullOrWhiteSpace(_restorePath)) return; // Otherwise, check that the path exists and is a valid target await Initialize(cancellationToken); } /// /// Normalizes the given path by applying the RemovedRestorePrefix if it has been removed /// /// The path to normalize /// The normalized path private string NormalizePath(string path) => path; /// public void Dispose() { SourceProvider?.Dispose(); foreach (var file in _temporaryFiles.Values) file.Dispose(); _temporaryFiles.Clear(); } /// public async Task FileExists(string path, CancellationToken cancel) { path = NormalizePath(path); if (_temporaryFiles.ContainsKey(path)) return true; var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); return entry != null; } /// public Task OpenWrite(string path, CancellationToken cancel) { path = NormalizePath(path); var file = _temporaryFiles.GetOrAdd(path, _ => new TempFile()); return Task.FromResult(SystemIO.IO_OS.FileOpenWrite(file)); } /// public async Task OpenRead(string path, CancellationToken cancel) { path = NormalizePath(path); if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileOpenRead(_temporaryFiles[path]); var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); if (entry != null) return await entry.OpenRead(cancel).ConfigureAwait(false); throw new FileNotFoundException($"File not found: {path}"); } /// public async Task OpenReadWrite(string path, CancellationToken cancel) { path = NormalizePath(path); if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]); var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); if (entry == null) { _temporaryFiles.GetOrAdd(path, _ => new TempFile()); return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]); } if (entry.IsFolder) throw new FileNotFoundException($"Path is a folder: {path}"); var file = new TempFile(); using (var fs = SystemIO.IO_OS.FileOpenWrite(file)) using (var stream = await entry.OpenRead(cancel).ConfigureAwait(false)) await stream.CopyToAsync(fs).ConfigureAwait(false); _temporaryFiles[path] = file; return SystemIO.IO_OS.FileOpenReadWrite(file); } /// public async Task GetFileLength(string path, CancellationToken cancel) { path = NormalizePath(path); if (_temporaryFiles.ContainsKey(path)) return SystemIO.IO_OS.FileLength(_temporaryFiles[path]); var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false); if (entry == null) throw new FileNotFoundException($"File not found: {path}"); return entry.Size; } /// public Task HasReadOnlyAttribute(string path, CancellationToken cancel) => Task.FromResult(false); /// public Task ClearReadOnlyAttribute(string path, CancellationToken cancel) => Task.CompletedTask; /// public Task WriteMetadata(string path, Dictionary metadata, bool restoreSymlinkMetadata, bool restorePermissions, CancellationToken cancel) { path = NormalizePath(path); _metadata.AddOrUpdate(path, metadata, (_, _) => metadata); return Task.FromResult(true); } /// public Task DeleteFolder(string path, CancellationToken cancel) => Task.CompletedTask; /// public Task DeleteFile(string path, CancellationToken cancel) => throw new NotImplementedException("File deletion is not supported in Google Workspace RestoreProvider"); /// public async Task Finalize(Action? progressCallback, CancellationToken cancel) { var totalFiles = _metadata.Count; var completedPhases = 0; const int totalPhases = 13; // Phase 1: Restore Gmail Labels try { await RestoreGmailLabels(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailLabelsFailed", ex, $"Failed to restore Gmail labels"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 2: Restore Gmail Messages try { await RestoreGmailMessages(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailMessagesFailed", ex, $"Failed to restore Gmail messages"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 3: Restore Gmail Settings try { await RestoreGmailSettings(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailSettingsFailed", ex, $"Failed to restore Gmail settings"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 4: Restore Drive Folders try { await RestoreDriveFolders(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFoldersFailed", ex, $"Failed to restore Drive folders"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 5: Restore Drive Files try { await RestoreDriveFiles(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFilesFailed", ex, $"Failed to restore Drive files"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 6: Restore Drive Permissions try { await RestoreDrivePermissions(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDrivePermissionsFailed", ex, $"Failed to restore Drive permissions"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 7: Restore Calendar Events try { await RestoreCalendarEvents(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreCalendarEventsFailed", ex, $"Failed to restore calendar events"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 8: Restore Calendar ACLs try { await RestoreCalendarAcls(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreCalendarEventsFailed", ex, $"Failed to restore calendar events"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 9: Restore Contacts try { await RestoreContacts(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactsFailed", ex, $"Failed to restore contacts"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 10: Restore Contact Groups try { await RestoreContactGroups(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactGroupsFailed", ex, $"Failed to restore contact groups"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 11: Restore Task Lists and Tasks try { await RestoreTaskLists(cancel).ConfigureAwait(false); await RestoreTasks(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTasksFailed", ex, $"Failed to restore tasks"); } completedPhases++; progressCallback?.Invoke(completedPhases / (double)totalPhases); // Phase 12: Restore Keep Notes (if target supports it) try { await RestoreKeepNotes(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreKeepNotesFailed", ex, $"Failed to restore Keep notes"); } // Phase 13: Restore Chat Messages (if target supports it) try { await RestoreChatMessages(cancel).ConfigureAwait(false); } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreChatMessagesFailed", ex, $"Failed to restore chat messages"); } RemoveMetaEntriesForNonRestoredItems(); // We should have restored all items by now if (_metadata.Count > 0 || _temporaryFiles.Count > 0) { var displayItems = _metadata.Values .Select(x => (Type: x.GetValueOrDefault("gsuite:Type"), Name: x.GetValueOrDefault("gsuite:Name") ?? x.GetValueOrDefault("gsuite:DisplayName"))) .Where(x => !string.IsNullOrWhiteSpace(x.Type) && !string.IsNullOrWhiteSpace(x.Name)) .Take(10); foreach (var item in displayItems) Log.WriteWarningMessage(LOGTAG, "FinalizeIncompleteItem", null, $"Unrestored item - Type: {item.Type}, Name: {item.Name}"); if (_metadata.Count > displayItems.Count()) Log.WriteWarningMessage(LOGTAG, "FinalizeIncomplete", null, $"... and {_metadata.Count - displayItems.Count()} more unrestored items."); } var tempFiles = _temporaryFiles.Values.ToList(); _temporaryFiles.Clear(); foreach (var file in tempFiles) file.Dispose(); _metadata.Clear(); } /// /// Gets metadata entries by their source item type /// /// The source item type to filter by /// The list of metadata entries matching the given type private List>> GetMetadataByType(SourceItemType type) => _metadata .Where(kv => kv.Value.TryGetValue("gsuite:Type", out var typeStr) && typeStr == type.ToString()) .ToList(); /// /// Removes metadata entries for items that were not restored and are not required to be processed /// private void RemoveMetaEntriesForNonRestoredItems() { var toRemove = new HashSet(); foreach (var kv in _metadata) { // Some intermediate directories may not have a type if (!kv.Value.ContainsKey("gsuite:Type")) { toRemove.Add(kv.Key); continue; } var key = kv.Value.GetValueOrDefault("gsuite:Type"); if (!Enum.TryParse(key, out var type)) continue; switch (type) { // Various meta-types that are not restored directly case SourceItemType.User: case SourceItemType.UserGmail: case SourceItemType.UserDrive: case SourceItemType.UserCalendar: case SourceItemType.UserContacts: case SourceItemType.UserTasks: case SourceItemType.UserKeep: case SourceItemType.UserChat: case SourceItemType.Calendar: toRemove.Add(kv.Key); break; default: break; } // If we are restoring TO a specific label, we don't need to restore the label itself if (type == SourceItemType.GmailLabel && RestoreTarget != null && RestoreTarget.Type == SourceItemType.GmailLabel) toRemove.Add(kv.Key); // If we are restoring TO a specific folder, we don't need to restore the folder itself if (type == SourceItemType.DriveFolder && RestoreTarget != null && RestoreTarget.Type == SourceItemType.DriveFolder) toRemove.Add(kv.Key); } foreach (var key in toRemove) { _metadata.TryRemove(key, out _); _temporaryFiles.TryRemove(key, out var f); f?.Dispose(); } } /// public IList GetPriorityFiles() => new List(); }