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