diff --git a/Duplicati.slnx b/Duplicati.slnx
index 76d7df4a3..7d010723a 100644
--- a/Duplicati.slnx
+++ b/Duplicati.slnx
@@ -75,6 +75,7 @@
+
diff --git a/proprietary/GoogleWorkspace/APIHelper.cs b/proprietary/GoogleWorkspace/APIHelper.cs
new file mode 100644
index 000000000..f72ec74f1
--- /dev/null
+++ b/proprietary/GoogleWorkspace/APIHelper.cs
@@ -0,0 +1,248 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Google.Apis.Auth.OAuth2;
+using Google.Apis.Auth.OAuth2.Flows;
+using Google.Apis.Gmail.v1;
+using Google.Apis.Admin.Directory.directory_v1;
+using Google.Apis.Calendar.v3;
+using Google.Apis.Drive.v3;
+using Google.Apis.PeopleService.v1;
+using Google.Apis.Tasks.v1;
+using Google.Apis.Keep.v1;
+using Google.Apis.Groupssettings.v1;
+using Google.Apis.HangoutsChat.v1;
+using Google.Apis.Services;
+using Google.Apis.Http;
+using Google.Apis.Util;
+
+namespace Duplicati.Proprietary.GoogleWorkspace;
+
+public class APIHelper
+{
+ private readonly string? _clientId;
+ private readonly string? _clientSecret;
+ private readonly string? _refreshToken;
+ private readonly string? _serviceAccountJson;
+ private readonly string? _adminEmail;
+ private readonly OptionsHelper.GoogleWorkspaceOptions _options;
+ private readonly HashSet _scopes;
+
+ private GmailService? _gmailService;
+ private DirectoryService? _directoryService;
+ private CalendarService? _calendarService;
+ private DriveService? _driveService;
+ private PeopleServiceService? _peopleService;
+ private TasksService? _tasksService;
+ private KeepService? _keepService;
+ private GroupssettingsService? _groupsSettingsService;
+ private HangoutsChatService? _chatService;
+
+ private static readonly Dictionary _rootTypeScopes = new()
+ {
+ { GoogleRootType.Users, [DirectoryService.Scope.AdminDirectoryUserReadonly] },
+ { GoogleRootType.Groups, [DirectoryService.Scope.AdminDirectoryGroupReadonly, GroupssettingsService.Scope.AppsGroupsSettings] },
+ { GoogleRootType.SharedDrives, [DriveService.Scope.DriveReadonly] },
+ { GoogleRootType.Sites, [DriveService.Scope.DriveReadonly] },
+ { GoogleRootType.OrganizationalUnits, [DirectoryService.Scope.AdminDirectoryOrgunitReadonly] }
+ };
+
+ private static readonly Dictionary _userTypeScopes = new()
+ {
+ { GoogleUserType.Gmail, [GmailService.Scope.GmailReadonly] },
+ { GoogleUserType.Drive, [DriveService.Scope.DriveReadonly] },
+ { GoogleUserType.Calendar, [CalendarService.Scope.CalendarReadonly] },
+ { GoogleUserType.Contacts, [PeopleServiceService.Scope.ContactsReadonly] },
+ { GoogleUserType.Tasks, [TasksService.Scope.TasksReadonly] },
+ { GoogleUserType.Keep, [KeepService.Scope.KeepReadonly] },
+ { GoogleUserType.Chat, [HangoutsChatService.Scope.ChatSpacesReadonly, HangoutsChatService.Scope.ChatMessagesReadonly, HangoutsChatService.Scope.ChatMembershipsReadonly] }
+ };
+
+ public APIHelper(OptionsHelper.GoogleWorkspaceOptions options)
+ {
+ _options = options;
+ _clientId = options.ClientId;
+ _clientSecret = options.ClientSecret;
+ _refreshToken = options.RefreshToken;
+ _serviceAccountJson = options.ServiceAccountJson;
+ _adminEmail = options.AdminEmail;
+ _scopes = GetConfiguredScopes();
+ }
+
+ public void Initialize()
+ {
+ var initializer = GetServiceInitializer();
+
+ _gmailService = new GmailService(initializer);
+ _directoryService = new DirectoryService(initializer);
+ _calendarService = new CalendarService(initializer);
+ _driveService = new DriveService(initializer);
+ _peopleService = new PeopleServiceService(initializer);
+ _tasksService = new TasksService(initializer);
+ _keepService = new KeepService(initializer);
+ _groupsSettingsService = new GroupssettingsService(initializer);
+ _chatService = new HangoutsChatService(initializer);
+ }
+
+ private BaseClientService.Initializer GetServiceInitializer(string? userId = null)
+ {
+ var backoffInitializer = new BackOffInitializer();
+
+ if (!string.IsNullOrEmpty(_serviceAccountJson))
+ {
+ var credential = GoogleCredential.FromJson(_serviceAccountJson)
+ .CreateScoped(_scopes.ToArray());
+
+ var userToImpersonate = userId ?? _adminEmail;
+ if (!string.IsNullOrEmpty(userToImpersonate))
+ {
+ credential = credential.CreateWithUser(userToImpersonate);
+ }
+
+ return new BaseClientService.Initializer()
+ {
+ HttpClientInitializer = new CompositeHttpClientInitializer(credential, backoffInitializer),
+ ApplicationName = "Duplicati",
+ };
+ }
+ else if (!string.IsNullOrEmpty(_clientId) && !string.IsNullOrEmpty(_clientSecret) && !string.IsNullOrEmpty(_refreshToken))
+ {
+ var credential = new UserCredential(new GoogleAuthorizationCodeFlow(
+ new GoogleAuthorizationCodeFlow.Initializer
+ {
+ ClientSecrets = new ClientSecrets
+ {
+ ClientId = _clientId,
+ ClientSecret = _clientSecret
+ }
+ }),
+ "user",
+ new Google.Apis.Auth.OAuth2.Responses.TokenResponse { RefreshToken = _refreshToken });
+
+ return new BaseClientService.Initializer()
+ {
+ HttpClientInitializer = new CompositeHttpClientInitializer(credential, backoffInitializer),
+ ApplicationName = "Duplicati",
+ };
+ }
+ else
+ {
+ throw new Exception("Missing credentials");
+ }
+ }
+
+ public void TestConnection()
+ {
+ if (_gmailService == null) Initialize();
+ _gmailService!.Users.GetProfile("me").Execute();
+ }
+
+ private HashSet GetConfiguredScopes()
+ {
+ var scopes = new HashSet();
+ if (_options.RequestedScopes != null && _options.RequestedScopes.Length > 0)
+ {
+ scopes.UnionWith(_options.RequestedScopes);
+ }
+ else
+ {
+ foreach (var type in _options.IncludedRootTypes)
+ if (_rootTypeScopes.TryGetValue(type, out var s))
+ scopes.UnionWith(s);
+
+ foreach (var type in _options.IncludedUserTypes)
+ if (_userTypeScopes.TryGetValue(type, out var s))
+ scopes.UnionWith(s);
+ }
+ return scopes;
+ }
+
+ public bool HasScope(string scope)
+ {
+ return _scopes.Contains(scope);
+ }
+
+ public GmailService GetGmailService(string userId)
+ {
+ return new GmailService(GetServiceInitializer(userId));
+ }
+
+ public DirectoryService GetDirectoryService()
+ {
+ if (_directoryService == null) Initialize();
+ return _directoryService!;
+ }
+
+ public CalendarService GetCalendarService()
+ {
+ if (_calendarService == null) Initialize();
+ return _calendarService!;
+ }
+
+ public DriveService GetDriveService()
+ {
+ if (_driveService == null) Initialize();
+ return _driveService!;
+ }
+
+ public PeopleServiceService GetPeopleService()
+ {
+ if (_peopleService == null) Initialize();
+ return _peopleService!;
+ }
+
+ public TasksService GetTasksService()
+ {
+ if (_tasksService == null) Initialize();
+ return _tasksService!;
+ }
+
+ public KeepService GetKeepService()
+ {
+ if (_keepService == null) Initialize();
+ return _keepService!;
+ }
+
+ public GroupssettingsService GetGroupsSettingsService()
+ {
+ if (_groupsSettingsService == null) Initialize();
+ return _groupsSettingsService!;
+ }
+
+ public HangoutsChatService GetChatService()
+ {
+ if (_chatService == null) Initialize();
+ return _chatService!;
+ }
+
+ private class CompositeHttpClientInitializer : IConfigurableHttpClientInitializer
+ {
+ private readonly IConfigurableHttpClientInitializer[] _initializers;
+
+ public CompositeHttpClientInitializer(params IConfigurableHttpClientInitializer[] initializers)
+ {
+ _initializers = initializers;
+ }
+
+ public void Initialize(ConfigurableHttpClient httpClient)
+ {
+ foreach (var initializer in _initializers)
+ {
+ initializer.Initialize(httpClient);
+ }
+ }
+ }
+
+ private class BackOffInitializer : IConfigurableHttpClientInitializer
+ {
+ public void Initialize(ConfigurableHttpClient httpClient)
+ {
+ var backoffHandler = new BackOffHandler(new BackOffHandler.Initializer(new ExponentialBackOff())
+ {
+ HandleUnsuccessfulResponseFunc = (r) => r.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable || r.StatusCode == (System.Net.HttpStatusCode)429
+ });
+
+ httpClient.MessageHandler.AddUnsuccessfulResponseHandler(backoffHandler);
+ httpClient.MessageHandler.AddExceptionHandler(backoffHandler);
+ }
+ }
+}
diff --git a/proprietary/GoogleWorkspace/Duplicati.Proprietary.GoogleWorkspace.csproj b/proprietary/GoogleWorkspace/Duplicati.Proprietary.GoogleWorkspace.csproj
new file mode 100644
index 000000000..65471b8d2
--- /dev/null
+++ b/proprietary/GoogleWorkspace/Duplicati.Proprietary.GoogleWorkspace.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ Library
+ Duplicati.Proprietary.GoogleWorkspace
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/proprietary/GoogleWorkspace/OptionsHelper.cs b/proprietary/GoogleWorkspace/OptionsHelper.cs
new file mode 100644
index 000000000..fc9c41cce
--- /dev/null
+++ b/proprietary/GoogleWorkspace/OptionsHelper.cs
@@ -0,0 +1,108 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility.Options;
+
+namespace Duplicati.Proprietary.GoogleWorkspace;
+
+[Flags]
+public enum GoogleRootType
+{
+ Users = 1,
+ Groups = 2,
+ SharedDrives = 4,
+ Sites = 8,
+ OrganizationalUnits = 16
+}
+
+[Flags]
+public enum GoogleUserType
+{
+ Gmail = 1,
+ Drive = 2,
+ Calendar = 4,
+ Contacts = 8,
+ Tasks = 16,
+ Keep = 32,
+ Chat = 64
+}
+
+public static class OptionsHelper
+{
+ public const string ModuleKey = "googleworkspace";
+
+ public const string GOOGLE_CLIENT_ID_OPTION = "google-client-id";
+ public const string GOOGLE_CLIENT_SECRET_OPTION = "google-client-secret";
+ public const string GOOGLE_REFRESH_TOKEN_OPTION = "google-refresh-token";
+ public const string GOOGLE_SERVICE_ACCOUNT_JSON_OPTION = "google-service-account-json";
+ public const string GOOGLE_SERVICE_ACCOUNT_FILE_OPTION = "google-service-account-file";
+ public const string GOOGLE_ADMIN_EMAIL_OPTION = "google-admin-email";
+ public const string GOOGLE_INCLUDED_ROOT_TYPES_OPTION = "google-included-root-types";
+ public const string GOOGLE_INCLUDED_USER_TYPES_OPTION = "google-included-user-types";
+ public const string GOOGLE_REQUESTED_SCOPES_OPTION = "google-requested-scopes";
+
+ public static readonly ICommandLineArgument[] SupportedCommands =
+ [
+ new CommandLineArgument(GOOGLE_CLIENT_ID_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.GoogleClientIdShort, Strings.Options.GoogleClientIdLong, null, [AuthOptionsHelper.AuthUsernameOption]),
+ new CommandLineArgument(GOOGLE_CLIENT_SECRET_OPTION, CommandLineArgument.ArgumentType.Password, Strings.Options.GoogleClientSecretShort, Strings.Options.GoogleClientSecretLong, null, [AuthOptionsHelper.AuthPasswordOption]),
+ new CommandLineArgument(GOOGLE_REFRESH_TOKEN_OPTION, CommandLineArgument.ArgumentType.Password, Strings.Options.GoogleRefreshTokenShort, Strings.Options.GoogleRefreshTokenLong),
+ new CommandLineArgument(GOOGLE_SERVICE_ACCOUNT_JSON_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.GoogleServiceAccountJsonShort, Strings.Options.GoogleServiceAccountJsonLong),
+ new CommandLineArgument(GOOGLE_SERVICE_ACCOUNT_FILE_OPTION, CommandLineArgument.ArgumentType.Path, Strings.Options.GoogleServiceAccountFileShort, Strings.Options.GoogleServiceAccountFileLong),
+ new CommandLineArgument(GOOGLE_ADMIN_EMAIL_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.GoogleAdminEmailShort, Strings.Options.GoogleAdminEmailLong),
+ new CommandLineArgument(GOOGLE_INCLUDED_ROOT_TYPES_OPTION, CommandLineArgument.ArgumentType.Flags, Strings.Options.GoogleIncludedRootTypesShort, Strings.Options.GoogleIncludedRootTypesLong, string.Join(",", Enum.GetValues().Select(n => n.ToString())), null, Enum.GetNames(typeof(GoogleRootType))),
+ new CommandLineArgument(GOOGLE_INCLUDED_USER_TYPES_OPTION, CommandLineArgument.ArgumentType.Flags, Strings.Options.GoogleIncludedUserTypesShort, Strings.Options.GoogleIncludedUserTypesLong, string.Join(",", Enum.GetValues().Select(n => n.ToString())), null, Enum.GetNames(typeof(GoogleUserType))),
+ new CommandLineArgument(GOOGLE_REQUESTED_SCOPES_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.GoogleRequestedScopesShort, Strings.Options.GoogleRequestedScopesLong),
+ ];
+
+ public class GoogleWorkspaceOptions
+ {
+ public string? ClientId { get; set; }
+ public string? ClientSecret { get; set; }
+ public string? RefreshToken { get; set; }
+ public string? ServiceAccountJson { get; set; }
+ public string? AdminEmail { get; set; }
+ public GoogleRootType[] IncludedRootTypes { get; set; } = Enum.GetValues().ToArray();
+ public GoogleUserType[] IncludedUserTypes { get; set; } = Enum.GetValues().ToArray();
+ public string[]? RequestedScopes { get; set; }
+ }
+
+ public static GoogleWorkspaceOptions ParseOptions(Dictionary options)
+ {
+ var result = new GoogleWorkspaceOptions();
+
+ if (options.ContainsKey(GOOGLE_CLIENT_ID_OPTION))
+ result.ClientId = options[GOOGLE_CLIENT_ID_OPTION];
+ if (options.ContainsKey(GOOGLE_CLIENT_SECRET_OPTION))
+ result.ClientSecret = options[GOOGLE_CLIENT_SECRET_OPTION];
+ if (options.ContainsKey(GOOGLE_REFRESH_TOKEN_OPTION))
+ result.RefreshToken = options[GOOGLE_REFRESH_TOKEN_OPTION];
+
+ if (options.ContainsKey(GOOGLE_SERVICE_ACCOUNT_JSON_OPTION))
+ result.ServiceAccountJson = options[GOOGLE_SERVICE_ACCOUNT_JSON_OPTION];
+
+ if (string.IsNullOrEmpty(result.ServiceAccountJson) && options.ContainsKey(GOOGLE_SERVICE_ACCOUNT_FILE_OPTION))
+ {
+ var path = options[GOOGLE_SERVICE_ACCOUNT_FILE_OPTION];
+ if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
+ {
+ result.ServiceAccountJson = System.IO.File.ReadAllText(path);
+ }
+ }
+
+ if (options.ContainsKey(GOOGLE_ADMIN_EMAIL_OPTION))
+ result.AdminEmail = options[GOOGLE_ADMIN_EMAIL_OPTION];
+
+ var includedRootTypes = Library.Utility.Utility.ParseFlagsOption(options, GOOGLE_INCLUDED_ROOT_TYPES_OPTION, (GoogleRootType)Enum.GetValues().Cast().Aggregate(0, (acc, type) => acc | type));
+ var includedUserTypes = Library.Utility.Utility.ParseFlagsOption(options, GOOGLE_INCLUDED_USER_TYPES_OPTION, (GoogleUserType)Enum.GetValues().Cast().Aggregate(0, (acc, type) => acc | type));
+
+ result.IncludedRootTypes = Enum.GetValues().Where(n => includedRootTypes.HasFlag(n)).ToArray();
+ result.IncludedUserTypes = Enum.GetValues().Where(n => includedUserTypes.HasFlag(n)).ToArray();
+
+ if (options.ContainsKey(GOOGLE_REQUESTED_SCOPES_OPTION))
+ {
+ result.RequestedScopes = options[GOOGLE_REQUESTED_SCOPES_OPTION]?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToArray();
+ }
+
+ return result;
+ }
+}
diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs
new file mode 100644
index 000000000..786ab7bcd
--- /dev/null
+++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs
@@ -0,0 +1,149 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.GoogleWorkspace;
+
+public sealed class RestoreProvider : IRestoreDestinationProviderModule, IDisposable
+{
+ private readonly APIHelper _apiHelper;
+ private readonly string _restorePath;
+ private readonly OptionsHelper.GoogleWorkspaceOptions _options;
+ private readonly string _tempPath;
+ private readonly Dictionary _tempFiles = new();
+ private readonly Dictionary> _metadata = new();
+
+ public RestoreProvider()
+ {
+ _apiHelper = null!;
+ _restorePath = null!;
+ _options = null!;
+ _tempPath = null!;
+ }
+
+ public RestoreProvider(string url, string restorePath, Dictionary options)
+ {
+ _restorePath = restorePath;
+ _options = OptionsHelper.ParseOptions(options);
+ _apiHelper = new APIHelper(_options);
+
+ _tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Duplicati-GoogleWorkspace-Restore-" + Guid.NewGuid().ToString());
+ System.IO.Directory.CreateDirectory(_tempPath);
+ }
+
+ public string Key => OptionsHelper.ModuleKey;
+
+ public string DisplayName => Strings.Common.DisplayName;
+
+ public string Description => Strings.Common.Description;
+
+ public IList SupportedCommands => OptionsHelper.SupportedCommands;
+
+ public string TargetDestination => _restorePath;
+
+ public void Dispose()
+ {
+ if (!string.IsNullOrEmpty(_tempPath) && System.IO.Directory.Exists(_tempPath))
+ {
+ try
+ {
+ System.IO.Directory.Delete(_tempPath, true);
+ }
+ catch { }
+ }
+ }
+
+ public Task Initialize(CancellationToken cancellationToken)
+ {
+ _apiHelper.Initialize();
+ return Task.CompletedTask;
+ }
+
+ public Task Test(CancellationToken cancellationToken)
+ {
+ _apiHelper.TestConnection();
+ return Task.CompletedTask;
+ }
+
+ public Task CreateFolderIfNotExists(string path, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(true);
+ }
+
+ public Task FileExists(string path, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(_tempFiles.ContainsKey(path));
+ }
+
+ public Task OpenWrite(string path, CancellationToken cancellationToken)
+ {
+ var tempFile = System.IO.Path.Combine(_tempPath, Guid.NewGuid().ToString());
+ _tempFiles[path] = tempFile;
+ return Task.FromResult(new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None));
+ }
+
+ public Task OpenRead(string path, CancellationToken cancellationToken)
+ {
+ if (_tempFiles.TryGetValue(path, out var tempFile) && System.IO.File.Exists(tempFile))
+ {
+ return Task.FromResult(new FileStream(tempFile, FileMode.Open, FileAccess.Read, FileShare.Read));
+ }
+ throw new FileNotFoundException("File not found in temp storage", path);
+ }
+
+ public Task OpenReadWrite(string path, CancellationToken cancellationToken)
+ {
+ if (_tempFiles.TryGetValue(path, out var tempFile) && System.IO.File.Exists(tempFile))
+ {
+ return Task.FromResult(new FileStream(tempFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None));
+ }
+ throw new FileNotFoundException("File not found in temp storage", path);
+ }
+
+ public Task GetFileLength(string path, CancellationToken cancellationToken)
+ {
+ if (_tempFiles.TryGetValue(path, out var tempFile) && System.IO.File.Exists(tempFile))
+ {
+ return Task.FromResult(new FileInfo(tempFile).Length);
+ }
+ return Task.FromResult(-1L);
+ }
+
+ public Task HasReadOnlyAttribute(string path, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(false);
+ }
+
+ public Task ClearReadOnlyAttribute(string path, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ public Task WriteMetadata(string path, Dictionary metadata, bool isFolder, bool isRoot, CancellationToken cancellationToken)
+ {
+ _metadata[path] = metadata;
+ return Task.FromResult(true);
+ }
+
+ public Task DeleteFolder(string path, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteFile(string path, CancellationToken cancellationToken)
+ {
+ if (_tempFiles.TryGetValue(path, out var tempFile))
+ {
+ if (System.IO.File.Exists(tempFile))
+ System.IO.File.Delete(tempFile);
+ _tempFiles.Remove(path);
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task Finalize(Action? progress, CancellationToken cancellationToken)
+ {
+ // TODO: Implement restore logic based on _tempFiles and _metadata
+ return Task.CompletedTask;
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarAclSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarAclSourceEntry.cs
new file mode 100644
index 000000000..3a9db0965
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/CalendarAclSourceEntry.cs
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class CalendarAclSourceEntry(SourceProvider provider, string parentPath, string calendarId)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "acl.json"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetCalendarService();
+ var request = service.Acl.List(calendarId);
+ var acl = await request.ExecuteAsync(cancellationToken);
+
+ var json = JsonSerializer.Serialize(acl, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarEventAttachmentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarEventAttachmentSourceEntry.cs
new file mode 100644
index 000000000..b505b164a
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/CalendarEventAttachmentSourceEntry.cs
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Calendar.v3.Data;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class CalendarEventAttachmentSourceEntry(SourceProvider provider, string parentPath, EventAttachment attachment)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, attachment.Title), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ if (!string.IsNullOrEmpty(attachment.FileId))
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Files.Get(attachment.FileId);
+ var stream = new MemoryStream();
+ await request.DownloadAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+ else if (!string.IsNullOrEmpty(attachment.FileUrl))
+ {
+ using var client = new HttpClient();
+ var response = await client.GetAsync(attachment.FileUrl, cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ var stream = new MemoryStream();
+ await response.Content.CopyToAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+ }
+
+ throw new FileNotFoundException("Cannot download attachment");
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.CalendarEventAttachment.ToString() },
+ { "gsuite:Name", attachment.Title },
+ { "gsuite:id", attachment.FileId ?? attachment.FileUrl },
+ { "gsuite:MimeType", attachment.MimeType }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarEventFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarEventFileSourceEntry.cs
new file mode 100644
index 000000000..bcc334f76
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/CalendarEventFileSourceEntry.cs
@@ -0,0 +1,65 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Calendar.v3.Data;
+using System.Text;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class CalendarEventFileSourceEntry(string parentPath, Event evt)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "event.ics"),
+ evt.CreatedDateTimeOffset.HasValue ? evt.CreatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch,
+ evt.UpdatedDateTimeOffset.HasValue ? evt.UpdatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("BEGIN:VCALENDAR");
+ sb.AppendLine("VERSION:2.0");
+ sb.AppendLine("PRODID:-//Duplicati//Google Workspace Backup//EN");
+ sb.AppendLine("BEGIN:VEVENT");
+ sb.AppendLine($"UID:{evt.Id}");
+ sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
+
+ if (evt.Start != null)
+ {
+ if (evt.Start.DateTimeDateTimeOffset.HasValue)
+ sb.AppendLine($"DTSTART:{evt.Start.DateTimeDateTimeOffset.Value.UtcDateTime:yyyyMMddTHHmmssZ}");
+ else if (evt.Start.Date != null)
+ sb.AppendLine($"DTSTART;VALUE=DATE:{evt.Start.Date.Replace("-", "")}");
+ }
+
+ if (evt.End != null)
+ {
+ if (evt.End.DateTimeDateTimeOffset.HasValue)
+ sb.AppendLine($"DTEND:{evt.End.DateTimeDateTimeOffset.Value.UtcDateTime:yyyyMMddTHHmmssZ}");
+ else if (evt.End.Date != null)
+ sb.AppendLine($"DTEND;VALUE=DATE:{evt.End.Date.Replace("-", "")}");
+ }
+
+ if (!string.IsNullOrEmpty(evt.Summary)) sb.AppendLine($"SUMMARY:{evt.Summary}");
+ if (!string.IsNullOrEmpty(evt.Description)) sb.AppendLine($"DESCRIPTION:{evt.Description}");
+ if (!string.IsNullOrEmpty(evt.Location)) sb.AppendLine($"LOCATION:{evt.Location}");
+
+ sb.AppendLine("END:VEVENT");
+ sb.AppendLine("END:VCALENDAR");
+
+ return new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString()));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.CalendarEvent.ToString() },
+ { "gsuite:Name", "event.ics" },
+ { "gsuite:id", evt.Id },
+ { "gsuite:HtmlLink", evt.HtmlLink }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarEventSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarEventSourceEntry.cs
new file mode 100644
index 000000000..8a6e88f01
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/CalendarEventSourceEntry.cs
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.Calendar.v3.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class CalendarEventSourceEntry(SourceProvider provider, string parentPath, Event evt)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, evt.Id)), evt.CreatedDateTimeOffset.HasValue ? evt.CreatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, evt.UpdatedDateTimeOffset.HasValue ? evt.UpdatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new CalendarEventFileSourceEntry(this.Path, evt);
+
+ if (evt.Attachments != null)
+ {
+ foreach (var attachment in evt.Attachments)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new CalendarEventAttachmentSourceEntry(provider, this.Path, attachment);
+ }
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.CalendarEvent.ToString() },
+ { "gsuite:Name", evt.Summary ?? evt.Id },
+ { "gsuite:id", evt.Id },
+ { "gsuite:HtmlLink", evt.HtmlLink }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarMetadataSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarMetadataSourceEntry.cs
new file mode 100644
index 000000000..95ed73be6
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/CalendarMetadataSourceEntry.cs
@@ -0,0 +1,19 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Calendar.v3.Data;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class CalendarMetadataSourceEntry(string parentPath, CalendarListEntry calendar)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "metadata.json"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(calendar, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarSourceEntry.cs
new file mode 100644
index 000000000..3c010ab3c
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/CalendarSourceEntry.cs
@@ -0,0 +1,58 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.Calendar.v3;
+using Google.Apis.Calendar.v3.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class CalendarSourceEntry(SourceProvider provider, string parentPath, CalendarListEntry calendar)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, calendar.SummaryOverride ?? calendar.Summary)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new CalendarMetadataSourceEntry(this.Path, calendar);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ if (provider.ApiHelper.HasScope(CalendarService.Scope.Calendar))
+ yield return new CalendarAclSourceEntry(provider, this.Path, calendar.Id);
+
+ var service = provider.ApiHelper.GetCalendarService();
+ var request = service.Events.List(calendar.Id);
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var events = await request.ExecuteAsync(cancellationToken);
+
+ if (events.Items != null)
+ {
+ foreach (var evt in events.Items)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new CalendarEventSourceEntry(provider, this.Path, evt);
+ }
+ }
+ nextPageToken = events.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.Calendar.ToString() },
+ { "gsuite:Name", calendar.SummaryOverride ?? calendar.Summary },
+ { "gsuite:id", calendar.Id },
+ { "gsuite:Description", calendar.Description }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatAttachmentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatAttachmentSourceEntry.cs
new file mode 100644
index 000000000..072a1dbec
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatAttachmentSourceEntry.cs
@@ -0,0 +1,44 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.HangoutsChat.v1.Data;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ChatAttachmentSourceEntry(string parentPath, Attachment attachment)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, attachment.ContentName ?? attachment.Name.Split('/').Last()), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ if (!string.IsNullOrEmpty(attachment.DownloadUri))
+ {
+ using var client = new HttpClient();
+ var response = await client.GetAsync(attachment.DownloadUri, cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ var stream = new MemoryStream();
+ await response.Content.CopyToAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+ }
+
+ throw new FileNotFoundException("Cannot download attachment");
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.ChatAttachment.ToString() },
+ { "gsuite:Name", attachment.ContentName ?? attachment.Name.Split('/').Last() },
+ { "gsuite:id", attachment.Name },
+ { "gsuite:ContentType", attachment.ContentType }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs
new file mode 100644
index 000000000..f21fd2d99
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.HangoutsChat.v1.Data;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ChatMessageFileSourceEntry(string parentPath, Message message)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "message.json"),
+ message.CreateTimeDateTimeOffset.HasValue ? message.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch,
+ DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.ChatMessage.ToString() },
+ { "gsuite:Name", "message.json" },
+ { "gsuite:id", message.Name }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs
new file mode 100644
index 000000000..ac963c492
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs
@@ -0,0 +1,42 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.HangoutsChat.v1.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ChatMessageSourceEntry(string parentPath, Message message)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, message.Name.Split('/').Last())),
+ message.CreateTimeDateTimeOffset.HasValue ? message.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch,
+ DateTime.UnixEpoch)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ChatMessageFileSourceEntry(this.Path, message);
+
+ if (message.Attachment != null)
+ {
+ foreach (var attachment in message.Attachment)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ChatAttachmentSourceEntry(this.Path, attachment);
+ }
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.ChatMessage.ToString() },
+ { "gsuite:Name", message.Name },
+ { "gsuite:id", message.Name }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatSourceEntry.cs
new file mode 100644
index 000000000..9f6240790
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatSourceEntry.cs
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ChatSourceEntry(SourceProvider provider, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Chat")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetChatService();
+ var request = service.Spaces.List();
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var spaces = await request.ExecuteAsync(cancellationToken);
+
+ if (spaces.Spaces != null)
+ {
+ foreach (var space in spaces.Spaces)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ChatSpaceSourceEntry(provider, this.Path, space);
+ }
+ }
+ nextPageToken = spaces.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.UserChat.ToString() },
+ { "gsuite:Name", "Chat" },
+ { "gsuite:id", "Chat" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs
new file mode 100644
index 000000000..afb1b7f1b
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs
@@ -0,0 +1,50 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.HangoutsChat.v1.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ChatSpaceSourceEntry(SourceProvider provider, string parentPath, Space space)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, space.DisplayName ?? space.Name)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetChatService();
+ var request = service.Spaces.Messages.List(space.Name);
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var messages = await request.ExecuteAsync(cancellationToken);
+
+ if (messages.Messages != null)
+ {
+ foreach (var message in messages.Messages)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ChatMessageSourceEntry(this.Path, message);
+ }
+ }
+ nextPageToken = messages.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.ChatSpace.ToString() },
+ { "gsuite:Name", space.DisplayName ?? space.Name },
+ { "gsuite:id", space.Name },
+ { "gsuite:SpaceType", space.Type }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactFileSourceEntry.cs
new file mode 100644
index 000000000..20e4eaa67
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ContactFileSourceEntry.cs
@@ -0,0 +1,63 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.PeopleService.v1.Data;
+using System.Text;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ContactFileSourceEntry(string parentPath, Person person)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "contact.vcf"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("BEGIN:VCARD");
+ sb.AppendLine("VERSION:3.0");
+
+ if (person.Names != null)
+ {
+ foreach (var name in person.Names)
+ {
+ sb.AppendLine($"FN:{name.DisplayName}");
+ sb.AppendLine($"N:{name.FamilyName};{name.GivenName};{name.MiddleName};;");
+ }
+ }
+
+ if (person.EmailAddresses != null)
+ {
+ foreach (var email in person.EmailAddresses)
+ {
+ sb.AppendLine($"EMAIL;TYPE={email.Type}:{email.Value}");
+ }
+ }
+
+ if (person.PhoneNumbers != null)
+ {
+ foreach (var phone in person.PhoneNumbers)
+ {
+ sb.AppendLine($"TEL;TYPE={phone.Type}:{phone.Value}");
+ }
+ }
+
+ sb.AppendLine("END:VCARD");
+
+ return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.Contact.ToString() },
+ { "gsuite:Name", person.Names?.FirstOrDefault()?.DisplayName ?? person.ResourceName },
+ { "gsuite:id", person.ResourceName },
+ { "gsuite:Etag", person.ETag }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactGroupSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactGroupSourceEntry.cs
new file mode 100644
index 000000000..17e2debef
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ContactGroupSourceEntry.cs
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.PeopleService.v1.Data;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ContactGroupSourceEntry(string parentPath, ContactGroup group)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, (group.FormattedName ?? group.Name) + ".json"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(group, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.ContactGroup.ToString() },
+ { "gsuite:Name", group.FormattedName ?? group.Name },
+ { "gsuite:id", group.ResourceName },
+ { "gsuite:Etag", group.ETag }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactGroupsFolderSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactGroupsFolderSourceEntry.cs
new file mode 100644
index 000000000..e2d9f8464
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ContactGroupsFolderSourceEntry.cs
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ContactGroupsFolderSourceEntry(SourceProvider provider, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Groups")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetPeopleService();
+ var request = service.ContactGroups.List();
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var groups = await request.ExecuteAsync(cancellationToken);
+
+ if (groups.ContactGroups != null)
+ {
+ foreach (var group in groups.ContactGroups)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ContactGroupSourceEntry(this.Path, group);
+ }
+ }
+ nextPageToken = groups.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.ContactGroup.ToString() }, // Or UserContactGroups?
+ { "gsuite:Name", "Groups" },
+ { "gsuite:id", "Groups" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactPhotoSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactPhotoSourceEntry.cs
new file mode 100644
index 000000000..2608702f3
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ContactPhotoSourceEntry.cs
@@ -0,0 +1,43 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.PeopleService.v1.Data;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ContactPhotoSourceEntry(string parentPath, Photo photo)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "photo.jpg"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ if (!string.IsNullOrEmpty(photo.Url))
+ {
+ using var client = new HttpClient();
+ var response = await client.GetAsync(photo.Url, cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ var stream = new MemoryStream();
+ await response.Content.CopyToAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+ }
+
+ throw new FileNotFoundException("Cannot download photo");
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.ContactPhoto.ToString() },
+ { "gsuite:Name", "photo.jpg" },
+ { "gsuite:id", photo.Metadata?.Source?.Id ?? photo.Url }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactSourceEntry.cs
new file mode 100644
index 000000000..4a0a49d2f
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ContactSourceEntry.cs
@@ -0,0 +1,44 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.PeopleService.v1.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ContactSourceEntry(string parentPath, Person person)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, person.Names?.FirstOrDefault()?.DisplayName ?? person.ResourceName)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ContactFileSourceEntry(this.Path, person);
+
+ if (person.Photos != null)
+ {
+ foreach (var photo in person.Photos)
+ {
+ // Skip if it's the default placeholder (if we can detect it)
+ // Usually default photos have a specific URL pattern or metadata.
+ // For now, let's just include all photos.
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ContactPhotoSourceEntry(this.Path, photo);
+ }
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.Contact.ToString() },
+ { "gsuite:Name", person.Names?.FirstOrDefault()?.DisplayName ?? person.ResourceName },
+ { "gsuite:id", person.ResourceName },
+ { "gsuite:Etag", person.ETag }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactsFolderSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactsFolderSourceEntry.cs
new file mode 100644
index 000000000..6b2395cb4
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ContactsFolderSourceEntry.cs
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class ContactsFolderSourceEntry(SourceProvider provider, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Contacts")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetPeopleService();
+ var request = service.People.Connections.List("people/me");
+ request.PersonFields = "names,emailAddresses,phoneNumbers,photos";
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var connections = await request.ExecuteAsync(cancellationToken);
+
+ if (connections.Connections != null)
+ {
+ foreach (var person in connections.Connections)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ContactSourceEntry(this.Path, person);
+ }
+ }
+ nextPageToken = connections.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.UserContacts.ToString() },
+ { "gsuite:Name", "Contacts" },
+ { "gsuite:id", "Contacts" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileCommentsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileCommentsSourceEntry.cs
new file mode 100644
index 000000000..813bc14aa
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileCommentsSourceEntry.cs
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFileCommentsSourceEntry(SourceProvider provider, string parentPath, File file)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "comments.json"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Comments.List(file.Id);
+ request.Fields = "*";
+ var comments = await request.ExecuteAsync(cancellationToken);
+
+ var json = JsonSerializer.Serialize(comments, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFileComment.ToString() },
+ { "gsuite:Name", "comments.json" },
+ { "gsuite:id", file.Id }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileContentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileContentSourceEntry.cs
new file mode 100644
index 000000000..cbbe163a5
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileContentSourceEntry.cs
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFileContentSourceEntry(SourceProvider provider, string parentPath, File file)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "content"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => file.Size ?? -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+
+ if (IsGoogleDoc(file.MimeType))
+ {
+ var exportMimeType = GetExportMimeType(file.MimeType);
+ var request = service.Files.Export(file.Id, exportMimeType);
+ var stream = new MemoryStream();
+ await request.DownloadAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+ else
+ {
+ var request = service.Files.Get(file.Id);
+ var stream = new MemoryStream();
+ await request.DownloadAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+ }
+
+ private static bool IsGoogleDoc(string mimeType)
+ {
+ return mimeType.StartsWith("application/vnd.google-apps.") &&
+ mimeType != "application/vnd.google-apps.folder" &&
+ mimeType != "application/vnd.google-apps.shortcut";
+ }
+
+ private static string GetExportMimeType(string mimeType)
+ {
+ return mimeType switch
+ {
+ "application/vnd.google-apps.document" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.google-apps.spreadsheet" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.google-apps.presentation" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.google-apps.script" => "application/vnd.google-apps.script+json",
+ _ => "application/pdf"
+ };
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFileContent.ToString() },
+ { "gsuite:Name", "content" },
+ { "gsuite:id", file.Id },
+ { "gsuite:MimeType", file.MimeType }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileMetadataSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileMetadataSourceEntry.cs
new file mode 100644
index 000000000..562a58317
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileMetadataSourceEntry.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFileMetadataSourceEntry(string parentPath, File file)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "metadata.json"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFileMetadata.ToString() },
+ { "gsuite:Name", "metadata.json" },
+ { "gsuite:id", file.Id }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFilePermissionsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFilePermissionsSourceEntry.cs
new file mode 100644
index 000000000..b55b51a21
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFilePermissionsSourceEntry.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFilePermissionsSourceEntry(SourceProvider provider, string parentPath, File file)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "permissions.json"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Permissions.List(file.Id);
+ var permissions = await request.ExecuteAsync(cancellationToken);
+
+ var json = JsonSerializer.Serialize(permissions, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DrivePermission.ToString() },
+ { "gsuite:Name", "permissions.json" },
+ { "gsuite:id", file.Id }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionContentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionContentSourceEntry.cs
new file mode 100644
index 000000000..ed0d1df45
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionContentSourceEntry.cs
@@ -0,0 +1,84 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Drive.v3.Data;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFileRevisionContentSourceEntry(SourceProvider provider, string parentPath, File file, Revision revision)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, revision.Id), revision.ModifiedTimeDateTimeOffset.HasValue ? revision.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, revision.ModifiedTimeDateTimeOffset.HasValue ? revision.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => revision.Size ?? -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var url = $"https://www.googleapis.com/drive/v3/files/{file.Id}/revisions/{revision.Id}?alt=media";
+
+ if (IsGoogleDoc(file.MimeType))
+ {
+ var exportMimeType = GetExportMimeType(file.MimeType);
+ if (revision.ExportLinks != null && revision.ExportLinks.ContainsKey(exportMimeType))
+ {
+ url = revision.ExportLinks[exportMimeType];
+ }
+ else
+ {
+ // Try to construct export URL if not present?
+ // Or skip.
+ // For now, let's try the constructed URL, but it might fail for Docs.
+ // Actually, for Docs, we can't download via alt=media.
+ // If ExportLinks is missing, we might be out of luck for that revision.
+ if (revision.ExportLinks == null || !revision.ExportLinks.ContainsKey(exportMimeType))
+ {
+ throw new NotSupportedException($"Cannot export revision {revision.Id} for mimeType {file.MimeType}");
+ }
+ }
+ }
+
+ var response = await service.HttpClient.GetAsync(url, cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ var stream = new MemoryStream();
+ await response.Content.CopyToAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+
+ throw new FileNotFoundException($"Cannot download revision: {response.StatusCode}");
+ }
+
+ private static bool IsGoogleDoc(string mimeType)
+ {
+ return mimeType.StartsWith("application/vnd.google-apps.") &&
+ mimeType != "application/vnd.google-apps.folder" &&
+ mimeType != "application/vnd.google-apps.shortcut";
+ }
+
+ private static string GetExportMimeType(string mimeType)
+ {
+ return mimeType switch
+ {
+ "application/vnd.google-apps.document" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.google-apps.spreadsheet" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.google-apps.presentation" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.google-apps.script" => "application/vnd.google-apps.script+json",
+ _ => "application/pdf"
+ };
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFileRevision.ToString() },
+ { "gsuite:Name", revision.Id },
+ { "gsuite:id", revision.Id },
+ { "gsuite:MimeType", revision.MimeType }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionsSourceEntry.cs
new file mode 100644
index 000000000..0d1b6a56b
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionsSourceEntry.cs
@@ -0,0 +1,41 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFileRevisionsSourceEntry(SourceProvider provider, string parentPath, File file)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Revisions")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Revisions.List(file.Id);
+ var revisions = await request.ExecuteAsync(cancellationToken);
+
+ if (revisions.Revisions != null)
+ {
+ foreach (var revision in revisions.Revisions)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFileRevisionContentSourceEntry(provider, this.Path, file, revision);
+ }
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFileRevision.ToString() }, // Folder
+ { "gsuite:Name", "Revisions" },
+ { "gsuite:id", file.Id + "/revisions" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileSourceEntry.cs
new file mode 100644
index 000000000..0e3b37e88
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileSourceEntry.cs
@@ -0,0 +1,44 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFileSourceEntry(SourceProvider provider, string parentPath, File file)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, file.Name)), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFileContentSourceEntry(provider, this.Path, file);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFileMetadataSourceEntry(this.Path, file);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFilePermissionsSourceEntry(provider, this.Path, file);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFileCommentsSourceEntry(provider, this.Path, file);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFileRevisionsSourceEntry(provider, this.Path, file);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFile.ToString() },
+ { "gsuite:Name", file.Name },
+ { "gsuite:id", file.Id },
+ { "gsuite:MimeType", file.MimeType }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFolderSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFolderSourceEntry.cs
new file mode 100644
index 000000000..6600fedd3
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveFolderSourceEntry.cs
@@ -0,0 +1,58 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveFolderSourceEntry(SourceProvider provider, string userId, string parentPath, string name, string folderId)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, name)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Files.List();
+ request.Q = $"'{folderId}' in parents and trashed = false";
+ request.Fields = "nextPageToken, files(id, name, mimeType, createdTime, modifiedTime, size, description, parents)";
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var files = await request.ExecuteAsync(cancellationToken);
+
+ if (files.Files != null)
+ {
+ foreach (var file in files.Files)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+
+ if (file.MimeType == "application/vnd.google-apps.folder")
+ {
+ yield return new DriveFolderSourceEntry(provider, userId, this.Path, file.Name, file.Id);
+ }
+ else
+ {
+ yield return new DriveFileSourceEntry(provider, this.Path, file);
+ }
+ }
+ }
+ nextPageToken = files.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFolder.ToString() },
+ { "gsuite:Name", name },
+ { "gsuite:id", folderId }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveSourceEntry.cs
new file mode 100644
index 000000000..6149a6bd3
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/DriveSourceEntry.cs
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class DriveSourceEntry(SourceProvider provider, string userId, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Drive")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFolderSourceEntry(provider, userId, this.Path, "My Drive", "root");
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new SharedDrivesSourceEntry(provider, userId, this.Path);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.Drive.ToString() },
+ { "gsuite:Name", "Drive" },
+ { "gsuite:id", "Drive" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailLabelSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailLabelSourceEntry.cs
new file mode 100644
index 000000000..57f4a119e
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/GmailLabelSourceEntry.cs
@@ -0,0 +1,81 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.Gmail.v1.Data;
+using Google.Apis.Gmail.v1;
+using Google.Apis.Requests;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class GmailLabelSourceEntry(SourceProvider provider, string userId, string parentPath, Label label)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, label.Name)), null, null)
+{
+ public override IAsyncEnumerable Enumerate(CancellationToken cancellationToken)
+ {
+ return EnumerateMessages(cancellationToken);
+ }
+
+ private async IAsyncEnumerable EnumerateMessages([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetGmailService(userId);
+ var request = service.Users.Messages.List(userId);
+ request.LabelIds = new[] { label.Id };
+
+ do
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+ if (response.Messages != null)
+ {
+ var batch = new BatchRequest(service);
+ var messages = new List();
+
+ foreach (var msg in response.Messages)
+ {
+ var msgRequest = service.Users.Messages.Get(userId, msg.Id);
+ msgRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Minimal;
+ batch.Queue(msgRequest, (message, error, index, httpResponse) =>
+ {
+ if (error == null)
+ {
+ lock (messages)
+ {
+ messages.Add(message);
+ }
+ }
+ });
+ }
+
+ await batch.ExecuteAsync(cancellationToken);
+
+ foreach (var message in messages)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GmailMessageSourceEntry(provider, userId, this.Path, message);
+ }
+ }
+
+ request.PageToken = response.NextPageToken;
+ } while (!string.IsNullOrEmpty(request.PageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.GmailLabel.ToString() },
+ { "gsuite:Name", label.Name },
+ { "gsuite:id", label.Id },
+ { "gsuite:LabelType", label.Type }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailMessageSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailMessageSourceEntry.cs
new file mode 100644
index 000000000..92e40a8bc
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/GmailMessageSourceEntry.cs
@@ -0,0 +1,51 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Gmail.v1.Data;
+using Google.Apis.Gmail.v1;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class GmailMessageSourceEntry(SourceProvider provider, string userId, string parentPath, Message message)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, message.Id + ".eml"),
+ message.InternalDate.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(message.InternalDate.Value).UtcDateTime : DateTime.UnixEpoch,
+ message.InternalDate.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(message.InternalDate.Value).UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => message.SizeEstimate ?? 0;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetGmailService(userId);
+ var request = service.Users.Messages.Get(userId, message.Id);
+ request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
+ var msg = await request.ExecuteAsync(cancellationToken);
+
+ // Raw is base64url encoded string
+ var bytes = FromBase64Url(msg.Raw);
+ return new MemoryStream(bytes);
+ }
+
+ private static byte[] FromBase64Url(string base64Url)
+ {
+ string padded = base64Url.Length % 4 == 0
+ ? base64Url
+ : base64Url + new string('=', 4 - base64Url.Length % 4);
+ string base64 = padded.Replace('-', '+').Replace('_', '/');
+ return Convert.FromBase64String(base64);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.GmailMessage.ToString() },
+ { "gsuite:Name", message.Id },
+ { "gsuite:id", message.Id },
+ { "gsuite:ThreadId", message.ThreadId },
+ { "gsuite:Snippet", message.Snippet }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailSettingFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailSettingFileSourceEntry.cs
new file mode 100644
index 000000000..8f534b7cb
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/GmailSettingFileSourceEntry.cs
@@ -0,0 +1,65 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Text.Json;
+using Duplicati.Library.Common.IO;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal enum GmailSettingType
+{
+ Filters,
+ Forwarding,
+ Vacation,
+ Signatures
+}
+
+internal class GmailSettingFileSourceEntry(SourceProvider provider, string userId, string parentPath, string filename, GmailSettingType type)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, filename), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetGmailService(userId);
+ object? data = null;
+
+ switch (type)
+ {
+ case GmailSettingType.Filters:
+ var filtersReq = service.Users.Settings.Filters.List(userId);
+ data = await filtersReq.ExecuteAsync(cancellationToken);
+ break;
+ case GmailSettingType.Forwarding:
+ var forwardingReq = service.Users.Settings.ForwardingAddresses.List(userId);
+ var forwarding = await forwardingReq.ExecuteAsync(cancellationToken);
+ var autoForwardingReq = service.Users.Settings.GetAutoForwarding(userId);
+ var autoForwarding = await autoForwardingReq.ExecuteAsync(cancellationToken);
+ data = new { ForwardingAddresses = forwarding, AutoForwarding = autoForwarding };
+ break;
+ case GmailSettingType.Vacation:
+ var vacationReq = service.Users.Settings.GetVacation(userId);
+ data = await vacationReq.ExecuteAsync(cancellationToken);
+ break;
+ case GmailSettingType.Signatures:
+ var sendAsReq = service.Users.Settings.SendAs.List(userId);
+ data = await sendAsReq.ExecuteAsync(cancellationToken);
+ break;
+ }
+
+ var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", type.ToString() },
+ { "gsuite:Name", filename },
+ { "gsuite:id", filename }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailSettingsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailSettingsSourceEntry.cs
new file mode 100644
index 000000000..4cdc750aa
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/GmailSettingsSourceEntry.cs
@@ -0,0 +1,40 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class GmailSettingsSourceEntry(SourceProvider provider, string userId, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Settings")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "filters.json", GmailSettingType.Filters);
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "forwarding.json", GmailSettingType.Forwarding);
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "vacation.json", GmailSettingType.Vacation);
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+ yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "signatures.json", GmailSettingType.Signatures);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.GmailSettings.ToString() },
+ { "gsuite:Name", "Settings" },
+ { "gsuite:id", "Settings" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/GroupMembersSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GroupMembersSourceEntry.cs
new file mode 100644
index 000000000..5633e1a75
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/GroupMembersSourceEntry.cs
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class GroupMembersSourceEntry(SourceProvider provider, string parentPath, string groupEmail)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "members.json"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDirectoryService();
+ var request = service.Members.List(groupEmail);
+ var members = new List();
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) break;
+ request.PageToken = nextPageToken;
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ if (response.MembersValue != null)
+ {
+ members.AddRange(response.MembersValue);
+ }
+ nextPageToken = response.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+
+ var json = JsonSerializer.Serialize(members, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.GroupMember.ToString() },
+ { "gsuite:Name", "members.json" },
+ { "gsuite:id", groupEmail }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/GroupSettingsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GroupSettingsSourceEntry.cs
new file mode 100644
index 000000000..5a74700e4
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/GroupSettingsSourceEntry.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class GroupSettingsSourceEntry(SourceProvider provider, string parentPath, string groupEmail)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "settings.json"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetGroupsSettingsService();
+ var request = service.Groups.Get(groupEmail);
+ var settings = await request.ExecuteAsync(cancellationToken);
+
+ var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.GroupSettings.ToString() },
+ { "gsuite:Name", "settings.json" },
+ { "gsuite:id", groupEmail }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/GroupSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GroupSourceEntry.cs
new file mode 100644
index 000000000..db2a8bec8
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/GroupSourceEntry.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.Admin.Directory.directory_v1.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class GroupSourceEntry(SourceProvider provider, string parentPath, Group group)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, group.Name ?? group.Email)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new GroupSettingsSourceEntry(provider, this.Path, group.Email);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new GroupMembersSourceEntry(provider, this.Path, group.Email);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.Group.ToString() },
+ { "gsuite:Name", group.Name ?? group.Email },
+ { "gsuite:id", group.Id },
+ { "gsuite:Email", group.Email }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepNoteAttachmentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepNoteAttachmentSourceEntry.cs
new file mode 100644
index 000000000..9341d6cbd
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/KeepNoteAttachmentSourceEntry.cs
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Keep.v1.Data;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class KeepNoteAttachmentSourceEntry(SourceProvider provider, string parentPath, Attachment attachment)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, attachment.Name.Split('/').Last()), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ // Keep API attachments are downloaded via media URL?
+ // attachment.Name is like "notes/123/attachments/456".
+ // Docs say: GET https://keep.googleapis.com/v1/notes/{noteId}/attachments/{attachmentId}?alt=media
+
+ var service = provider.ApiHelper.GetKeepService();
+ var url = $"https://keep.googleapis.com/v1/{attachment.Name}?alt=media";
+
+ var response = await service.HttpClient.GetAsync(url, cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ var stream = new MemoryStream();
+ await response.Content.CopyToAsync(stream, cancellationToken);
+ stream.Seek(0, SeekOrigin.Begin);
+ return stream;
+ }
+
+ throw new FileNotFoundException($"Cannot download attachment: {response.StatusCode}");
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.KeepNoteAttachment.ToString() },
+ { "gsuite:Name", attachment.Name.Split('/').Last() },
+ { "gsuite:id", attachment.Name },
+ { "gsuite:MimeType", attachment.MimeType != null ? string.Join(",", attachment.MimeType) : null }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepNoteFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepNoteFileSourceEntry.cs
new file mode 100644
index 000000000..91f74befd
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/KeepNoteFileSourceEntry.cs
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Keep.v1.Data;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class KeepNoteFileSourceEntry(string parentPath, Note note)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "note.json"),
+ note.CreateTimeDateTimeOffset.HasValue ? note.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch,
+ note.UpdateTimeDateTimeOffset.HasValue ? note.UpdateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(note, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.KeepNote.ToString() },
+ { "gsuite:Name", "note.json" },
+ { "gsuite:id", note.Name }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepNoteSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepNoteSourceEntry.cs
new file mode 100644
index 000000000..9ef2d118d
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/KeepNoteSourceEntry.cs
@@ -0,0 +1,42 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.Keep.v1.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class KeepNoteSourceEntry(SourceProvider provider, string parentPath, Note note)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, note.Title ?? note.Name)),
+ note.CreateTimeDateTimeOffset.HasValue ? note.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch,
+ note.UpdateTimeDateTimeOffset.HasValue ? note.UpdateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new KeepNoteFileSourceEntry(this.Path, note);
+
+ if (note.Attachments != null)
+ {
+ foreach (var attachment in note.Attachments)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new KeepNoteAttachmentSourceEntry(provider, this.Path, attachment);
+ }
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.KeepNote.ToString() },
+ { "gsuite:Name", note.Title ?? note.Name },
+ { "gsuite:id", note.Name }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepSourceEntry.cs
new file mode 100644
index 000000000..25fa13657
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/KeepSourceEntry.cs
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class KeepSourceEntry(SourceProvider provider, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Keep")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetKeepService();
+ var request = service.Notes.List();
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var notes = await request.ExecuteAsync(cancellationToken);
+
+ if (notes.Notes != null)
+ {
+ foreach (var note in notes.Notes)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new KeepNoteSourceEntry(provider, this.Path, note);
+ }
+ }
+ nextPageToken = notes.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.UserKeep.ToString() },
+ { "gsuite:Name", "Keep" },
+ { "gsuite:id", "Keep" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/MetaEntryBase.cs b/proprietary/GoogleWorkspace/SourceItems/MetaEntryBase.cs
new file mode 100644
index 000000000..17b7185b1
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/MetaEntryBase.cs
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal abstract class MetaEntryBase(string path, DateTime? createdUtc, DateTime? lastModificationUtc) : ISourceProviderEntry
+{
+ public bool IsFolder => true;
+
+ public virtual bool IsMetaEntry => false;
+
+ public virtual bool IsRootEntry => false;
+
+ public DateTime CreatedUtc => createdUtc ?? DateTime.UnixEpoch;
+
+ public DateTime LastModificationUtc => lastModificationUtc ?? DateTime.UnixEpoch;
+
+ public string Path => path;
+
+ public long Size => -1;
+ public bool IsSymlink => false;
+
+ public string? SymlinkTarget => null;
+
+ public FileAttributes Attributes => FileAttributes.Directory;
+
+ public bool IsBlockDevice => false;
+
+ public bool IsCharacterDevice => false;
+
+ public bool IsAlternateStream => false;
+ public string? HardlinkTargetId => null;
+
+ public abstract IAsyncEnumerable Enumerate(CancellationToken cancellationToken);
+
+ public virtual Task FileExists(string filename, CancellationToken cancellationToken)
+ => Task.FromResult(false);
+
+ public virtual Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary());
+
+ public Task OpenRead(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/MetaRootSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/MetaRootSourceEntry.cs
new file mode 100644
index 000000000..3d6f58cdc
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/MetaRootSourceEntry.cs
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class MetaRootSourceEntry(SourceProvider provider, string parentPath, string name, SourceItemType type)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, name)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ if (type == SourceItemType.MetaRootUsers)
+ {
+ var service = provider.ApiHelper.GetDirectoryService();
+ var request = service.Users.List();
+ request.Customer = "my_customer";
+
+ string? nextPageToken = null;
+
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+
+ request.PageToken = nextPageToken;
+ var users = await request.ExecuteAsync(cancellationToken);
+
+ if (users != null && users.UsersValue != null)
+ {
+ foreach (var user in users.UsersValue)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+
+ if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.Users, user.Id))
+ yield break;
+
+ yield return new UserSourceEntry(provider, this.Path, user.PrimaryEmail);
+ }
+ }
+
+ nextPageToken = users?.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+ else if (type == SourceItemType.MetaRootGroups)
+ {
+ var service = provider.ApiHelper.GetDirectoryService();
+ var request = service.Groups.List();
+ request.Customer = "my_customer";
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var groups = await request.ExecuteAsync(cancellationToken);
+
+ if (groups.GroupsValue != null)
+ {
+ foreach (var group in groups.GroupsValue)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+
+ if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.Groups, group.Id))
+ yield break;
+
+ yield return new GroupSourceEntry(provider, this.Path, group);
+ }
+ }
+ nextPageToken = groups.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+ else if (type == SourceItemType.MetaRootSharedDrives)
+ {
+ yield return new SharedDrivesSourceEntry(provider, "me", this.Path);
+ }
+ else if (type == SourceItemType.MetaRootSites)
+ {
+ yield return new SitesSourceEntry(provider, this.Path);
+ }
+ else if (type == SourceItemType.MetaRootOrganizationalUnits)
+ {
+ // OrganizationalUnits is a single file containing all OUs, so we check license once
+ if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.OrganizationalUnits, "organizational_units"))
+ yield break;
+
+ yield return new OrganizationalUnitsSourceEntry(provider, this.Path);
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", type.ToString() },
+ { "gsuite:Name", System.IO.Path.GetFileName(Path) },
+ { "gsuite:id", System.IO.Path.GetFileName(Path) }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/OrganizationalUnitsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/OrganizationalUnitsSourceEntry.cs
new file mode 100644
index 000000000..8e08a5b2e
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/OrganizationalUnitsSourceEntry.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class OrganizationalUnitsSourceEntry(SourceProvider provider, string parentPath)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "organizational_units.json"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDirectoryService();
+ var request = service.Orgunits.List("my_customer");
+ request.Type = Google.Apis.Admin.Directory.directory_v1.OrgunitsResource.ListRequest.TypeEnum.All;
+ var orgUnits = await request.ExecuteAsync(cancellationToken);
+
+ var json = JsonSerializer.Serialize(orgUnits, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.OrganizationalUnit.ToString() },
+ { "gsuite:Name", "organizational_units.json" },
+ { "gsuite:id", "organizational_units" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/RootSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/RootSourceEntry.cs
new file mode 100644
index 000000000..53c6f10bf
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/RootSourceEntry.cs
@@ -0,0 +1,107 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class RootSourceEntry(SourceProvider provider)
+ : MetaEntryBase(Util.AppendDirSeparator(provider.MountedPath), null, null)
+{
+ internal sealed record BackupDescription(
+ string Version,
+ string DuplicatiVersion,
+ string MachineId,
+ string PackageTypeId,
+ string OSType,
+ string OSVersion,
+ string DirectorySeparator,
+ string MountPoint
+ );
+
+ public override bool IsRootEntry => true;
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.MetaRoot.ToString() },
+ { "gsuite:Name", "Google Workspace Tenant" },
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var description = new BackupDescription(
+ Version: "1.0",
+ DuplicatiVersion: Library.AutoUpdater.UpdaterManager.SelfVersion.Version ?? "",
+ MachineId: Library.AutoUpdater.DataFolderManager.MachineID,
+ PackageTypeId: Library.AutoUpdater.UpdaterManager.PackageTypeId,
+ OSType: Library.AutoUpdater.UpdaterManager.OperatingSystemName,
+ OSVersion: Library.Utility.OSInfoHelper.PlatformString,
+ DirectorySeparator: System.IO.Path.DirectorySeparatorChar.ToString(),
+ MountPoint: Path
+ );
+
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "google-workspace-backup-description.json"),
+ createdUtc: DateTime.UnixEpoch,
+ lastModificationUtc: DateTime.UnixEpoch,
+ size: -1,
+ streamFactory: async (ct) =>
+ {
+ var ms = new MemoryStream();
+ await System.Text.Json.JsonSerializer.SerializeAsync(ms, description, cancellationToken: ct).ConfigureAwait(false);
+ ms.Seek(0, SeekOrigin.Begin);
+ return ms;
+ },
+ minorMetadataFactory: (ct) => Task.FromResult(new Dictionary()
+ {
+ { "ExtType", "gsuite"},
+ { "Ext:DuplicatiVersion", description.DuplicatiVersion },
+ { "Ext:MachineId", description.MachineId },
+ { "Ext:PackageTypeId", description.PackageTypeId },
+ { "Ext:OSType", description.OSType },
+ { "Ext:OSVersion", description.OSVersion },
+ { "Ext:DirectorySeparator", description.DirectorySeparator },
+ { "Ext:MountPoint", description.MountPoint }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value))
+ );
+
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ foreach (var type in provider.Options.IncludedRootTypes)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ if (type == GoogleRootType.Users)
+ {
+ yield return new MetaRootSourceEntry(provider, this.Path, "Users", SourceItemType.MetaRootUsers);
+ }
+ else if (type == GoogleRootType.Groups)
+ {
+ yield return new MetaRootSourceEntry(provider, this.Path, "Groups", SourceItemType.MetaRootGroups);
+ }
+ else if (type == GoogleRootType.SharedDrives)
+ {
+ yield return new MetaRootSourceEntry(provider, this.Path, "Shared Drives", SourceItemType.MetaRootSharedDrives);
+ }
+ else if (type == GoogleRootType.Sites)
+ {
+ yield return new MetaRootSourceEntry(provider, this.Path, "Sites", SourceItemType.MetaRootSites);
+ }
+ else if (type == GoogleRootType.OrganizationalUnits)
+ {
+ yield return new MetaRootSourceEntry(provider, this.Path, "Organizational Units", SourceItemType.MetaRootOrganizationalUnits);
+ }
+ }
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDriveMetadataSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDriveMetadataSourceEntry.cs
new file mode 100644
index 000000000..19c216b19
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/SharedDriveMetadataSourceEntry.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Drive.v3.Data;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class SharedDriveMetadataSourceEntry(string parentPath, Drive drive)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "metadata.json"), drive.CreatedTimeDateTimeOffset.HasValue ? drive.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(drive, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DriveFileMetadata.ToString() }, // Reuse or new type?
+ { "gsuite:Name", "metadata.json" },
+ { "gsuite:id", drive.Id }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDrivePermissionsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDrivePermissionsSourceEntry.cs
new file mode 100644
index 000000000..d4ac65d92
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/SharedDrivePermissionsSourceEntry.cs
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Google.Apis.Drive.v3.Data;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class SharedDrivePermissionsSourceEntry(SourceProvider provider, string parentPath, Drive drive)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "permissions.json"), drive.CreatedTimeDateTimeOffset.HasValue ? drive.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Permissions.List(drive.Id);
+ request.SupportsAllDrives = true;
+ var permissions = await request.ExecuteAsync(cancellationToken);
+
+ var json = JsonSerializer.Serialize(permissions, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.DrivePermission.ToString() },
+ { "gsuite:Name", "permissions.json" },
+ { "gsuite:id", drive.Id }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDriveSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDriveSourceEntry.cs
new file mode 100644
index 000000000..50af7136a
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/SharedDriveSourceEntry.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.Drive.v3.Data;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class SharedDriveSourceEntry(SourceProvider provider, string userId, string parentPath, Drive drive)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, drive.Name)), drive.CreatedTimeDateTimeOffset.HasValue ? drive.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new SharedDriveMetadataSourceEntry(this.Path, drive);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new SharedDrivePermissionsSourceEntry(provider, this.Path, drive);
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveFolderSourceEntry(provider, userId, this.Path, "Content", drive.Id);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.SharedDrives.ToString() }, // Or specific type?
+ { "gsuite:Name", drive.Name },
+ { "gsuite:id", drive.Id }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDrivesSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDrivesSourceEntry.cs
new file mode 100644
index 000000000..2f30e244f
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/SharedDrivesSourceEntry.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class SharedDrivesSourceEntry(SourceProvider provider, string userId, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Shared Drives")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Drives.List();
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var drives = await request.ExecuteAsync(cancellationToken);
+
+ if (drives.Drives != null)
+ {
+ foreach (var drive in drives.Drives)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+
+ if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.SharedDrives, drive.Id))
+ yield break;
+
+ yield return new SharedDriveSourceEntry(provider, userId, this.Path, drive);
+ }
+ }
+ nextPageToken = drives.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.SharedDrives.ToString() },
+ { "gsuite:Name", "Shared Drives" },
+ { "gsuite:id", "Shared Drives" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/SiteSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SiteSourceEntry.cs
new file mode 100644
index 000000000..bd31917cc
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/SiteSourceEntry.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+using File = Google.Apis.Drive.v3.Data.File;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class SiteSourceEntry(string parentPath, File file)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, file.Name + ".site"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ // Google Sites cannot be exported via Drive API.
+ // We serialize the metadata to JSON so we at least have a record of it.
+ var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.Site.ToString() },
+ { "gsuite:Name", file.Name },
+ { "gsuite:id", file.Id },
+ { "gsuite:MimeType", file.MimeType }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/SitesSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SitesSourceEntry.cs
new file mode 100644
index 000000000..b1aa4da5e
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/SitesSourceEntry.cs
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using System.Runtime.CompilerServices;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class SitesSourceEntry(SourceProvider provider, string parentPath)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Sites")), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDriveService();
+ var request = service.Files.List();
+ request.Q = "mimeType='application/vnd.google-apps.site' and trashed=false";
+ request.SupportsAllDrives = true;
+ request.IncludeItemsFromAllDrives = true;
+ // Request all drives so we do not get user drives only
+ request.Corpora = "allDrives";
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+
+ Google.Apis.Drive.v3.Data.FileList? files = null;
+ try
+ {
+ files = await request.ExecuteAsync(cancellationToken);
+ }
+ catch
+ {
+ // Fallback or ignore
+ yield break;
+ }
+
+ if (files != null && files.Files != null)
+ {
+ foreach (var file in files.Files)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+
+ if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.Sites, file.Id))
+ yield break;
+
+ yield return new SiteSourceEntry(this.Path, file);
+ }
+ }
+ nextPageToken = files?.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.MetaRootSites.ToString() },
+ { "gsuite:Name", "Sites" },
+ { "gsuite:id", "Sites" }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs b/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs
new file mode 100644
index 000000000..024d42764
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+public enum SourceItemType
+{
+ MetaRoot,
+ MetaRootUsers,
+ MetaRootGroups,
+ MetaRootSharedDrives,
+ MetaRootSites,
+ MetaRootOrganizationalUnits,
+ User,
+ UserGmail,
+ UserDrive,
+ UserCalendar,
+ UserContacts,
+ UserTasks,
+ GmailLabel,
+ GmailMessage,
+ GmailAttachment,
+ GmailSettings,
+ Drive,
+ DriveFile,
+ DriveFolder,
+ DrivePermission,
+ DriveFileContent,
+ DriveFileMetadata,
+ DriveFileComment,
+ DriveFileRevision,
+ SharedDrives,
+ Calendar,
+ CalendarEvent,
+ CalendarEventAttachment,
+ Contact,
+ ContactGroup,
+ ContactPhoto,
+ TaskList,
+ Task,
+ UserKeep,
+ UserChat,
+ KeepNote,
+ KeepNoteAttachment,
+ UserProfile,
+ OrganizationalUnit,
+ Group,
+ GroupMember,
+ GroupSettings,
+ Site,
+ ChatSpace,
+ ChatMessage,
+ ChatAttachment
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryBase.cs b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryBase.cs
new file mode 100644
index 000000000..7288f7e23
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryBase.cs
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal abstract class StreamResourceEntryBase(string path, DateTime createdUtc, DateTime lastModificationUtc) : ISourceProviderEntry
+{
+ public bool IsFolder => false;
+
+ public bool IsMetaEntry => false;
+
+ public virtual bool IsRootEntry => false;
+
+ public virtual DateTime CreatedUtc => createdUtc;
+
+ public virtual DateTime LastModificationUtc => lastModificationUtc;
+
+ public string Path => path;
+
+ public abstract long Size { get; }
+ public bool IsSymlink => false;
+
+ public string? SymlinkTarget => null;
+
+ public virtual FileAttributes Attributes => FileAttributes.Normal;
+
+ public bool IsBlockDevice => false;
+
+ public bool IsCharacterDevice => false;
+
+ public bool IsAlternateStream => false;
+ public string? HardlinkTargetId => null;
+
+ public IAsyncEnumerable Enumerate(CancellationToken cancellationToken)
+ => throw new NotSupportedException("Cannot enumerate a file entry");
+
+ public Task FileExists(string filename, CancellationToken cancellationToken)
+ => throw new NotSupportedException("Cannot enumerate a file entry");
+
+ public virtual Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary());
+
+ public abstract Task OpenRead(CancellationToken cancellationToken);
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryFunction.cs b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryFunction.cs
new file mode 100644
index 000000000..51156b245
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryFunction.cs
@@ -0,0 +1,40 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class StreamResourceEntryFunction(string path, DateTime createdUtc, DateTime lastModificationUtc, long size, Func> streamFactory, Func>>? minorMetadataFactory = null)
+ : StreamResourceEntryBase(path, createdUtc, lastModificationUtc)
+{
+ public override long Size => _stream == null ? size : _stream.Length;
+ private Stream? _stream;
+
+ public async Task ProbeIfExistsAsync(CancellationToken cancellationToken)
+ {
+ if (_stream != null)
+ return true;
+
+ try
+ {
+ _stream = await streamFactory(cancellationToken).ConfigureAwait(false);
+ return _stream != null;
+ }
+ catch (FileNotFoundException)
+ {
+ return false;
+ }
+ catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ return false;
+ }
+ }
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ => _stream != null
+ ? _stream
+ : (await streamFactory(cancellationToken).ConfigureAwait(false) ?? throw new FileNotFoundException($"Resource not found: {Path}"));
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => minorMetadataFactory != null
+ ? minorMetadataFactory(cancellationToken)
+ : base.GetMinorMetadata(cancellationToken);
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/TaskListSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/TaskListSourceEntry.cs
new file mode 100644
index 000000000..99781af2f
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/TaskListSourceEntry.cs
@@ -0,0 +1,51 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+using Google.Apis.Tasks.v1.Data;
+using System.Runtime.CompilerServices;
+using Task = System.Threading.Tasks.Task;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class TaskListSourceEntry(SourceProvider provider, string parentPath, TaskList taskList)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, taskList.Title)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetTasksService();
+ var request = service.Tasks.List(taskList.Id);
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var tasks = await request.ExecuteAsync(cancellationToken);
+
+ if (tasks.Items != null)
+ {
+ foreach (var task in tasks.Items)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new TaskSourceEntry(this.Path, task);
+ }
+ }
+ nextPageToken = tasks.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.TaskList.ToString() },
+ { "gsuite:Name", taskList.Title },
+ { "gsuite:id", taskList.Id },
+ { "gsuite:Updated", taskList.Updated }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/TaskSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/TaskSourceEntry.cs
new file mode 100644
index 000000000..75a5086f4
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/TaskSourceEntry.cs
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+using GTask = Google.Apis.Tasks.v1.Data.Task;
+using Task = System.Threading.Tasks.Task;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class TaskSourceEntry(string parentPath, GTask task)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, (task.Title ?? task.Id) + ".json"),
+ !string.IsNullOrEmpty(task.Updated) ? DateTime.Parse(task.Updated!) : DateTime.UnixEpoch,
+ !string.IsNullOrEmpty(task.Updated) ? DateTime.Parse(task.Updated!) : DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override Task OpenRead(CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(task, new JsonSerializerOptions { WriteIndented = true });
+ return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.Task.ToString() },
+ { "gsuite:Name", task.Title ?? task.Id },
+ { "gsuite:id", task.Id },
+ { "gsuite:Status", task.Status },
+ { "gsuite:Due", task.Due }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/UserProfileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/UserProfileSourceEntry.cs
new file mode 100644
index 000000000..ed2e46ce7
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/UserProfileSourceEntry.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Common.IO;
+using System.Text.Json;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class UserProfileSourceEntry(SourceProvider provider, string parentPath, string userId)
+ : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "profile.json"), DateTime.UnixEpoch, DateTime.UnixEpoch)
+{
+ public override long Size => -1;
+
+ public override async Task OpenRead(CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetDirectoryService();
+ var request = service.Users.Get(userId);
+ var user = await request.ExecuteAsync(cancellationToken);
+
+ var json = JsonSerializer.Serialize(user, new JsonSerializerOptions { WriteIndented = true });
+ return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.UserProfile.ToString() },
+ { "gsuite:Name", "profile.json" },
+ { "gsuite:id", userId }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/UserSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/UserSourceEntry.cs
new file mode 100644
index 000000000..61bc60556
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/UserSourceEntry.cs
@@ -0,0 +1,51 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class UserSourceEntry(SourceProvider provider, string parentPath, string userId)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, userId)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new UserProfileSourceEntry(provider, this.Path, userId);
+
+ foreach (var type in provider.Options.IncludedUserTypes)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ if (type == GoogleUserType.Gmail)
+ yield return new UserTypeSourceEntry(provider, this.Path, userId, "Gmail", SourceItemType.UserGmail);
+ else if (type == GoogleUserType.Calendar)
+ yield return new UserTypeSourceEntry(provider, this.Path, userId, "Calendar", SourceItemType.UserCalendar);
+ else if (type == GoogleUserType.Contacts)
+ yield return new UserTypeSourceEntry(provider, this.Path, userId, "Contacts", SourceItemType.UserContacts);
+ else if (type == GoogleUserType.Drive)
+ yield return new UserTypeSourceEntry(provider, this.Path, userId, "Drive", SourceItemType.UserDrive);
+ else if (type == GoogleUserType.Tasks)
+ yield return new UserTypeSourceEntry(provider, this.Path, userId, "Tasks", SourceItemType.UserTasks);
+ else if (type == GoogleUserType.Keep)
+ yield return new UserTypeSourceEntry(provider, this.Path, userId, "Keep", SourceItemType.UserKeep);
+ else if (type == GoogleUserType.Chat)
+ yield return new UserTypeSourceEntry(provider, this.Path, userId, "Chat", SourceItemType.UserChat);
+ }
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", SourceItemType.User.ToString() },
+ { "gsuite:Name", userId },
+ { "gsuite:id", userId }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/UserTypeSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/UserTypeSourceEntry.cs
new file mode 100644
index 000000000..20c291af4
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/UserTypeSourceEntry.cs
@@ -0,0 +1,151 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using Duplicati.Library.Common.IO;
+using Duplicati.Library.Interface;
+
+namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+internal class UserTypeSourceEntry(SourceProvider provider, string parentPath, string userId, string name, SourceItemType type)
+ : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, name)), null, null)
+{
+ public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ var source = type switch
+ {
+ SourceItemType.UserGmail => EnumerateGmail(cancellationToken),
+ SourceItemType.UserCalendar => EnumerateCalendar(cancellationToken),
+ SourceItemType.UserContacts => EnumerateContacts(cancellationToken),
+ SourceItemType.UserDrive => EnumerateDrive(cancellationToken),
+ SourceItemType.UserTasks => EnumerateTasks(cancellationToken),
+ SourceItemType.UserKeep => EnumerateKeep(cancellationToken),
+ SourceItemType.UserChat => EnumerateChat(cancellationToken),
+ _ => throw new NotImplementedException()
+ };
+
+ await foreach (var entry in source)
+ yield return entry;
+ }
+
+ private async IAsyncEnumerable EnumerateGmail([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetGmailService(userId);
+ var request = service.Users.Labels.List(userId);
+ Google.Apis.Gmail.v1.Data.ListLabelsResponse response;
+
+ try
+ {
+ response = await request.ExecuteAsync(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ // Customize exception to indicate which user failed
+ throw new Exception($"Error enumerating Gmail labels for user {userId}: {ex.Message}", ex);
+ }
+
+ if (response.Labels != null)
+ {
+ foreach (var label in response.Labels)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GmailLabelSourceEntry(provider, userId, this.Path, label);
+ }
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ yield break;
+
+ yield return new GmailSettingsSourceEntry(provider, userId, this.Path);
+ }
+
+ private async IAsyncEnumerable EnumerateCalendar([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetCalendarService();
+ var request = service.CalendarList.List();
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var calendars = await request.ExecuteAsync(cancellationToken);
+
+ if (calendars.Items != null)
+ {
+ foreach (var calendar in calendars.Items)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new CalendarSourceEntry(provider, this.Path, calendar);
+ }
+ }
+ nextPageToken = calendars.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ private async IAsyncEnumerable EnumerateContacts([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ContactsFolderSourceEntry(provider, this.Path);
+
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ContactGroupsFolderSourceEntry(provider, this.Path);
+ }
+
+ private async IAsyncEnumerable EnumerateDrive([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new DriveSourceEntry(provider, userId, this.Path);
+ }
+
+ private async IAsyncEnumerable EnumerateTasks([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var service = provider.ApiHelper.GetTasksService();
+ var request = service.Tasklists.List();
+
+ string? nextPageToken = null;
+ do
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ request.PageToken = nextPageToken;
+ var taskLists = await request.ExecuteAsync(cancellationToken);
+
+ if (taskLists.Items != null)
+ {
+ foreach (var taskList in taskLists.Items)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new TaskListSourceEntry(provider, this.Path, taskList);
+ }
+ }
+ nextPageToken = taskLists.NextPageToken;
+ } while (!string.IsNullOrEmpty(nextPageToken));
+ }
+
+ private async IAsyncEnumerable EnumerateKeep([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new KeepSourceEntry(provider, this.Path);
+ }
+
+ private async IAsyncEnumerable EnumerateChat([EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) yield break;
+ yield return new ChatSourceEntry(provider, this.Path);
+ }
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ => Task.FromResult(new Dictionary()
+ {
+ { "gsuite:v", "1" },
+ { "gsuite:Type", type.ToString() },
+ { "gsuite:Name", System.IO.Path.GetFileName(Path) },
+ { "gsuite:id", System.IO.Path.GetFileName(Path) }
+ }
+ .Where(kv => !string.IsNullOrEmpty(kv.Value))
+ .ToDictionary(kv => kv.Key, kv => kv.Value));
+}
diff --git a/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs b/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs
new file mode 100644
index 000000000..11ac45ca3
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs
@@ -0,0 +1,289 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Interface;
+using Duplicati.Library.Logging;
+using Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+using System.Collections.Concurrent;
+
+namespace Duplicati.Proprietary.GoogleWorkspace;
+
+public sealed class SourceProvider : ISourceProviderModule, IDisposable
+{
+ ///
+ /// The log tag for this class.
+ ///
+ private static readonly string LOGTAG = Log.LogTagFromType();
+
+ private readonly APIHelper _apiHelper;
+ private readonly string _mountPoint;
+ private readonly OptionsHelper.GoogleWorkspaceOptions _options;
+ private readonly ConcurrentDictionary _entryCache = new();
+
+ ///
+ /// Counter for tracking which licensed paths are being enumerated.
+ ///
+ private readonly ConcurrentDictionary _enumerationCounter = new();
+
+ ///
+ /// The current number of users that has been enumerated.
+ ///
+ private int _userCount = 0;
+ ///
+ /// The current number of groups that has been enumerated.
+ ///
+ private int _groupCount = 0;
+ ///
+ /// The current number of shared drives that has been enumerated.
+ ///
+ private int _sharedDriveCount = 0;
+ ///
+ /// The current number of sites that has been enumerated.
+ ///
+ private int _siteCount = 0;
+ ///
+ /// The current number of organizational units that has been enumerated.
+ ///
+ private int _organizationalUnitCount = 0;
+
+ ///
+ /// Whether a license warning has been issued for users.
+ ///
+ private int _userLicenseWarningIssued = 0;
+ ///
+ /// Whether a license warning has been issued for groups.
+ ///
+ private int _groupLicenseWarningIssued = 0;
+ ///
+ /// Whether a license warning has been issued for shared drives.
+ ///
+ private int _sharedDriveLicenseWarningIssued = 0;
+ ///
+ /// Whether a license warning has been issued for sites.
+ ///
+ private int _siteLicenseWarningIssued = 0;
+ ///
+ /// Whether a license warning has been issued for organizational units.
+ ///
+ private int _organizationalUnitLicenseWarningIssued = 0;
+
+ ///
+ /// Whether this provider is being used for a restore operation.
+ ///
+ internal bool UsedForRestoreOperation { get; set; } = false;
+
+ ///
+ /// Indicates whether the metadata storage option has been set.
+ ///
+ private readonly bool _hasSetMetadataStorageOption;
+
+ public APIHelper ApiHelper => _apiHelper;
+ public OptionsHelper.GoogleWorkspaceOptions Options => _options;
+
+ public SourceProvider()
+ {
+ _apiHelper = null!;
+ _mountPoint = null!;
+ _options = null!;
+ }
+
+ public SourceProvider(string url, string mountPoint, Dictionary options)
+ {
+ _hasSetMetadataStorageOption = Library.Utility.Utility.ParseBoolOption(options, "store-metadata-content-in-database");
+
+ _mountPoint = mountPoint;
+ _options = OptionsHelper.ParseOptions(options);
+
+ _apiHelper = new APIHelper(_options);
+ }
+
+ public string Key => OptionsHelper.ModuleKey;
+
+ public string DisplayName => Strings.Common.DisplayName;
+
+ public string Description => Strings.Common.Description;
+
+ public IList SupportedCommands => OptionsHelper.SupportedCommands;
+
+ public string MountedPath => _mountPoint;
+
+ public void Dispose()
+ {
+ }
+
+ public IAsyncEnumerable Enumerate(CancellationToken cancellationToken)
+ {
+ return new RootSourceEntry(this).Enumerate(cancellationToken);
+ }
+
+ public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken)
+ {
+ if (_entryCache.TryGetValue(path, out var cachedEntry))
+ return cachedEntry;
+
+ ISourceProviderEntry resultEntry = new RootSourceEntry(this);
+ var targetPath = path.TrimEnd(Path.DirectorySeparatorChar);
+
+ var currentPath = targetPath;
+ while (!string.IsNullOrEmpty(currentPath))
+ {
+ var parentPath = Path.GetDirectoryName(currentPath);
+ if (parentPath != null && _entryCache.TryGetValue(parentPath, out var cachedParent))
+ {
+ resultEntry = cachedParent;
+ break;
+ }
+ currentPath = parentPath;
+ }
+
+ var relativePath = string.IsNullOrEmpty(currentPath)
+ ? targetPath
+ : Path.GetRelativePath(currentPath, targetPath);
+
+ var pathsegments = relativePath?.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)
+ ?? Array.Empty();
+
+ var segmentIndex = 0;
+ foreach (var item in pathsegments)
+ {
+ var found = false;
+ var isLastSegment = segmentIndex == pathsegments.Length - 1;
+ await foreach (var entry in resultEntry.Enumerate(cancellationToken))
+ {
+ _entryCache.TryAdd(entry.Path, entry);
+
+ var name = entry.Path.TrimEnd(Path.DirectorySeparatorChar)
+ .Split(Path.DirectorySeparatorChar)
+ .Last();
+ if (name.Equals(item, StringComparison.OrdinalIgnoreCase))
+ {
+ if (isLastSegment && entry.IsFolder != isFolder)
+ throw new UserInformationException($"Path segment '{item}' is not a {(isFolder ? "folder" : "file")}", "PathSegmentNotFolder");
+ else if (!isLastSegment && !entry.IsFolder)
+ throw new UserInformationException($"Path segment '{item}' is not a folder", "PathSegmentNotFolder");
+
+ resultEntry = entry;
+ found = true;
+ break;
+ }
+ }
+
+ segmentIndex++;
+ if (!found)
+ return null;
+ }
+
+ return resultEntry;
+ }
+
+ public Task Initialize(CancellationToken cancellationToken)
+ {
+ if (!_hasSetMetadataStorageOption)
+ throw new UserInformationException(Strings.MetadataStorageNotEnabled("store-metadata-content-in-database"), "DatabaseMetadataStorageNotEnabled");
+
+ _apiHelper.Initialize();
+ return Task.CompletedTask;
+ }
+
+ public Task Test(CancellationToken cancellationToken)
+ {
+ _apiHelper.TestConnection();
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Determines whether the license is approved for the given entry.
+ ///
+ /// The path to verify.
+ /// The type of entry.
+ /// The ID of the entry.
+ /// true if the license is approved; otherwise, false.
+ internal bool LicenseApprovedForEntry(string path, GoogleRootType type, string id)
+ {
+ // We do not limit restores
+ if (UsedForRestoreOperation)
+ return true;
+
+ // Make a unique target path for the type and id, just for counting purposes, not matching actual paths
+ var targetpath = $"{path.TrimEnd(Path.DirectorySeparatorChar)}{Path.DirectorySeparatorChar}{type}{Path.DirectorySeparatorChar}{id}";
+
+ if (_enumerationCounter.ContainsKey(targetpath))
+ return true;
+
+ if (type == GoogleRootType.Users)
+ {
+ var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceUserSeats;
+ var current = _userCount;
+ if (current >= approved)
+ {
+ if (Interlocked.Exchange(ref _userLicenseWarningIssued, 1) == 0)
+ Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up.");
+ return false;
+ }
+ }
+ else if (type == GoogleRootType.Groups)
+ {
+ var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceGroupSeats;
+ var current = _groupCount;
+ if (current >= approved)
+ {
+ if (Interlocked.Exchange(ref _groupLicenseWarningIssued, 1) == 0)
+ Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up.");
+ return false;
+ }
+ }
+ else if (type == GoogleRootType.SharedDrives)
+ {
+ var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceSharedDriveSeats;
+ var current = _sharedDriveCount;
+ if (current >= approved)
+ {
+ if (Interlocked.Exchange(ref _sharedDriveLicenseWarningIssued, 1) == 0)
+ Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up.");
+ return false;
+ }
+ }
+ else if (type == GoogleRootType.Sites)
+ {
+ var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceSiteSeats;
+ var current = _siteCount;
+ if (current >= approved)
+ {
+ if (Interlocked.Exchange(ref _siteLicenseWarningIssued, 1) == 0)
+ Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up.");
+ return false;
+ }
+ }
+ else if (type == GoogleRootType.OrganizationalUnits)
+ {
+ var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceOrganizationalUnitSeats;
+ var current = _organizationalUnitCount;
+ if (current >= approved)
+ {
+ if (Interlocked.Exchange(ref _organizationalUnitLicenseWarningIssued, 1) == 0)
+ Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up.");
+ return false;
+ }
+ }
+ else
+ {
+ // Unknown type, allow it
+ return true;
+ }
+
+ if (_enumerationCounter.TryAdd(targetpath, true))
+ {
+ if (type == GoogleRootType.Users)
+ Interlocked.Increment(ref _userCount);
+ else if (type == GoogleRootType.Groups)
+ Interlocked.Increment(ref _groupCount);
+ else if (type == GoogleRootType.SharedDrives)
+ Interlocked.Increment(ref _sharedDriveCount);
+ else if (type == GoogleRootType.Sites)
+ Interlocked.Increment(ref _siteCount);
+ else if (type == GoogleRootType.OrganizationalUnits)
+ Interlocked.Increment(ref _organizationalUnitCount);
+ }
+
+ return true;
+ }
+}
diff --git a/proprietary/GoogleWorkspace/Strings.cs b/proprietary/GoogleWorkspace/Strings.cs
new file mode 100644
index 000000000..aabebcef0
--- /dev/null
+++ b/proprietary/GoogleWorkspace/Strings.cs
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using Duplicati.Library.Localization.Short;
+
+namespace Duplicati.Proprietary.GoogleWorkspace;
+
+internal static class Strings
+{
+ internal static class Options
+ {
+ public static string GoogleClientIdShort => LC.L("The Google Client ID");
+ public static string GoogleClientIdLong => LC.L("The Client ID for the Google App.");
+ public static string GoogleClientSecretShort => LC.L("The Google Client Secret");
+ public static string GoogleClientSecretLong => LC.L("The Client Secret for the Google App.");
+ public static string GoogleRefreshTokenShort => LC.L("The Google Refresh Token");
+ public static string GoogleRefreshTokenLong => LC.L("The Refresh Token for the Google App.");
+ public static string GoogleServiceAccountJsonShort => LC.L("The Google Service Account JSON");
+ public static string GoogleServiceAccountJsonLong => LC.L("The Google Service Account JSON content.");
+ public static string GoogleServiceAccountFileShort => LC.L("The Google Service Account File");
+ public static string GoogleServiceAccountFileLong => LC.L("The path to the Google Service Account JSON file.");
+ public static string GoogleAdminEmailShort => LC.L("The Google Admin Email");
+ public static string GoogleAdminEmailLong => LC.L("The email of the Google Admin user to impersonate.");
+ public static string GoogleIncludedRootTypesShort => LC.L("Included Root Types");
+ public static string GoogleIncludedRootTypesLong => LC.L("Comma-separated list of root types to include (e.g. Users, Groups, SharedDrives).");
+ public static string GoogleIncludedUserTypesShort => LC.L("Included User Types");
+ public static string GoogleIncludedUserTypesLong => LC.L("Comma-separated list of user types to include (e.g. Gmail, Drive, Calendar).");
+ public static string GoogleRequestedScopesShort => LC.L("Requested Scopes");
+ public static string GoogleRequestedScopesLong => LC.L("Comma-separated list of scopes to request. If not provided, the scopes will be calculated based on the included root and user types.");
+ }
+
+ internal static class Common
+ {
+ public static string DisplayName => LC.L("Google Workspace");
+ public static string Description => LC.L("Backup and restore Google Workspace data.");
+ public static string WebModuleDisplayName => LC.L("Google Workspace Web Module");
+ public static string WebModuleDescription => LC.L("Web module for Google Workspace.");
+ public static string WebModuleOperationShort => LC.L("Operation");
+ public static string WebModuleOperationLong => LC.L("The operation to perform.");
+ public static string WebModuleURLShort => LC.L("URL");
+ public static string WebModuleURLLong => LC.L("The URL to connect to.");
+ public static string WebModulePathShort => LC.L("Path");
+ public static string WebModulePathLong => LC.L("The path to list.");
+ }
+
+ public static string MetadataStorageNotEnabled(string optionname) => LC.L(
+ $"Storing metadata content in the database must be enabled to use the Google Workspace source provider. Use the option: --{optionname}");
+}
diff --git a/proprietary/GoogleWorkspace/WebModule/WebModule.cs b/proprietary/GoogleWorkspace/WebModule/WebModule.cs
new file mode 100644
index 000000000..9b5c5ff1e
--- /dev/null
+++ b/proprietary/GoogleWorkspace/WebModule/WebModule.cs
@@ -0,0 +1,149 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System.Text.Json;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Utility;
+using Duplicati.Proprietary.GoogleWorkspace.SourceItems;
+
+namespace Duplicati.Proprietary.GoogleWorkspace;
+
+public class WebModule : IWebModule
+{
+ public string Key => OptionsHelper.ModuleKey;
+
+ public string DisplayName => Strings.Common.WebModuleDisplayName;
+
+ public string Description => Strings.Common.WebModuleDescription;
+
+ public enum Operation
+ {
+ ListDestination,
+ ListDestinationRestoreTargets
+ }
+
+ private static readonly Operation DEFAULT_OPERATION = Operation.ListDestination;
+ private const string KEY_OPERATION = "operation";
+ private const string KEY_URL = "url";
+ private const string KEY_PATH = "path";
+
+ private static readonly IReadOnlySet RESTORE_TARGET_TYPES = new HashSet
+ {
+ SourceItemType.User.ToString(),
+ SourceItemType.UserGmail.ToString(),
+ SourceItemType.UserDrive.ToString(),
+ SourceItemType.UserCalendar.ToString(),
+ SourceItemType.UserContacts.ToString(),
+ SourceItemType.UserTasks.ToString(),
+ SourceItemType.UserKeep.ToString(),
+ SourceItemType.UserChat.ToString(),
+ SourceItemType.GmailLabel.ToString(),
+ SourceItemType.Drive.ToString(),
+ SourceItemType.DriveFolder.ToString(),
+ SourceItemType.Calendar.ToString(),
+ SourceItemType.TaskList.ToString(),
+ SourceItemType.ContactGroup.ToString(),
+ SourceItemType.Group.ToString(),
+ SourceItemType.SharedDrives.ToString(),
+ SourceItemType.Site.ToString(),
+ SourceItemType.ChatSpace.ToString(),
+ };
+
+ private static readonly IReadOnlySet RESTORE_TARGET_LEAF_TYPES = new HashSet
+ {
+ SourceItemType.GmailLabel.ToString(),
+ SourceItemType.DriveFolder.ToString(),
+ SourceItemType.Calendar.ToString(),
+ SourceItemType.TaskList.ToString(),
+ SourceItemType.ContactGroup.ToString(),
+ };
+
+ private static readonly IReadOnlySet RESTORE_TARGET_NONSELECTABLE_FOLDERS = new HashSet
+ {
+ SourceItemType.MetaRoot.ToString(),
+ SourceItemType.MetaRootUsers.ToString(),
+ SourceItemType.MetaRootGroups.ToString(),
+ SourceItemType.MetaRootSharedDrives.ToString(),
+ SourceItemType.MetaRootSites.ToString(),
+ SourceItemType.MetaRootOrganizationalUnits.ToString(),
+ };
+
+
+ public IList SupportedCommands => [
+ new CommandLineArgument(KEY_OPERATION, CommandLineArgument.ArgumentType.Enumeration, Strings.Common.WebModuleOperationShort, Strings.Common.WebModuleOperationLong, DEFAULT_OPERATION.ToString(), null, Enum.GetNames(typeof(Operation))),
+ new CommandLineArgument(KEY_URL, CommandLineArgument.ArgumentType.String, Strings.Common.WebModuleURLShort, Strings.Common.WebModuleURLLong),
+ new CommandLineArgument(KEY_PATH, CommandLineArgument.ArgumentType.String, Strings.Common.WebModulePathShort, Strings.Common.WebModulePathLong)
+ ];
+
+ public async Task> Execute(IDictionary options, CancellationToken cancellationToken)
+ {
+ var op = Utility.ParseEnumOption(options.AsReadOnly(), KEY_OPERATION, DEFAULT_OPERATION);
+ options.TryGetValue(KEY_URL, out var url);
+ options.TryGetValue(KEY_PATH, out var path);
+
+ if (op != Operation.ListDestination && op != Operation.ListDestinationRestoreTargets)
+ throw new UserInformationException($"Unsupported operation: {op}", "UnsupportedOperation");
+
+ if (string.IsNullOrWhiteSpace(url))
+ throw new UserInformationException($"Missing URL", "MissingURL");
+
+ var forwardoptions = new Dictionary()
+ {
+ { "store-metadata-content-in-database", "true" }
+ };
+
+ var uri = new Library.Utility.Uri(url);
+ foreach (var key in uri.QueryParameters.AllKeys)
+ forwardoptions[key!] = uri.QueryParameters[key];
+
+ using var client = new SourceProvider(url, "", forwardoptions);
+ await client.Initialize(cancellationToken);
+
+ var targetEntry = await client.GetEntry((path ?? "").TrimStart('/'), isFolder: true, cancellationToken).ConfigureAwait(false);
+ if (targetEntry == null)
+ throw new DirectoryNotFoundException($"Path not found: {path}");
+
+ var result = new Dictionary();
+ await foreach (var entry in targetEntry.Enumerate(cancellationToken))
+ {
+ if (op == Operation.ListDestinationRestoreTargets)
+ {
+ if (!entry.IsFolder)
+ continue;
+ }
+
+ var targetpath = entry.Path;
+ var metadata = new Dictionary();
+ try
+ {
+ if (!entry.IsMetaEntry)
+ metadata = await entry.GetMinorMetadata(cancellationToken);
+ }
+ catch
+ {
+ // ignore metadata errors
+ }
+
+ // For restore targets, treat leafs as non-folders
+ if (op == Operation.ListDestinationRestoreTargets)
+ {
+ var type = metadata.GetValueOrDefault("gsuite:Type");
+ if (type != null)
+ {
+ if (RESTORE_TARGET_LEAF_TYPES.Contains(type))
+ targetpath = targetpath.TrimEnd(Path.DirectorySeparatorChar);
+
+ if (!RESTORE_TARGET_NONSELECTABLE_FOLDERS.Contains(type) && !RESTORE_TARGET_TYPES.Contains(type))
+ continue;
+ }
+ }
+
+ result[targetpath] = JsonSerializer.Serialize(metadata);
+ }
+
+ return result;
+
+ }
+
+ public IDictionary> GetLookups()
+ => new Dictionary>();
+}
diff --git a/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs b/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs
index 5780c7fc5..9ee3107eb 100644
--- a/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs
+++ b/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs
@@ -22,4 +22,29 @@ public static class DuplicatiLicenseFeatures
/// The feature indicating support for Office365 site backups
///
public const string Office365Sites = "duplicati:office365:sites";
+
+ ///
+ /// The feature indicating support for Google Workspace user backups
+ ///
+ public const string GoogleWorkspaceUsers = "duplicati:googleworkspace:users";
+
+ ///
+ /// The feature indicating support for Google Workspace group backups
+ ///
+ public const string GoogleWorkspaceGroups = "duplicati:googleworkspace:groups";
+
+ ///
+ /// The feature indicating support for Google Workspace shared drive backups
+ ///
+ public const string GoogleWorkspaceSharedDrives = "duplicati:googleworkspace:shareddrives";
+
+ ///
+ /// The feature indicating support for Google Workspace site backups
+ ///
+ public const string GoogleWorkspaceSites = "duplicati:googleworkspace:sites";
+
+ ///
+ /// The feature indicating support for Google Workspace organizational unit backups
+ ///
+ public const string GoogleWorkspaceOrganizationalUnits = "duplicati:googleworkspace:organizationalunits";
}
diff --git a/proprietary/LicenseChecker/LicenseHelper.cs b/proprietary/LicenseChecker/LicenseHelper.cs
index ad4297823..0d9aee8d2 100644
--- a/proprietary/LicenseChecker/LicenseHelper.cs
+++ b/proprietary/LicenseChecker/LicenseHelper.cs
@@ -13,16 +13,34 @@ public static class LicenseHelper
public static int AvailableOffice365GroupSeats => GetFeatureSeats(DuplicatiLicenseFeatures.Office365Groups);
public static int AvailableOffice365SiteSeats => GetFeatureSeats(DuplicatiLicenseFeatures.Office365Sites);
+ public static int AvailableGoogleWorkspaceUserSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceUsers);
+ public static int AvailableGoogleWorkspaceGroupSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceGroups);
+ public static int AvailableGoogleWorkspaceSharedDriveSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceSharedDrives);
+ public static int AvailableGoogleWorkspaceSiteSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceSites);
+ public static int AvailableGoogleWorkspaceOrganizationalUnitSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceOrganizationalUnits);
+
public static bool IsOffice365Enabled =>
AvailableOffice365UserSeats > 0 ||
AvailableOffice365GroupSeats > 0 ||
AvailableOffice365SiteSeats > 0;
+ public static bool IsGoogleWorkspaceEnabled =>
+ AvailableGoogleWorkspaceUserSeats > 0 ||
+ AvailableGoogleWorkspaceGroupSeats > 0 ||
+ AvailableGoogleWorkspaceSharedDriveSeats > 0 ||
+ AvailableGoogleWorkspaceSiteSeats > 0 ||
+ AvailableGoogleWorkspaceOrganizationalUnitSeats > 0;
+
private static Dictionary UnlicensedSeats = new Dictionary
{
{ DuplicatiLicenseFeatures.Office365Users, 5 },
{ DuplicatiLicenseFeatures.Office365Groups, 5 },
{ DuplicatiLicenseFeatures.Office365Sites, 5 },
+ { DuplicatiLicenseFeatures.GoogleWorkspaceUsers, 5 },
+ { DuplicatiLicenseFeatures.GoogleWorkspaceGroups, 5 },
+ { DuplicatiLicenseFeatures.GoogleWorkspaceSharedDrives, 5 },
+ { DuplicatiLicenseFeatures.GoogleWorkspaceSites, 5 },
+ { DuplicatiLicenseFeatures.GoogleWorkspaceOrganizationalUnits, 5 },
};
private static int GetDefaultSeats(string feature)
diff --git a/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj b/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj
index 6eaf1ecf6..0b854e598 100644
--- a/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj
+++ b/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj
@@ -13,6 +13,7 @@
+
diff --git a/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs b/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs
index ce8472481..abf12a9b5 100644
--- a/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs
+++ b/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs
@@ -35,7 +35,8 @@ public static class RestoreDestinationProviderModules
///
private static readonly Lazy> LicensedRestoreDestinationProvidersLazy = new(() =>
new IRestoreDestinationProviderModule?[] {
- LicenseHelper.IsOffice365Enabled ? new Office365.RestoreProvider() : null
+ LicenseHelper.IsOffice365Enabled ? new Office365.RestoreProvider() : null,
+ LicenseHelper.IsGoogleWorkspaceEnabled ? new GoogleWorkspace.RestoreProvider() : null
}
.WhereNotNull()
.ToList()
diff --git a/proprietary/LoaderHelper/SourceProviderModules.cs b/proprietary/LoaderHelper/SourceProviderModules.cs
index 228e3f527..7193dedad 100644
--- a/proprietary/LoaderHelper/SourceProviderModules.cs
+++ b/proprietary/LoaderHelper/SourceProviderModules.cs
@@ -35,7 +35,8 @@ public static class SourceProviderModules
///
private static Lazy> LicensedSourceProvidersLazy = new(() =>
new ISourceProviderModule?[] {
- LicenseHelper.IsOffice365Enabled ? new Office365.SourceProvider() : null
+ LicenseHelper.IsOffice365Enabled ? new Office365.SourceProvider() : null,
+ LicenseHelper.IsGoogleWorkspaceEnabled ? new GoogleWorkspace.SourceProvider() : null
}
.WhereNotNull()
.ToList()
diff --git a/proprietary/LoaderHelper/WebModules.cs b/proprietary/LoaderHelper/WebModules.cs
index b5d713cc3..e8c90fe03 100644
--- a/proprietary/LoaderHelper/WebModules.cs
+++ b/proprietary/LoaderHelper/WebModules.cs
@@ -35,7 +35,8 @@ public static class WebModules
///
private static Lazy> LicensedWebModulesLazy = new(() =>
new IWebModule?[] {
- LicenseHelper.IsOffice365Enabled ? new Office365.WebModule() : null
+ LicenseHelper.IsOffice365Enabled ? new Office365.WebModule() : null,
+ LicenseHelper.IsGoogleWorkspaceEnabled ? new GoogleWorkspace.WebModule() : null
}
.WhereNotNull()
.ToList()