Files
duplicati/proprietary/Office365/RestoreProvider/RestoreProvider.cs
T

2200 lines
88 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
using Duplicati.Library.Utility;
using Duplicati.Library.Utility.Options;
using Duplicati.Proprietary.Office365.SourceItems;
namespace Duplicati.Proprietary.Office365;
public partial class RestoreProvider : IRestoreDestinationProviderModule
{
/// <summary>
/// Log tag for RestoreProvider
/// </summary>
private static readonly string LOGTAG = Log.LogTagFromType<RestoreProvider>();
/// <summary>
/// API helper for accessing Office365 APIs
/// </summary>
private readonly APIHelper _apiHelper;
/// <summary>
/// Path to restore to
/// </summary>
private readonly string _restorePath;
/// <summary>
/// Whether the overwrite option has been set
/// </summary>
private readonly bool _hasSetOverwriteOption;
/// <summary>
/// Source provider for accessing currently existing items on the destination tenant
/// </summary>
internal SourceProvider SourceProvider { get; init; }
/// <summary>
/// Properties related to the restore target
/// </summary>
/// <param name="Path">The path to restore to</param>
/// <param name="Entry">The source provider entry to restore to</param>
/// <param name="Metadata">The metadata associated with the restore target</param>
/// <param name="Type">The type of the restore target</param>
internal record RestoreTargetData(
string Path,
ISourceProviderEntry Entry,
Dictionary<string, string?> Metadata,
SourceItemType Type);
/// <summary>
/// The restore target data
/// </summary>
internal RestoreTargetData? RestoreTarget { get; private set; }
/// <summary>
/// Temporary files created during the restore process
/// </summary>
private readonly ConcurrentDictionary<string, TempFile> _temporaryFiles = new();
/// <summary>
/// Metadata recorded during the restore process
/// </summary>
private readonly ConcurrentDictionary<string, Dictionary<string, string?>> _metadata = new();
/// <summary>
/// Map of restored email folder paths to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredEmailFolderMap = new();
/// <summary>
/// Map of restored contact folder paths to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredContactFolderMap = new();
/// <summary>
/// Map of restored drive paths to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredDriveMap = new();
/// <summary>
/// Map of restored drive folder paths to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredDriveFolderMap = new();
/// <summary>
/// Map of restored planner buckets to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredPlannerBucketMap = new();
/// <summary>
/// Map of restored notebooks to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredNotebookMap = new();
/// <summary>
/// Map of restored section groups to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredSectionGroupMap = new();
/// <summary>
/// Map of restored sections to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredSectionMap = new();
/// <summary>
/// Map of restored task lists to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredTaskListMap = new();
/// <summary>
/// Map of restored tasks to their corresponding IDs
/// </summary>
private readonly ConcurrentDictionary<string, string> _restoredTaskMap = new();
/// <summary>
/// Whether to overwrite existing items
/// </summary>
internal readonly bool _ignoreExisting;
/// <summary>
/// Default constructor for the restore provider.
/// Only used for loading metadata about the provider.
/// </summary>
public RestoreProvider()
{
_apiHelper = null!;
_restorePath = null!;
SourceProvider = null!;
_hasSetOverwriteOption = false;
}
/// <summary>
/// Constructs the RestoreProvider with the given URL and options.
/// </summary>
/// <param name="url">The destination URL for the restore operation</param>
/// <param name="options">The options for the restore operation</param>
public RestoreProvider(string url, Dictionary<string, string?> options)
{
var uri = new Library.Utility.Uri(url);
_restorePath = uri.HostAndPath;
var parsedOptions = OptionsHelper.ParseAndValidateOptions(url, options);
_apiHelper = APIHelper.Create(
tenantId: parsedOptions.TenantId,
authOptions: parsedOptions.AuthOptions,
graphBaseUrl: parsedOptions.GraphBaseUrl,
certificatePath: parsedOptions.CertificatePath,
certificatePassword: parsedOptions.CertificatePassword,
timeouts: TimeoutOptionsHelper.Parse(options),
scope: parsedOptions.Scope
);
_ignoreExisting = Utility.ParseBoolOption(options, OptionsHelper.OFFICE_IGNORE_EXISTING_OPTION);
_hasSetOverwriteOption = Utility.ParseBoolOption(options, "overwrite");
var sourceOpts = new Dictionary<string, string?>(options);
sourceOpts["store-metadata-content-in-database"] = "true";
SourceProvider = new SourceProvider("office365://", "", sourceOpts)
{
// Make sure we can reach all items during restore
UsedForRestoreOperation = true
};
}
/// <inheritdoc />
public string Key => OptionsHelper.ModuleKey;
/// <inheritdoc />
public string DisplayName => Strings.ProviderDisplayName;
/// <inheritdoc />
public string Description => Strings.ProviderDescription;
/// <inheritdoc />
public IList<ICommandLineArgument> SupportedCommands => OptionsHelper.SupportedCommands.Concat([new CommandLineArgument(OptionsHelper.OFFICE_IGNORE_EXISTING_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.OfficeIgnoreExistingOptionShort, Strings.OfficeIgnoreExistingOptionLong)]).ToList();
/// <inheritdoc />
public string TargetDestination => _restorePath;
/// <inheritdoc />
public Task<bool> CreateFolderIfNotExists(string path, CancellationToken cancel)
{
// TODO: Do we need to do anything here?
return Task.FromResult(false);
}
/// <inheritdoc />
public async Task Initialize(CancellationToken cancel)
{
if (!_hasSetOverwriteOption)
throw new UserInformationException(Strings.RestoreTargetMissingOverwriteOption("overwrite", OptionsHelper.OFFICE_IGNORE_EXISTING_OPTION), "OverwriteOptionNotSet");
await _apiHelper.AcquireAccessTokenAsync(true, cancel);
await SourceProvider.Initialize(cancel);
var entry = await SourceProvider.GetEntry(_restorePath, isFolder: true, cancel);
if (entry == null)
throw new UserInformationException($"Restore target path not found: {_restorePath}", "RestoreTargetNotFound");
var metadata = await entry.GetMinorMetadata(cancel);
var type = metadata.TryGetValue("o365:Type", out var typeStr)
&& Enum.TryParse<SourceItemType>(typeStr, out var sourceItemType)
? sourceItemType
: throw new UserInformationException($"Invalid restore target type in metadata: {typeStr}", "InvalidRestoreTargetType");
RestoreTarget = new RestoreTargetData(_restorePath, entry, metadata, type);
}
/// <inheritdoc />
public async Task Test(CancellationToken cancellationToken)
{
await _apiHelper.AcquireAccessTokenAsync(false, cancellationToken);
// If we have no path, just check that we can connect
if (string.IsNullOrWhiteSpace(_restorePath))
return;
// Otherwise, check that the path exists and is a valid target
await Initialize(cancellationToken);
}
/// <summary>
/// Normalizes the given path by applying the RemovedRestorePrefix if it has been removed
/// </summary>
/// <param name="path">The path to normalize</param>
/// <returns>The normalized path</returns>
private string NormalizePath(string path)
=> path;
/// <summary>
/// Replaces the extension of a path with a new extension
/// </summary>
/// <param name="path">The path to modify</param>
/// <param name="oldExtension">The extension to replace</param>
/// <param name="newExtension">The new extension</param>
/// <returns>The modified path</returns>
private static string ReplaceExtension(string path, string oldExtension, string newExtension)
{
if (path.EndsWith(oldExtension, StringComparison.OrdinalIgnoreCase))
return path.Substring(0, path.Length - oldExtension.Length) + newExtension;
return path;
}
/// <inheritdoc />
public void Dispose()
{
_apiHelper?.Dispose();
SourceProvider?.Dispose();
foreach (var file in _temporaryFiles.Values)
file.Dispose();
_temporaryFiles.Clear();
}
/// <inheritdoc />
public async Task<bool> FileExists(string path, CancellationToken cancel)
{
path = NormalizePath(path);
if (_temporaryFiles.ContainsKey(path))
return true;
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
return entry != null;
}
/// <inheritdoc />
public Task<Stream> OpenWrite(string path, CancellationToken cancel)
{
path = NormalizePath(path);
var file = _temporaryFiles.GetOrAdd(path, _ => new TempFile());
return Task.FromResult<Stream>(SystemIO.IO_OS.FileOpenWrite(file));
}
/// <inheritdoc />
public async Task<Stream> OpenRead(string path, CancellationToken cancel)
{
path = NormalizePath(path);
if (_temporaryFiles.ContainsKey(path))
return SystemIO.IO_OS.FileOpenRead(_temporaryFiles[path]);
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
if (entry != null)
return await entry.OpenRead(cancel).ConfigureAwait(false);
throw new FileNotFoundException($"File not found: {path}");
}
/// <inheritdoc />
public async Task<Stream> OpenReadWrite(string path, CancellationToken cancel)
{
path = NormalizePath(path);
if (_temporaryFiles.ContainsKey(path))
return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]);
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
if (entry == null)
{
_temporaryFiles.GetOrAdd(path, _ => new TempFile());
return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]);
}
if (entry.IsFolder)
throw new FileNotFoundException($"Path is a folder: {path}");
var file = new TempFile();
using (var fs = SystemIO.IO_OS.FileOpenWrite(file))
using (var stream = await entry.OpenRead(cancel).ConfigureAwait(false))
await stream.CopyToAsync(fs).ConfigureAwait(false);
_temporaryFiles[path] = file;
return SystemIO.IO_OS.FileOpenReadWrite(file);
}
/// <inheritdoc />
public async Task<long> GetFileLength(string path, CancellationToken cancel)
{
path = NormalizePath(path);
if (_temporaryFiles.ContainsKey(path))
return SystemIO.IO_OS.FileLength(_temporaryFiles[path]);
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
if (entry == null)
throw new FileNotFoundException($"File not found: {path}");
return entry.Size;
}
/// <inheritdoc />
public Task<bool> HasReadOnlyAttribute(string path, CancellationToken cancel)
=> Task.FromResult(false);
/// <inheritdoc />
public Task ClearReadOnlyAttribute(string path, CancellationToken cancel)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<bool> WriteMetadata(string path, Dictionary<string, string?> metadata, bool restoreSymlinkMetadata, bool restorePermissions, CancellationToken cancel)
{
path = NormalizePath(path);
_metadata.AddOrUpdate(path, metadata, (_, _) => metadata);
return Task.FromResult(true);
}
/// <inheritdoc />
public Task DeleteFolder(string path, CancellationToken cancel)
=> Task.CompletedTask;
/// <inheritdoc />
public Task DeleteFile(string path, CancellationToken cancel)
// Should only happen if attempting to restore a symlink, which is not supported
=> throw new NotImplementedException("File deletion is not supported in Office365 RestoreProvider");
/// <inheritdoc />
public IList<string> GetPriorityFiles() => Array.Empty<string>();
/// <inheritdoc />
public async Task Finalize(Action<double>? progressCallback, CancellationToken cancel)
{
var totalFiles = _metadata.Count;
try
{
await RestoreUserEmailFolders(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreUserEmailFoldersFailed", ex, $"Failed to restore user email folders");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreUserEmails(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreUserEmailsFailed", ex, $"Failed to restore user emails");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreUserMailboxSettings(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreUserMailboxSettingsFailed", ex, $"Failed to restore user mailbox settings");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreUserMailboxRules(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreUserMailboxRulesFailed", ex, $"Failed to restore user mailbox rules");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreDrives(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDrivesFailed", ex, $"Failed to restore drives");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreSiteDrives(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreSiteDrivesFailed", ex, $"Failed to restore site drives");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreSharePointLists(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreSharePointListsFailed", ex, $"Failed to restore SharePoint lists");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreSharePointListItems(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreSharePointListItemsFailed", ex, $"Failed to restore SharePoint list items");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreDriveFolders(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFoldersFailed", ex, $"Failed to restore drive folders");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreDriveFiles(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFilesFailed", ex, $"Failed to restore drive files");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreCalendarEvents(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreCalendarEventsFailed", ex, $"Failed to restore calendar events");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreContactFolders(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactFoldersFailed", ex, $"Failed to restore contact folders");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreContacts(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactsFailed", ex, $"Failed to restore contacts");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreContactGroups(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactGroupsFailed", ex, $"Failed to restore contact groups");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreGroupChannels(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGroupChannelsFailed", ex, $"Failed to restore group channels");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreGroupSettings(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGroupSettingsFailed", ex, $"Failed to restore group settings");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreGroupMembers(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGroupMembersFailed", ex, $"Failed to restore group members");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreGroupOwners(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGroupOwnersFailed", ex, $"Failed to restore group owners");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreTeamApps(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTeamAppsFailed", ex, $"Failed to restore team apps");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreChannelTabs(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreChannelTabsFailed", ex, $"Failed to restore channel tabs");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreChannelMessages(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreChannelMessagesFailed", ex, $"Failed to restore channel messages");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreGroupConversations(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGroupConversationsFailed", ex, $"Failed to restore group conversations");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreGroupCalendarEvents(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGroupCalendarEventsFailed", ex, $"Failed to restore group calendar events");
}
try
{
await ChannelRestore.EndMigrationMode(cancel);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "EndMigrationModeFailed", ex, $"Failed to end migration mode for channels");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestorePlannerBuckets(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestorePlannerBucketsFailed", ex, $"Failed to restore planner buckets");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestorePlannerTasks(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestorePlannerTasksFailed", ex, $"Failed to restore planner tasks");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreNotebooks(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreNotebooksFailed", ex, $"Failed to restore notebooks");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreNotebookSectionGroups(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreNotebookSectionGroupsFailed", ex, $"Failed to restore notebook section groups");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreNotebookSections(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreNotebookSectionsFailed", ex, $"Failed to restore notebook sections");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreNotebookPages(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreNotebookPagesFailed", ex, $"Failed to restore notebook pages");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreTaskLists(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTaskListsFailed", ex, $"Failed to restore task lists");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreTaskListTasks(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTaskListTasksFailed", ex, $"Failed to restore task list tasks");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreTaskChecklistItems(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTaskChecklistItemsFailed", ex, $"Failed to restore task checklist items");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreTaskLinkedResources(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTaskLinkedResourcesFailed", ex, $"Failed to restore task linked resources");
}
progressCallback?.Invoke(_metadata.Count / (double)totalFiles);
try
{
await RestoreUserProfile(cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreUserProfileFailed", ex, $"Failed to restore user profile");
}
RemoveMetaEntriesForNonRestoredItems();
// We should have restored all items by now
if (_metadata.Count > 0 || _temporaryFiles.Count > 0)
{
var displayItems = _metadata.Values
.Select(x => (Type: x.GetValueOrDefault("o365:Type"), Name: x.GetValueOrDefault("o365:Name") ?? x.GetValueOrDefault("o365:DisplayName")))
.Where(x => !string.IsNullOrWhiteSpace(x.Type) && !string.IsNullOrWhiteSpace(x.Name))
.Take(10);
foreach (var item in displayItems)
Log.WriteWarningMessage(LOGTAG, "FinalizeIncompleteItem", null, $"Unrestored item - Type: {item.Type}, Name: {item.Name}");
if (_metadata.Count > displayItems.Count())
Log.WriteWarningMessage(LOGTAG, "FinalizeIncomplete", null, $"... and {_metadata.Count - displayItems.Count()} more unrestored items.");
}
var tempFiles = _temporaryFiles.Values.ToList();
_temporaryFiles.Clear();
foreach (var file in tempFiles)
file.Dispose();
_metadata.Clear();
}
/// <summary>
/// Gets metadata entries by their source item type
/// </summary>
/// <param name="type">The source item type to filter by</param>
/// <returns>The list of metadata entries matching the given type</returns>
private List<KeyValuePair<string, Dictionary<string, string?>>> GetMetadataByType(SourceItemType type)
=> _metadata
.Where(kv => kv.Value.TryGetValue("o365:Type", out var typeStr)
&& typeStr == type.ToString())
.ToList();
/// <summary>
/// Removes metadata entries for items that were not restored and are not required to be processed
/// </summary>
private void RemoveMetaEntriesForNonRestoredItems()
{
var toRemove = new HashSet<string>();
foreach (var kv in _metadata)
{
// Some intermediate directories may not have a type
if (!kv.Value.ContainsKey("o365:Type"))
{
toRemove.Add(kv.Key);
continue;
}
var key = kv.Value.GetValueOrDefault("o365:Type");
if (!Enum.TryParse<SourceItemType>(key, out var type))
continue;
switch (type)
{
// Various meta-types that are not restored directly
case SourceItemType.User:
case SourceItemType.UserMailbox:
case SourceItemType.UserContacts:
case SourceItemType.Calendar:
case SourceItemType.Drive:
toRemove.Add(kv.Key);
break;
default:
break;
}
// If we are restoring TO a channel, we don't need to restore the channel itself
if (type == SourceItemType.GroupChannel && RestoreTarget != null && RestoreTarget.Type == SourceItemType.GroupChannel)
toRemove.Add(kv.Key);
// If we are restoring TO a site, we don't need to restore the site itself
if (type == SourceItemType.Site && RestoreTarget != null && RestoreTarget.Type == SourceItemType.Site)
toRemove.Add(kv.Key);
if (type == SourceItemType.Planner && RestoreTarget != null)
{
if (RestoreTarget.Type == SourceItemType.Group || RestoreTarget.Type == SourceItemType.Planner)
toRemove.Add(kv.Key);
toRemove.Add(kv.Key);
}
}
foreach (var key in toRemove)
{
_metadata.TryRemove(key, out _);
_temporaryFiles.TryRemove(key, out var f);
f?.Dispose();
}
}
/// <summary>
/// Restores user email folders and updates the folder ID map
/// </summary>
/// <param name="cancel">The cancellation token</param>
/// <returns>An awaitable task</returns>
private async Task RestoreUserEmailFolders(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
// Find email entries in _metadata
var emailFolders = GetMetadataByType(SourceItemType.UserMailboxFolder);
if (emailFolders.Count == 0)
return;
(var userId, var mailboxId) = await EmailRestore.GetUserIdAndMailboxTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(mailboxId))
return; // A warning has already been logged
// Create folders
// Sort folders by path length to ensure parents are created before children
var sortedFolders = emailFolders.OrderBy(k => k.Key.Split(Path.DirectorySeparatorChar).Length).ToList();
foreach (var folder in sortedFolders)
{
if (cancel.IsCancellationRequested)
break;
var originalPath = folder.Key;
var metadata = folder.Value;
var displayName = metadata.GetValueOrDefault("o365:Name") ?? metadata.GetValueOrDefault("o365:DisplayName") ?? metadata.GetValueOrDefault("o365:Id");
_metadata.TryRemove(originalPath, out _);
if (string.IsNullOrWhiteSpace(displayName))
{
Log.WriteWarningMessage(LOGTAG, "RestoreUserEmailFoldersMissingName", null, $"Missing display name for folder {originalPath}, skipping.");
continue;
}
// Determine parent folder ID
// We assume the path structure reflects the hierarchy
// If parent path is not in map, we assume it's the root (mailboxId)
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
var parentId = mailboxId;
if (parentPath != null && _restoredEmailFolderMap.TryGetValue(parentPath, out var mappedParentId))
parentId = mappedParentId;
try
{
// Check if folder exists
var existingFolder = await EmailApi.GetChildFolderAsync(userId, parentId, displayName, cancel);
if (existingFolder != null)
{
_restoredEmailFolderMap[originalPath] = existingFolder.Id;
}
else
{
var newFolder = await EmailApi.CreateMailFolderAsync(userId, parentId, displayName, cancel);
_restoredEmailFolderMap[originalPath] = newFolder.Id;
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreUserEmailFoldersFailed", ex, $"Failed to restore folder {displayName} at {originalPath}");
}
}
}
/// <summary>
/// Restores user emails
/// </summary>
/// <param name="cancel">>The cancellation token</param>
/// <returns>An awaitable task</returns>
private async Task RestoreUserEmails(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
// Find email entries in _metadata
var emailSources = GetMetadataByType(SourceItemType.UserMailboxEmail);
if (emailSources.Count == 0)
return;
(var userId, var mailboxId) = await EmailRestore.GetUserIdAndMailboxTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(mailboxId))
return; // A warning has already been logged
// Restore emails
foreach (var emailSource in emailSources)
{
if (cancel.IsCancellationRequested)
break;
try
{
var contentPath = SystemIO.IO_OS.PathCombine(emailSource.Key, "content.eml");
var metadataPath = SystemIO.IO_OS.PathCombine(emailSource.Key, "metadata.json");
var internetMessageId = emailSource.Value.GetValueOrDefault("o365:InternetMessageId");
// Determine target folder
var targetFolderId = mailboxId;
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(emailSource.Key.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
if (parentPath != null && _restoredEmailFolderMap.TryGetValue(parentPath, out var mappedFolderId))
{
targetFolderId = mappedFolderId;
}
if (!_ignoreExisting && !string.IsNullOrWhiteSpace(internetMessageId) && await EmailApi.EmailExistsInFolderByInternetMessageIdAsync(userId, targetFolderId, internetMessageId, cancel))
{
Log.WriteInformationMessage(LOGTAG, "RestoreUserEmailsSkipExisting", $"Email with InternetMessageId {internetMessageId} already exists in target mailbox, skipping restore for {emailSource.Key}.");
}
else
{
var contentEntry = _temporaryFiles.GetValueOrDefault(contentPath);
var metadataEntry = _temporaryFiles.GetValueOrDefault(metadataPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreUserEmailsMissingContent", null, $"Missing email content for {emailSource.Key}, skipping.");
continue;
}
if (metadataEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreUserEmailsMissingMetadata", null, $"Missing email metadata for {emailSource.Key}, skipping.");
continue;
}
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
using (var metadataStream = SystemIO.IO_OS.FileOpenRead(metadataEntry))
await EmailApi.RestoreEmailToFolderAsync(userId, targetFolderId, contentStream, metadataStream, cancel);
}
// Clean up when done
_metadata.TryRemove(emailSource.Key, out _);
_metadata.TryRemove(contentPath, out _);
_metadata.TryRemove(metadataPath, out _);
_temporaryFiles.TryRemove(contentPath, out var contentFile);
_temporaryFiles.TryRemove(metadataPath, out var metadataFile);
contentFile?.Dispose();
metadataFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreUserEmailsFailed", ex, $"Failed to restore email at {emailSource.Key}");
}
}
}
private async Task RestoreDrives(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var driveSources = GetMetadataByType(SourceItemType.Drive);
if (driveSources.Count == 0)
return;
if (RestoreTarget.Type == SourceItemType.Drive)
{
var targetDriveId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
if (!string.IsNullOrWhiteSpace(targetDriveId))
{
foreach (var driveSource in driveSources)
{
_restoredDriveMap[driveSource.Key] = targetDriveId;
}
}
return;
}
string? targetUserId = null;
string? targetSiteId = null;
if (RestoreTarget.Type == SourceItemType.User)
{
targetUserId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
}
else if (RestoreTarget.Type == SourceItemType.Site)
{
targetSiteId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
}
if (!string.IsNullOrWhiteSpace(targetUserId))
{
try
{
var primaryDrive = await SourceProvider.OneDriveApi.GetUserPrimaryDriveAsync(targetUserId, cancel);
foreach (var driveSource in driveSources)
{
_restoredDriveMap[driveSource.Key] = primaryDrive.Id;
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreDrivesFailed", ex, $"Failed to restore drives");
}
}
else if (!string.IsNullOrWhiteSpace(targetSiteId))
{
try
{
var primaryDrive = await SourceProvider.SiteApi.GetSitePrimaryDriveAsync(targetSiteId, cancel);
foreach (var driveSource in driveSources)
{
_restoredDriveMap[driveSource.Key] = primaryDrive.Id;
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreDrivesFailed", ex, $"Failed to restore drives");
}
}
}
private async Task RestoreDriveFolders(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var folders = GetMetadataByType(SourceItemType.DriveFolder);
if (folders.Count == 0)
return;
// Sort folders by path length to ensure parents are created before children
var sortedFolders = folders.OrderBy(k => k.Key.Split(Path.DirectorySeparatorChar).Length).ToList();
var restoreTarget = await DriveRestore.GetDefaultDriveAndFolder(cancel);
if (!string.IsNullOrWhiteSpace(restoreTarget.FolderId))
{
var rootFolderPath = Util.AppendDirSeparator(Path.GetDirectoryName(sortedFolders.First().Key.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
_restoredDriveFolderMap.AddOrUpdate(rootFolderPath, restoreTarget.FolderId, (k, v) => v);
}
foreach (var folder in sortedFolders)
{
if (cancel.IsCancellationRequested)
break;
var originalPath = folder.Key;
var metadata = folder.Value;
var displayName = metadata.GetValueOrDefault("o365:Name") ?? metadata.GetValueOrDefault("o365:DisplayName") ?? metadata.GetValueOrDefault("o365:Id");
_metadata.TryRemove(originalPath, out _);
if (string.IsNullOrWhiteSpace(displayName))
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFoldersMissingName", null, $"Missing display name for folder {originalPath}, skipping.");
continue;
}
// Find the drive ID
var drivePath = _restoredDriveMap.Keys
.Where(k => originalPath.StartsWith(Util.AppendDirSeparator(k)))
.OrderByDescending(k => k.Length)
.FirstOrDefault();
string? driveId = null;
string defaultRootId = "root";
if (drivePath != null)
{
driveId = _restoredDriveMap[drivePath];
}
else
{
driveId = restoreTarget.DriveId;
if (restoreTarget.FolderId != null) defaultRootId = restoreTarget.FolderId;
}
if (driveId == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFoldersMissingDrive", null, $"Could not find restored drive for folder {originalPath}, skipping.");
continue;
}
// Determine parent folder ID
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
var parentId = defaultRootId;
if (parentPath != null && (drivePath == null || parentPath.TrimEnd(Path.DirectorySeparatorChar) != drivePath.TrimEnd(Path.DirectorySeparatorChar)))
{
if (_restoredDriveFolderMap.TryGetValue(parentPath, out var mappedParentId))
{
parentId = mappedParentId;
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFoldersMissingParent", null, $"Could not find parent folder for {originalPath}, skipping.");
continue;
}
}
try
{
// Skip if folder already exists
var restoredFolder = await DriveApi.GetDriveItemAsync(driveId, parentId, displayName, cancel);
if (restoredFolder != null)
{
_restoredDriveFolderMap[originalPath] = restoredFolder.Id;
continue;
}
var newFolder = await DriveApi.CreateDriveFolderAsync(driveId, parentId, displayName, cancel);
_restoredDriveFolderMap[originalPath] = newFolder.Id;
// Restore permissions
var permissionsJson = metadata.GetValueOrDefault("o365:Permissions");
if (!string.IsNullOrWhiteSpace(permissionsJson))
{
try
{
await DriveApi.RestoreDriveItemPermissionsAsync(driveId, newFolder.Id, permissionsJson, cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFolderPermissionsFailed", ex, $"Failed to restore permissions for folder {originalPath}");
}
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreDriveFoldersFailed", ex, $"Failed to restore folder {displayName} at {originalPath}");
}
}
}
private async Task RestoreDriveFiles(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var files = GetMetadataByType(SourceItemType.DriveFile);
if (files.Count == 0)
return;
foreach (var file in files)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = file.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFilesMissingContent", null, $"Missing file content for {originalPath}, skipping.");
continue;
}
// Find drive ID
var drivePath = _restoredDriveMap.Keys
.Where(k => originalPath.StartsWith(Util.AppendDirSeparator(k)))
.OrderByDescending(k => k.Length)
.FirstOrDefault();
string? driveId = null;
string defaultRootId = "root";
if (drivePath != null)
{
driveId = _restoredDriveMap[drivePath];
}
else
{
var defaults = await DriveRestore.GetDefaultDriveAndFolder(cancel);
driveId = defaults.DriveId;
if (defaults.FolderId != null) defaultRootId = defaults.FolderId;
}
if (driveId == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFilesMissingDrive", null, $"Could not find restored drive for file {originalPath}, skipping.");
continue;
}
// Determine parent folder ID
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
var parentId = defaultRootId;
if (parentPath != null && (drivePath == null || parentPath.TrimEnd(Path.DirectorySeparatorChar) != drivePath.TrimEnd(Path.DirectorySeparatorChar)))
{
if (_restoredDriveFolderMap.TryGetValue(parentPath, out var mappedParentId))
{
parentId = mappedParentId;
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFilesMissingParent", null, $"Could not find parent folder for {originalPath}, skipping.");
continue;
}
}
var displayName = file.Value.GetValueOrDefault("o365:Name") ?? Path.GetFileName(originalPath);
if (!_ignoreExisting)
{
var existingItem = await DriveApi.GetDriveItemAsync(driveId, parentId, displayName, cancel);
if (existingItem != null)
{
// Check if it's the same file
// We can check size
var sizeStr = file.Value.GetValueOrDefault("o365:Size");
long? sourceSize = null;
if (long.TryParse(sizeStr, out var s)) sourceSize = s;
else
{
sourceSize = SystemIO.IO_OS.FileLength(contentEntry);
}
if (existingItem.Size == sourceSize)
{
// Check hash if available
string? sourceHash = null;
var sourceHashStr = file.Value.GetValueOrDefault("o365:Hashes");
if (sourceHashStr != null)
{
var sourceHashes = JsonSerializer.Deserialize<GraphDriveHashes>(sourceHashStr);
sourceHash = sourceHashes?.QuickXorHash ?? sourceHashes?.Sha1Hash;
}
var existingHash = existingItem.File?.Hashes?.QuickXorHash ?? existingItem.File?.Hashes?.Sha1Hash;
if (!string.IsNullOrWhiteSpace(sourceHash) && !string.IsNullOrWhiteSpace(existingHash))
{
if (string.Equals(sourceHash, existingHash, StringComparison.OrdinalIgnoreCase))
{
Log.WriteVerboseMessage(LOGTAG, "RestoreDriveFilesSkipDuplicate", $"Skipping existing file {originalPath} (Name: {displayName}, Hash match)");
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var cFile);
cFile?.Dispose();
continue;
}
}
else
{
// Fallback to size only if hash is missing
Log.WriteVerboseMessage(LOGTAG, "RestoreDriveFilesSkipDuplicate", $"Skipping existing file {originalPath} (Name: {displayName}, Size match)");
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var cFile);
cFile?.Dispose();
continue;
}
}
}
}
GraphDriveItem restoredItem;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
restoredItem = await DriveApi.RestoreDriveItemToFolderAsync(driveId, parentId, displayName, contentStream, null, cancel);
}
// Restore metadata (timestamps)
var createdStr = file.Value.GetValueOrDefault("o365:CreatedDateTime");
var modifiedStr = file.Value.GetValueOrDefault("o365:LastModifiedDateTime");
DateTimeOffset? created = null;
DateTimeOffset? modified = null;
if (DateTimeOffset.TryParse(createdStr, CultureInfo.InvariantCulture, out var c)) created = c;
if (DateTimeOffset.TryParse(modifiedStr, CultureInfo.InvariantCulture, out var m)) modified = m;
if (created != null || modified != null)
{
try
{
await DriveApi.UpdateDriveItemFileSystemInfoAsync(driveId, restoredItem.Id, created, modified, cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFileMetadataFailed", ex, $"Failed to restore metadata for file {file.Key}");
}
}
// Restore permissions
var permissionsJson = file.Value.GetValueOrDefault("o365:Permissions");
if (!string.IsNullOrWhiteSpace(permissionsJson))
{
try
{
await DriveApi.RestoreDriveItemPermissionsAsync(driveId, restoredItem.Id, permissionsJson, cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreDriveFilePermissionsFailed", ex, $"Failed to restore permissions for file {file.Key}");
}
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreDriveFilesFailed", ex, $"Failed to restore file {file.Key}");
}
}
}
private async Task RestoreCalendarEvents(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var events = GetMetadataByType(SourceItemType.CalendarEvent);
if (events.Count == 0)
return;
(var userId, var calendarId) = await CalendarRestore.GetUserIdAndCalendarTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(calendarId))
return;
// Group attachments by event path
var attachments = GetMetadataByType(SourceItemType.CalendarEventAttachment)
.GroupBy(k => Util.AppendDirSeparator(Path.GetDirectoryName(k.Key.TrimEnd(Path.DirectorySeparatorChar)) ?? ""))
.ToDictionary(g => g.Key, g => g.ToList());
var singles = new List<(string Path, GraphEvent Event)>();
var masters = new List<(string Path, GraphEvent Event)>();
var exceptions = new List<(string Path, GraphEvent Event)>();
foreach (var eventItem in events)
{
if (cancel.IsCancellationRequested) break;
var originalPath = eventItem.Key;
GraphEvent? graphEvent = null;
try
{
var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
if (_temporaryFiles.TryRemove(contentPath, out var contentFile))
{
using var tf = contentFile;
using var stream = SystemIO.IO_OS.FileOpenRead(tf);
graphEvent = await JsonSerializer.DeserializeAsync<GraphEvent>(stream, cancellationToken: cancel);
}
// Clean up content file metadata
_metadata.TryRemove(contentPath, out var _);
// Temporary patch for missing ICalUId
_metadata.TryGetValue(originalPath, out var eventMeta);
if (string.IsNullOrWhiteSpace(graphEvent?.ICalUId) && eventMeta != null && eventMeta.TryGetValue("o365:CalUId", out var iCalUId))
graphEvent!.ICalUId = iCalUId;
if (graphEvent == null) continue;
if (graphEvent.Type == "seriesMaster")
masters.Add((originalPath, graphEvent));
else if (graphEvent.Type == "exception")
exceptions.Add((originalPath, graphEvent));
else if (graphEvent.Type == "occurrence")
continue; // Skip occurrences
else
singles.Add((originalPath, graphEvent));
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreCalendarEventAnalysisFailed", ex, $"Failed to analyze event {originalPath}");
}
}
// Restore singles
foreach (var item in singles)
await RestoreSingleEvent(userId, calendarId, item.Path, item.Event, attachments, cancel);
// Restore masters
var masterMap = new Dictionary<string, string>(); // OldId -> NewId
foreach (var item in masters)
{
var newId = await RestoreMasterEvent(userId, calendarId, item.Path, item.Event, attachments, cancel);
if (newId != null && !string.IsNullOrWhiteSpace(item.Event.Id))
masterMap[item.Event.Id] = newId;
}
// Restore exceptions
foreach (var item in exceptions)
await RestoreExceptionEvent(userId, calendarId, item.Path, item.Event, masterMap, attachments, cancel);
}
private async Task RestoreCalendarEventAttachments(string userId, string calendarId, string eventId, string eventPath, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
{
var eventPathWithSep = Util.AppendDirSeparator(eventPath);
if (!attachments.TryGetValue(eventPathWithSep, out var eventAttachments))
return;
foreach (var att in eventAttachments)
{
try
{
var attPath = att.Key;
var metadata = att.Value;
var name = metadata.GetValueOrDefault("o365:Name") ?? "attachment";
var contentType = metadata.GetValueOrDefault("o365:ContentType") ?? "application/octet-stream";
if (!_temporaryFiles.TryRemove(attPath, out var contentFile))
continue;
using var tf = contentFile;
using var stream = SystemIO.IO_OS.FileOpenRead(tf);
await CalendarApi.AddAttachmentAsync(userId, calendarId, eventId, name, contentType, stream, cancel);
_metadata.TryRemove(attPath, out _);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreCalendarAttachmentFailed", ex, $"Failed to restore attachment for event {eventPath}");
}
}
}
private async Task RestoreSingleEvent(string userId, string calendarId, string path, GraphEvent eventItem, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
{
try
{
if (!_ignoreExisting && !string.IsNullOrWhiteSpace(eventItem.ICalUId))
{
var existing = await CalendarApi.FindEventsAsync(userId, calendarId, eventItem, cancel);
if (existing.Count > 0)
{
Log.WriteInformationMessage(LOGTAG, "RestoreSingleEventSkipDuplicate", $"Skipping duplicate event {path} (Subject: {eventItem.Subject})");
_metadata.TryRemove(path, out _);
return;
}
}
// Clean up properties that shouldn't be sent on creation
eventItem.Id = null!;
eventItem.CreatedDateTime = null;
eventItem.LastModifiedDateTime = null;
var created = await CalendarApi.CreateCalendarEventAsync(userId, calendarId, eventItem, cancel);
await RestoreCalendarEventAttachments(userId, calendarId, created.Id, path, attachments, cancel);
_metadata.TryRemove(path, out _);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreSingleEventFailed", ex, $"Failed to restore event {path}");
}
}
private async Task<string?> RestoreMasterEvent(string userId, string calendarId, string path, GraphEvent eventItem, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
{
try
{
if (!_ignoreExisting && !string.IsNullOrWhiteSpace(eventItem.ICalUId))
{
var existing = await CalendarApi.FindEventsAsync(userId, calendarId, eventItem, cancel);
if (existing.Count > 0)
{
Log.WriteInformationMessage(LOGTAG, "RestoreMasterEventSkipDuplicate", $"Skipping duplicate master event {path} (Subject: {eventItem.Subject})");
_metadata.TryRemove(path, out _);
// Return the ID of the existing event so exceptions can be attached to it?
// If we skip it, we should probably return the existing ID.
return existing[0].Id;
}
}
eventItem.Id = null!;
eventItem.CreatedDateTime = null;
eventItem.LastModifiedDateTime = null;
var created = await CalendarApi.CreateCalendarEventAsync(userId, calendarId, eventItem, cancel);
await RestoreCalendarEventAttachments(userId, calendarId, created.Id, path, attachments, cancel);
_metadata.TryRemove(path, out _);
return created.Id;
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreMasterEventFailed", ex, $"Failed to restore master event {path}");
return null;
}
}
private async Task RestoreExceptionEvent(string userId, string calendarId, string path, GraphEvent eventItem, Dictionary<string, string> masterMap, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
{
try
{
if (string.IsNullOrWhiteSpace(eventItem.SeriesMasterId) || !masterMap.TryGetValue(eventItem.SeriesMasterId, out var newMasterId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreExceptionEventMissingMaster", null, $"Could not find restored master for exception event {path}, skipping.");
return;
}
if (eventItem.OriginalStart == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreExceptionEventMissingOriginalStart", null, $"Missing original start time for exception event {path}, skipping.");
return;
}
// Find the occurrence
// We search for instances around the original start time
var searchStart = eventItem.OriginalStart.Value.AddMinutes(-1);
var searchEnd = eventItem.OriginalStart.Value.AddMinutes(1);
var instances = await CalendarApi.GetCalendarEventInstancesAsync(userId, newMasterId, searchStart, searchEnd, cancel);
var instance = instances.FirstOrDefault(i =>
{
// Parse start time from instance
if (i.Start is JsonElement startElem && startElem.ValueKind == JsonValueKind.Object)
{
if (startElem.TryGetProperty("dateTime", out var dtProp) && dtProp.GetString() is string dtStr)
{
if (DateTimeOffset.TryParse(dtStr, out var dt))
{
return Math.Abs((dt - eventItem.OriginalStart.Value).TotalSeconds) < 5;
}
}
}
return false;
});
if (instance == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreExceptionEventInstanceNotFound", null, $"Could not find occurrence instance for exception event {path}, skipping.");
return;
}
// Update the instance
eventItem.Id = null!;
eventItem.SeriesMasterId = null;
eventItem.Type = null;
eventItem.CreatedDateTime = null;
eventItem.LastModifiedDateTime = null;
eventItem.OriginalStart = null;
var updated = await CalendarApi.UpdateCalendarEventAsync(userId, instance.Id, eventItem, cancel);
await RestoreCalendarEventAttachments(userId, calendarId, updated.Id, path, attachments, cancel);
_metadata.TryRemove(path, out _);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreExceptionEventFailed", ex, $"Failed to restore exception event {path}");
}
}
private async Task RestoreContactFolders(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var folders = GetMetadataByType(SourceItemType.UserContactFolder);
if (folders.Count == 0)
return;
(var userId, var defaultFolderId) = await ContactRestore.GetUserIdAndContactFolderTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(defaultFolderId))
return;
// Sort folders by path length to ensure parents are created before children
var sortedFolders = folders.OrderBy(k => k.Key.Split(Path.DirectorySeparatorChar).Length).ToList();
foreach (var folder in sortedFolders)
{
if (cancel.IsCancellationRequested)
break;
var originalPath = folder.Key;
var metadata = folder.Value;
var displayName = metadata.GetValueOrDefault("o365:Name") ?? metadata.GetValueOrDefault("o365:DisplayName") ?? metadata.GetValueOrDefault("o365:Id");
_metadata.TryRemove(originalPath, out _);
if (string.IsNullOrWhiteSpace(displayName))
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactFoldersMissingName", null, $"Missing display name for folder {originalPath}, skipping.");
continue;
}
// Determine parent folder ID
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
var parentId = defaultFolderId;
if (parentPath != null && _restoredContactFolderMap.TryGetValue(parentPath, out var mappedParentId))
{
parentId = mappedParentId;
}
try
{
// Check if folder exists
var existingFolder = await ContactApi.GetContactFolderByNameAsync(userId, displayName, parentId, cancel);
if (existingFolder != null)
{
_restoredContactFolderMap[originalPath] = existingFolder.Id;
}
else
{
var newFolder = await ContactApi.CreateContactFolderAsync(userId, displayName, parentId, cancel);
_restoredContactFolderMap[originalPath] = newFolder.Id;
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreContactFoldersFailed", ex, $"Failed to restore folder {displayName} at {originalPath}");
}
}
}
private async Task RestoreContacts(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var contacts = GetMetadataByType(SourceItemType.UserContact);
if (contacts.Count == 0)
return;
(var userId, var defaultFolderId) = await ContactRestore.GetUserIdAndContactFolderTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(defaultFolderId))
return; // A warning has already been logged
foreach (var contact in contacts)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = contact.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactsMissingContent", null, $"Missing contact content for {originalPath}, skipping.");
continue;
}
// Determine target folder
var targetFolderId = defaultFolderId;
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
if (parentPath != null && _restoredContactFolderMap.TryGetValue(parentPath, out var mappedFolderId))
{
targetFolderId = mappedFolderId;
}
if (!_ignoreExisting)
{
using (var checkStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
var contactData = await JsonSerializer.DeserializeAsync<GraphContact>(checkStream, cancellationToken: cancel);
if (contactData != null)
{
string? email = null;
if (contactData.EmailAddresses != null && contactData.EmailAddresses.Count > 0)
{
email = contactData.EmailAddresses[0].Address;
}
var displayName = contactData.DisplayName ?? contact.Value.GetValueOrDefault("o365:DisplayName");
if (!string.IsNullOrWhiteSpace(email) || !string.IsNullOrWhiteSpace(displayName))
{
var existing = await ContactApi.FindContactsAsync(userId, targetFolderId, email, displayName, cancel);
if (existing.Count > 0)
{
Log.WriteInformationMessage(LOGTAG, "RestoreContactsSkipDuplicate", $"Skipping duplicate contact {originalPath} (Name: {displayName}, Email: {email})");
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var cFile);
cFile?.Dispose();
continue;
}
}
}
}
}
GraphContact? restoredContact = null;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
restoredContact = await ContactApi.RestoreContactToFolderAsync(userId, targetFolderId, contentStream, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
// Restore photo if exists
var photoPath = ReplaceExtension(originalPath, ".json", ".photo");
if (originalPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && _temporaryFiles.TryGetValue(photoPath, out var photoEntry))
{
try
{
using (var photoStream = SystemIO.IO_OS.FileOpenRead(photoEntry))
{
// May have stored empty photo
if (photoStream.Length > 0)
await ContactApi.RestoreContactPhotoAsync(userId, restoredContact.Id, targetFolderId, photoStream, cancel);
}
_temporaryFiles.TryRemove(photoPath, out var photoFile);
photoFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactPhotoFailed", ex, $"Failed to restore photo for contact {originalPath}");
}
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreContactsFailed", ex, $"Failed to restore contact {contact.Key}");
}
}
}
private async Task RestoreContactGroups(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var groups = GetMetadataByType(SourceItemType.UserContactGroup);
if (groups.Count == 0)
return;
(var userId, var defaultFolderId) = await ContactRestore.GetUserIdAndContactFolderTarget(cancel);
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(defaultFolderId))
return;
foreach (var group in groups)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = group.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupsMissingContent", null, $"Missing content for {originalPath}, skipping.");
continue;
}
// Determine target folder
var targetFolderId = defaultFolderId;
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
if (parentPath != null && _restoredContactFolderMap.TryGetValue(parentPath, out var mappedFolderId))
targetFolderId = mappedFolderId;
GraphContactGroup? restoredGroup = null;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
var groupData = await JsonSerializer.DeserializeAsync<GraphContactGroup>(contentStream, cancellationToken: cancel);
if (groupData == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupsInvalidContent", null, $"Invalid content for {originalPath}, skipping.");
continue;
}
var displayName = groupData.DisplayName ?? group.Value.GetValueOrDefault("o365:DisplayName") ?? "Restored Group";
if (!_ignoreExisting)
{
var existing = await ContactApi.FindContactGroupsAsync(userId, targetFolderId, displayName, cancel);
if (existing.Count > 0)
{
Log.WriteInformationMessage(LOGTAG, "RestoreContactGroupsSkipDuplicate", $"Skipping duplicate contact group {originalPath} (Name: {displayName})");
restoredGroup = existing[0];
}
}
if (restoredGroup == null)
restoredGroup = await ContactApi.CreateContactGroupAsync(userId, displayName, targetFolderId, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
// Restore members
var membersPath = ReplaceExtension(originalPath, ".json", ".members.json");
if (_temporaryFiles.TryGetValue(membersPath, out var membersEntry))
{
try
{
using (var membersStream = SystemIO.IO_OS.FileOpenRead(membersEntry))
{
var members = await JsonSerializer.DeserializeAsync<List<GraphContact>>(membersStream, cancellationToken: cancel);
if (members != null)
{
foreach (var member in members)
{
var contactEmail = member.EmailAddresses?.FirstOrDefault();
if (contactEmail == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupMemberMissingEmail", null, $"Member {member.DisplayName} has no email address, skipping.");
continue;
}
var contacts = await ContactApi.FindContactsAsync(userId, targetFolderId, contactEmail.Address, member.DisplayName, cancel);
if (contacts.Count > 0)
{
try
{
var address = new GraphEmailAddress
{
Address = contactEmail.Address,
Name = member.DisplayName
};
await ContactApi.AddContactGroupMemberAsync(userId, targetFolderId, restoredGroup.Id, address, cancel);
}
catch (Exception ex)
{
// Member might already exist
Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupMemberFailed", ex, $"Failed to add member {member.DisplayName} to group {restoredGroup.DisplayName}");
}
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupMemberNotFound", null, $"Could not find contact for member {member.DisplayName} ({contactEmail}) in target folder.");
}
}
}
}
_temporaryFiles.TryRemove(membersPath, out var membersFile);
membersFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupMembersFailed", ex, $"Failed to restore members for group {originalPath}");
}
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreContactGroupsFailed", ex, $"Failed to restore contact group {group.Key}");
}
}
}
private async Task RestorePlannerBuckets(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var buckets = GetMetadataByType(SourceItemType.PlannerBucket);
if (buckets.Count == 0)
return;
string? targetPlanId = await PlannerRestore.GetTargetPlanId(cancel);
var existingBucketsByPlan = new Dictionary<string, List<GraphPlannerBucket>>();
foreach (var bucket in buckets)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = bucket.Key;
var metadata = bucket.Value;
var name = metadata.GetValueOrDefault("o365:Name") ?? "Restored Bucket";
var originalBucketId = metadata.GetValueOrDefault("o365:Id");
// Determine Plan ID
var planId = targetPlanId;
if (string.IsNullOrWhiteSpace(planId))
{
// Try to extract from path: .../PlanId/buckets/BucketId
var parent = Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)); // .../PlanId/buckets
if (parent != null)
{
var grandParent = Path.GetDirectoryName(parent); // .../PlanId
if (grandParent != null)
{
planId = Path.GetFileName(grandParent);
}
}
}
if (string.IsNullOrWhiteSpace(planId))
{
Log.WriteWarningMessage(LOGTAG, "RestorePlannerBucketsMissingPlanId", null, $"Missing target plan ID for bucket {originalPath}, skipping.");
continue;
}
if (!_ignoreExisting)
{
if (!existingBucketsByPlan.TryGetValue(planId, out var existingBuckets))
{
existingBuckets = await PlannerApi.GetPlannerBucketsAsync(planId, cancel);
existingBucketsByPlan[planId] = existingBuckets;
}
if (existingBuckets.Any(b => b.Name == name))
{
Log.WriteInformationMessage(LOGTAG, "RestorePlannerBucketsSkipDuplicate", $"Skipping duplicate bucket {originalPath} (Name: {name})");
// Map existing bucket ID so tasks can find it
var existingBucket = existingBuckets.First(b => b.Name == name);
_restoredPlannerBucketMap[originalPath] = existingBucket.Id;
if (!string.IsNullOrWhiteSpace(originalBucketId))
_restoredPlannerBucketMap[originalBucketId] = existingBucket.Id;
_metadata.TryRemove(originalPath, out _);
continue;
}
}
var newBucket = await PlannerApi.CreatePlannerBucketAsync(planId, name, null, cancel);
_restoredPlannerBucketMap[originalPath] = newBucket.Id;
if (!string.IsNullOrWhiteSpace(originalBucketId))
_restoredPlannerBucketMap[originalBucketId] = newBucket.Id;
_metadata.TryRemove(originalPath, out _);
if (!_ignoreExisting && existingBucketsByPlan.TryGetValue(planId, out var currentBuckets))
{
currentBuckets.Add(newBucket);
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestorePlannerBucketsFailed", ex, $"Failed to restore bucket {bucket.Key}");
}
}
}
private async Task RestorePlannerTasks(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var tasks = GetMetadataByType(SourceItemType.UserPlannerTasks);
if (tasks.Count == 0)
return;
string? targetPlanId = await PlannerRestore.GetTargetPlanId(cancel);
var existingTasksByPlan = new Dictionary<string, List<GraphPlannerTask>>();
foreach (var task in tasks)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = task.Key;
var metadata = task.Value;
var title = metadata.GetValueOrDefault("o365:Name") ?? "Restored Task";
var originalBucketId = metadata.GetValueOrDefault("o365:BucketId");
// Determine Plan ID
var planId = targetPlanId;
if (string.IsNullOrWhiteSpace(planId))
{
// Try to extract from path: .../PlanId/tasks/TaskId
var parent = Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)); // .../PlanId/tasks
if (parent != null)
{
var grandParent = Path.GetDirectoryName(parent); // .../PlanId
if (grandParent != null)
{
planId = Path.GetFileName(grandParent);
}
}
}
if (string.IsNullOrWhiteSpace(planId))
{
Log.WriteWarningMessage(LOGTAG, "RestorePlannerTasksMissingPlanId", null, $"Missing target plan ID for task {originalPath}, skipping.");
continue;
}
if (!_ignoreExisting)
{
if (!existingTasksByPlan.TryGetValue(planId, out var existingTasks))
{
existingTasks = await PlannerApi.GetPlannerTasksAsync(planId, cancel);
existingTasksByPlan[planId] = existingTasks;
}
if (existingTasks.Any(t => t.Title == title))
{
Log.WriteInformationMessage(LOGTAG, "RestorePlannerTasksSkipDuplicate", $"Skipping duplicate task {originalPath} (Title: {title})");
_metadata.TryRemove(originalPath, out _);
// Clean up temp files
var dupTaskJsonPath = SystemIO.IO_OS.PathCombine(originalPath, "task.json");
_temporaryFiles.TryRemove(dupTaskJsonPath, out var tFile);
tFile?.Dispose();
var dupContentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
_temporaryFiles.TryRemove(dupContentPath, out var cFile);
cFile?.Dispose();
continue;
}
}
// Determine Bucket ID
string? bucketId = null;
if (!string.IsNullOrWhiteSpace(originalBucketId))
_restoredPlannerBucketMap.TryGetValue(originalBucketId, out bucketId);
if (string.IsNullOrWhiteSpace(bucketId))
bucketId = await PlannerRestore.GetTargetBucketId(cancel);
// Read task.json if available
var taskJsonPath = SystemIO.IO_OS.PathCombine(originalPath, "task.json");
var taskJsonEntry = _temporaryFiles.GetValueOrDefault(taskJsonPath);
GraphPlannerTask? taskData = null;
if (taskJsonEntry != null)
{
using (var stream = SystemIO.IO_OS.FileOpenRead(taskJsonEntry))
{
taskData = await JsonSerializer.DeserializeAsync<GraphPlannerTask>(stream, cancellationToken: cancel);
}
_metadata.TryRemove(taskJsonPath, out _);
_temporaryFiles.TryRemove(taskJsonPath, out var taskJsonFile);
taskJsonFile?.Dispose();
}
if (title == "Conduct needs analysis")
{
Console.WriteLine("Debug");
}
var newTask = await PlannerApi.CreatePlannerTaskAsync(
planId,
bucketId,
title,
taskData?.StartDateTime,
taskData?.DueDateTime,
taskData?.PercentComplete,
taskData?.Priority,
taskData?.Assignments,
taskData?.AppliedCategories,
cancel);
if (!_ignoreExisting && existingTasksByPlan.TryGetValue(planId, out var currentTasks))
{
currentTasks.Add(newTask);
}
// Restore details
var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
var contentEntry = _temporaryFiles.GetValueOrDefault(contentPath);
if (contentEntry != null)
{
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
await PlannerApi.UpdatePlannerTaskDetailsAsync(newTask.Id, contentStream, cancel);
}
_metadata.TryRemove(contentPath, out _);
_temporaryFiles.TryRemove(contentPath, out var contentFile);
contentFile?.Dispose();
}
_metadata.TryRemove(originalPath, out _);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestorePlannerTasksFailed", ex, $"Failed to restore task {task.Key}");
}
}
}
private async Task RestoreUserMailboxSettings(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var settings = GetMetadataByType(SourceItemType.UserMailboxSettings);
if (settings.Count == 0)
return;
(var userId, _) = await EmailRestore.GetUserIdAndMailboxTarget(cancel);
if (string.IsNullOrWhiteSpace(userId))
return;
foreach (var setting in settings)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = setting.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreUserMailboxSettingsMissingContent", null, $"Missing content for {originalPath}, skipping.");
continue;
}
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
await EmailRestore.RestoreMailboxSettings(userId, contentStream, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreUserMailboxSettingsFailed", ex, $"Failed to restore mailbox settings {setting.Key}");
}
}
}
private async Task RestoreUserMailboxRules(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var rules = GetMetadataByType(SourceItemType.UserMailboxRule);
if (rules.Count == 0)
return;
(var userId, _) = await EmailRestore.GetUserIdAndMailboxTarget(cancel);
if (string.IsNullOrWhiteSpace(userId))
return;
foreach (var rule in rules)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = rule.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreUserMailboxRulesMissingContent", null, $"Missing content for {originalPath}, skipping.");
continue;
}
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
await EmailRestore.RestoreMessageRule(userId, contentStream, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreUserMailboxRulesFailed", ex, $"Failed to restore mailbox rule {rule.Key}");
}
}
}
}