mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 19:47:22 +00:00
Added backups of all data, restorable on another computer (#1008)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
ab45580473
commit
936043319c
144 files changed
+10417
-424
No files matched your search
@@ -0,0 +1,539 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.ChatArchive;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the chats of a backup to the chats there are, and leaves everything else alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The other way to use a backup, next to restoring it: a restore replaces all data, while this keeps
|
||||
/// every chat and adds those of the backup. A chat which exists already is kept, or added once more,
|
||||
/// as the user decides.<br/><br/>
|
||||
///
|
||||
/// The backup is opened for reading only, and every entry is read as a stream, cf. DataBackupReader.
|
||||
/// </remarks>
|
||||
public static class ChatArchiveImporter
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(ChatArchiveImporter));
|
||||
|
||||
private const int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatArchiveImporter).Namespace, nameof(ChatArchiveImporter));
|
||||
|
||||
/// <summary>
|
||||
/// One entry of a backup, with its name checked and normalized.
|
||||
/// </summary>
|
||||
private sealed record BackupEntry(string Name, ZipArchiveEntry Entry);
|
||||
|
||||
/// <summary>
|
||||
/// What one import did so far.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every counter is raised as the import goes: a cancellation leaves the chats imported so far in
|
||||
/// the storage, so the numbers have to describe them even when the import never reaches its end.
|
||||
/// </remarks>
|
||||
private sealed class ImportTally
|
||||
{
|
||||
public int ProcessedChats;
|
||||
public int ImportedWorkspaces;
|
||||
public int ImportedChats;
|
||||
public int SkippedChats;
|
||||
public int FailedChats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads which chats a backup holds, without adding any.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The backup to look into.</param>
|
||||
/// <param name="token">Cancels the reading.</param>
|
||||
/// <returns>The chats of the backup, or why the backup cannot be used.</returns>
|
||||
public static async Task<ChatArchiveImportPreview> ReadPreviewAsync(string archivePath, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(archivePath);
|
||||
var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token);
|
||||
if (manifestResult.Manifest is not { } manifest)
|
||||
return new(false, new(), manifestResult.Issue);
|
||||
|
||||
var chats = await ListChatsAsync(archive, GroupEntriesByChat(archive), token);
|
||||
return new(true, chats with { CreatedAt = manifest.CreatedAtUtc, AppVersion = manifest.AppVersion }, string.Empty);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return new(false, new(), TB("This file is no backup of AI Studio, or it was not written completely."));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LOG.LogError(exception, "Failed to read the backup '{ArchivePath}'.", archivePath);
|
||||
return new(false, new(), string.Format(TB("Unexpected error: {0}"), exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all chats of a backup to the chat storage.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The backup.</param>
|
||||
/// <param name="collisionBehavior">What to do with chats which exist already.</param>
|
||||
/// <param name="progress">Receives the progress.</param>
|
||||
/// <param name="token">Cancels the import. Chats added before the cancellation are kept.</param>
|
||||
/// <returns>What the import did.</returns>
|
||||
public static async Task<ChatArchiveImportResult> ImportAsync(string archivePath, ChatArchiveCollisionBehavior collisionBehavior, IProgress<ChatArchiveProgress>? progress, CancellationToken token)
|
||||
{
|
||||
var tally = new ImportTally();
|
||||
var preview = await ReadPreviewAsync(archivePath, token);
|
||||
if (!preview.Success)
|
||||
return new(false, 0, 0, 0, 0, preview.Issue, false);
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = preview.Manifest;
|
||||
using var archive = ZipFile.OpenRead(archivePath);
|
||||
var entriesByChat = GroupEntriesByChat(archive);
|
||||
|
||||
// Which workspaces exist already is asked once per workspace of the backup, so read
|
||||
// the tree once instead of building a full snapshot every time:
|
||||
var tree = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
|
||||
var knownWorkspaceIds = tree.Workspaces.Select(workspace => workspace.WorkspaceId).ToHashSet();
|
||||
|
||||
//
|
||||
// A chat counts as existing wherever it is stored, not just in the workspace the backup
|
||||
// names: it may have been moved since the backup was made. Adding it there again under
|
||||
// the same identity would leave two chats with one ID, while the app finds a chat, and
|
||||
// the answer being written for it, by that ID alone:
|
||||
//
|
||||
var existingChatIds = tree.TemporaryChats.Select(chat => chat.ChatId).ToHashSet();
|
||||
foreach (var workspace in tree.Workspaces)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
foreach (var chat in await WorkspaceBehaviour.GetWorkspaceChatsAsync(workspace.WorkspaceId))
|
||||
existingChatIds.Add(chat.ChatId);
|
||||
}
|
||||
|
||||
foreach (var workspace in manifest.Workspaces)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var targetWorkspaceId = await ResolveTargetWorkspaceAsync(workspace, knownWorkspaceIds);
|
||||
if (targetWorkspaceId == Guid.Empty)
|
||||
{
|
||||
LOG.LogWarning("Skipping workspace '{WorkspaceName}' of the backup, because no target workspace could be prepared.", workspace.Name);
|
||||
tally.FailedChats += workspace.Chats.Count;
|
||||
tally.ProcessedChats += workspace.Chats.Count;
|
||||
continue;
|
||||
}
|
||||
|
||||
var workspaceEntryPrefix = $"{ChatArchiveFormat.WORKSPACES_PREFIX}{workspace.WorkspaceId}{ZipEntryNames.SEPARATOR}";
|
||||
await ImportChatsAsync(tally, workspaceEntryPrefix, targetWorkspaceId, workspace.Chats, true, manifest.TotalChatCount, entriesByChat, collisionBehavior, existingChatIds, progress, token);
|
||||
}
|
||||
|
||||
await ImportChatsAsync(tally, ChatArchiveFormat.TEMPORARY_CHATS_PREFIX, Guid.Empty, manifest.TemporaryChats, false, manifest.TotalChatCount, entriesByChat, collisionBehavior, existingChatIds, progress, token);
|
||||
|
||||
progress?.Report(new(tally.ProcessedChats, manifest.TotalChatCount));
|
||||
LOG.LogInformation("Imported {ImportedChats} chats from the backup '{ArchivePath}'. Skipped {SkippedChats} existing chats, {FailedChats} chats failed.", tally.ImportedChats, archivePath, tally.SkippedChats, tally.FailedChats);
|
||||
|
||||
return new(true, tally.ImportedWorkspaces, tally.ImportedChats, tally.SkippedChats, tally.FailedChats, string.Empty, false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//
|
||||
// The chats imported so far stay in the chat storage. So report what happened instead
|
||||
// of throwing, otherwise the user has no idea what is now part of their chats:
|
||||
//
|
||||
LOG.LogInformation("The user cancelled the import of the backup '{ArchivePath}' after {ImportedChats} chats.", archivePath, tally.ImportedChats);
|
||||
return new(true, tally.ImportedWorkspaces, tally.ImportedChats, tally.SkippedChats, tally.FailedChats, string.Empty, true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LOG.LogError(exception, "Failed to import the backup '{ArchivePath}'.", archivePath);
|
||||
return new(false, tally.ImportedWorkspaces, tally.ImportedChats, tally.SkippedChats, tally.FailedChats, string.Format(TB("Unexpected error: {0}"), exception.Message), false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the chats of one workspace of the backup, or its temporary chats.
|
||||
/// </summary>
|
||||
private static async Task ImportChatsAsync(ImportTally tally, string entryPrefix, Guid targetWorkspaceId, IReadOnlyList<ChatArchiveManifestChat> chats, bool countsAsWorkspace, int totalChats, Dictionary<string, List<BackupEntry>> entriesByChat, ChatArchiveCollisionBehavior collisionBehavior, HashSet<Guid> existingChatIds, IProgress<ChatArchiveProgress>? progress, CancellationToken token)
|
||||
{
|
||||
var isFirstImportOfWorkspace = true;
|
||||
foreach (var chat in chats)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
progress?.Report(new(tally.ProcessedChats, totalChats));
|
||||
tally.ProcessedChats++;
|
||||
|
||||
var chatEntryPrefix = $"{entryPrefix}{chat.ChatId}{ZipEntryNames.SEPARATOR}";
|
||||
var chatEntries = entriesByChat.GetValueOrDefault(chatEntryPrefix, []);
|
||||
|
||||
switch (await ImportChatAsync(chatEntries, chatEntryPrefix, targetWorkspaceId, chat, collisionBehavior, existingChatIds, token))
|
||||
{
|
||||
case ChatArchiveImportOutcome.IMPORTED:
|
||||
tally.ImportedChats++;
|
||||
if (countsAsWorkspace && isFirstImportOfWorkspace)
|
||||
{
|
||||
tally.ImportedWorkspaces++;
|
||||
isFirstImportOfWorkspace = false;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ChatArchiveImportOutcome.SKIPPED:
|
||||
tally.SkippedChats++;
|
||||
break;
|
||||
|
||||
case ChatArchiveImportOutcome.FAILED:
|
||||
tally.FailedChats++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the entries of a backup by the chat they belong to. Without this, every chat would have
|
||||
/// to scan all entries of the backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An entry whose name could lead outside of the chat directory is left out, cf. ZipEntryNames.
|
||||
/// </remarks>
|
||||
private static Dictionary<string, List<BackupEntry>> GroupEntriesByChat(ZipArchive archive)
|
||||
{
|
||||
var entriesByChat = new Dictionary<string, List<BackupEntry>>(StringComparer.Ordinal);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!ZipEntryNames.TryNormalize(entry.FullName, out var entryName))
|
||||
{
|
||||
LOG.LogWarning("Ignoring the backup entry '{EntryName}', because its name could lead outside of the chat directory.", entry.FullName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip directory entries:
|
||||
if (entryName.EndsWith(ZipEntryNames.SEPARATOR))
|
||||
continue;
|
||||
|
||||
var chatEntryPrefix = GetChatEntryPrefix(entryName);
|
||||
if (chatEntryPrefix is null)
|
||||
continue;
|
||||
|
||||
if (!entriesByChat.TryGetValue(chatEntryPrefix, out var chatEntries))
|
||||
{
|
||||
chatEntries = [];
|
||||
entriesByChat[chatEntryPrefix] = chatEntries;
|
||||
}
|
||||
|
||||
chatEntries.Add(new(entryName, entry));
|
||||
}
|
||||
|
||||
return entriesByChat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the chat an entry belongs to: everything up to and including the ID of the chat, so
|
||||
/// "data/workspaces/W/C/thread.json" becomes "data/workspaces/W/C/".
|
||||
/// </summary>
|
||||
/// <returns>The chat prefix, or null when the entry belongs to no chat.</returns>
|
||||
private static string? GetChatEntryPrefix(string entryName)
|
||||
{
|
||||
string root;
|
||||
int levels;
|
||||
if (entryName.StartsWith(ChatArchiveFormat.WORKSPACES_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
root = ChatArchiveFormat.WORKSPACES_PREFIX;
|
||||
levels = 2;
|
||||
}
|
||||
else if (entryName.StartsWith(ChatArchiveFormat.TEMPORARY_CHATS_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
root = ChatArchiveFormat.TEMPORARY_CHATS_PREFIX;
|
||||
levels = 1;
|
||||
}
|
||||
else
|
||||
return null;
|
||||
|
||||
var index = root.Length;
|
||||
for (var level = 0; level < levels; level++)
|
||||
{
|
||||
var next = entryName.IndexOf(ZipEntryNames.SEPARATOR, index);
|
||||
if (next < 0)
|
||||
return null;
|
||||
|
||||
index = next + 1;
|
||||
}
|
||||
|
||||
return entryName[..index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists the chats of a backup, with the names of their workspaces.
|
||||
/// </summary>
|
||||
private static async Task<ChatArchiveManifest> ListChatsAsync(ZipArchive archive, Dictionary<string, List<BackupEntry>> entriesByChat, CancellationToken token)
|
||||
{
|
||||
var workspaces = new Dictionary<Guid, ChatArchiveManifestWorkspace>();
|
||||
var temporaryChats = new List<ChatArchiveManifestChat>();
|
||||
foreach (var (chatEntryPrefix, chatEntries) in entriesByChat)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// A chat without its thread cannot be added, so it is not listed either:
|
||||
if (!chatEntries.Any(entry => entry.Name.Equals($"{chatEntryPrefix}{ChatArchiveFormat.THREAD_FILE_NAME}", StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
var segments = chatEntryPrefix.TrimEnd(ZipEntryNames.SEPARATOR).Split(ZipEntryNames.SEPARATOR);
|
||||
if (!Guid.TryParse(segments[^1], out var chatId))
|
||||
continue;
|
||||
|
||||
var nameEntry = chatEntries.FirstOrDefault(entry => entry.Name.Equals($"{chatEntryPrefix}{ChatArchiveFormat.NAME_FILE_NAME}", StringComparison.OrdinalIgnoreCase));
|
||||
var chat = new ChatArchiveManifestChat
|
||||
{
|
||||
ChatId = chatId,
|
||||
Name = nameEntry is null ? string.Empty : await ReadTextAsync(nameEntry.Entry, token),
|
||||
};
|
||||
|
||||
if (chatEntryPrefix.StartsWith(ChatArchiveFormat.TEMPORARY_CHATS_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
temporaryChats.Add(chat);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(segments[^2], out var workspaceId))
|
||||
continue;
|
||||
|
||||
if (!workspaces.TryGetValue(workspaceId, out var workspace))
|
||||
{
|
||||
var workspaceNameEntry = archive.GetEntry($"{ChatArchiveFormat.WORKSPACES_PREFIX}{workspaceId}{ZipEntryNames.SEPARATOR}{ChatArchiveFormat.NAME_FILE_NAME}");
|
||||
workspace = new ChatArchiveManifestWorkspace
|
||||
{
|
||||
WorkspaceId = workspaceId,
|
||||
Name = workspaceNameEntry is null ? string.Empty : await ReadTextAsync(workspaceNameEntry, token),
|
||||
};
|
||||
|
||||
workspaces[workspaceId] = workspace;
|
||||
}
|
||||
|
||||
workspace.Chats.Add(chat);
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Workspaces = workspaces.Values.OrderBy(workspace => workspace.Name, StringComparer.CurrentCultureIgnoreCase).ToList(),
|
||||
TemporaryChats = temporaryChats,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> ReadTextAsync(ZipArchiveEntry entry, CancellationToken token)
|
||||
{
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
return (await reader.ReadToEndAsync(token)).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the workspace which receives the chats of a workspace of the backup. Known
|
||||
/// workspaces are reused, unknown ones are created with their original identity.
|
||||
/// </summary>
|
||||
/// <param name="workspace">The workspace of the backup.</param>
|
||||
/// <param name="knownWorkspaceIds">The workspaces which exist already. Newly created ones are added.</param>
|
||||
private static async Task<Guid> ResolveTargetWorkspaceAsync(ChatArchiveManifestWorkspace workspace, HashSet<Guid> knownWorkspaceIds)
|
||||
{
|
||||
if (workspace.WorkspaceId == Guid.Empty)
|
||||
return Guid.Empty;
|
||||
|
||||
if (knownWorkspaceIds.Contains(workspace.WorkspaceId))
|
||||
return workspace.WorkspaceId;
|
||||
|
||||
var workspaceName = string.IsNullOrWhiteSpace(workspace.Name) ? TB("Imported workspace") : workspace.Name;
|
||||
await WorkspaceBehaviour.EnsureWorkspace(workspace.WorkspaceId, await CreateUniqueWorkspaceNameAsync(workspaceName));
|
||||
knownWorkspaceIds.Add(workspace.WorkspaceId);
|
||||
|
||||
return workspace.WorkspaceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extends the workspace name by a counter until it is no longer taken. Workspace names
|
||||
/// must be unique, otherwise the workspace cannot be renamed later on.
|
||||
/// </summary>
|
||||
private static async Task<string> CreateUniqueWorkspaceNameAsync(string workspaceName)
|
||||
{
|
||||
var candidate = workspaceName;
|
||||
var counter = 1;
|
||||
|
||||
while (await WorkspaceBehaviour.IsWorkspaceNameExistingAsync(candidate))
|
||||
{
|
||||
counter++;
|
||||
candidate = $"{workspaceName} ({counter})";
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports one chat: extracts its files into a fresh chat directory, points the paths of
|
||||
/// those files to it, and stores the chat.
|
||||
/// </summary>
|
||||
/// <param name="chatEntries">The backup entries of the chat.</param>
|
||||
/// <param name="chatEntryPrefix">The path of the chat within the backup, which every entry of the chat starts with.</param>
|
||||
/// <param name="targetWorkspaceId">The workspace to import the chat into, or Guid.Empty for a temporary chat.</param>
|
||||
/// <param name="chat">The chat as the preview lists it.</param>
|
||||
/// <param name="collisionBehavior">What to do when a chat with the same ID exists already.</param>
|
||||
/// <param name="existingChatIds">The IDs of all chats in the storage. An imported chat is added.</param>
|
||||
/// <param name="token">Cancels the import.</param>
|
||||
private static async Task<ChatArchiveImportOutcome> ImportChatAsync(IReadOnlyList<BackupEntry> chatEntries, string chatEntryPrefix, Guid targetWorkspaceId, ChatArchiveManifestChat chat, ChatArchiveCollisionBehavior collisionBehavior, HashSet<Guid> existingChatIds, CancellationToken token)
|
||||
{
|
||||
var threadEntry = chatEntries.FirstOrDefault(entry => entry.Name.Equals($"{chatEntryPrefix}{ChatArchiveFormat.THREAD_FILE_NAME}", StringComparison.OrdinalIgnoreCase));
|
||||
if (threadEntry is null)
|
||||
{
|
||||
LOG.LogWarning("The backup contains no chat thread for the chat '{ChatId}'.", chat.ChatId);
|
||||
return ChatArchiveImportOutcome.FAILED;
|
||||
}
|
||||
|
||||
//
|
||||
// The directory is checked as well: a chat the storage could not list, for example
|
||||
// because its thread is unreadable, still occupies its place on disk.
|
||||
//
|
||||
var targetChatId = chat.ChatId;
|
||||
if (existingChatIds.Contains(targetChatId) || WorkspaceBehaviour.IsChatExisting(new(targetWorkspaceId, targetChatId)))
|
||||
{
|
||||
if (collisionBehavior is ChatArchiveCollisionBehavior.SKIP)
|
||||
return ChatArchiveImportOutcome.SKIPPED;
|
||||
|
||||
targetChatId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
var chatDirectory = WorkspaceBehaviour.GetChatDirectory(targetWorkspaceId, targetChatId);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(chatDirectory);
|
||||
|
||||
//
|
||||
// Extract everything except the chat thread and the name file. Both are written
|
||||
// by the chat storage itself once the paths inside the thread are corrected:
|
||||
//
|
||||
var extractedPaths = new HashSet<string>(PathTools.COMPARER);
|
||||
foreach (var entry in chatEntries)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = entry.Name[chatEntryPrefix.Length..];
|
||||
if (relativePath.Equals(ChatArchiveFormat.THREAD_FILE_NAME, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.Equals(ChatArchiveFormat.NAME_FILE_NAME, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var destinationPath = ZipEntryNames.GetDestinationPath(chatDirectory, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
|
||||
await using var entryStream = entry.Entry.Open();
|
||||
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await entryStream.CopyToAsync(fileStream, token);
|
||||
|
||||
extractedPaths.Add(relativePath);
|
||||
}
|
||||
|
||||
ChatThread? thread;
|
||||
await using (var threadStream = threadEntry.Entry.Open())
|
||||
thread = await JsonSerializer.DeserializeAsync<ChatThread>(threadStream, WorkspaceBehaviour.JSON_OPTIONS, token);
|
||||
|
||||
if (thread is null)
|
||||
{
|
||||
LOG.LogWarning("The chat thread of the chat '{ChatId}' could not be read.", chat.ChatId);
|
||||
TryDeleteDirectory(chatDirectory);
|
||||
return ChatArchiveImportOutcome.FAILED;
|
||||
}
|
||||
|
||||
//
|
||||
// A file below the data directory is stored with a path relative to it, which reading
|
||||
// the thread resolved against the data directory of this computer: where the chat lay
|
||||
// when the backup was made. Only the paths of files which were actually extracted move
|
||||
// to the imported chat. Everything else keeps its path:
|
||||
//
|
||||
var originalChatDirectory = WorkspaceBehaviour.GetChatDirectory(thread.WorkspaceId, thread.ChatId);
|
||||
thread = thread with { ChatId = targetChatId };
|
||||
thread.WorkspaceId = targetWorkspaceId;
|
||||
thread.RewriteFilePaths(path =>
|
||||
{
|
||||
var chatRelativePath = Path.GetRelativePath(originalChatDirectory, path).Replace(Path.DirectorySeparatorChar, ZipEntryNames.SEPARATOR);
|
||||
return extractedPaths.Contains(chatRelativePath)
|
||||
? Path.Combine(chatDirectory, chatRelativePath.Replace(ZipEntryNames.SEPARATOR, Path.DirectorySeparatorChar))
|
||||
: path;
|
||||
});
|
||||
|
||||
CopyTranscriptsStoredElsewhere(thread, chatDirectory);
|
||||
|
||||
//
|
||||
// The storage skips a chat whose lock another operation holds for too long. The
|
||||
// directory prepared above would then hold the extracted files but no chat:
|
||||
//
|
||||
if (!await WorkspaceBehaviour.StoreChatAsync(thread))
|
||||
{
|
||||
LOG.LogWarning("The chat '{ChatId}' could not be stored, because another operation held it for too long.", chat.ChatId);
|
||||
TryDeleteDirectory(chatDirectory);
|
||||
return ChatArchiveImportOutcome.FAILED;
|
||||
}
|
||||
|
||||
existingChatIds.Add(targetChatId);
|
||||
return ChatArchiveImportOutcome.IMPORTED;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryDeleteDirectory(chatDirectory);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LOG.LogError(exception, "Failed to import the chat '{ChatId}' into the workspace '{WorkspaceId}'.", chat.ChatId, targetWorkspaceId);
|
||||
TryDeleteDirectory(chatDirectory);
|
||||
return ChatArchiveImportOutcome.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives the imported chat its own copy of every transcript the backup did not carry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Such a transcript still names the place where it lay when the backup was made. On the
|
||||
/// same computer that is the chat the backup came from, and since a chat deletes the
|
||||
/// transcripts it names, deleting the imported chat would take the transcripts of the
|
||||
/// original along. A copied chat gets its own copies for the same reason, so this uses the
|
||||
/// same code. A transcript which no longer exists keeps its place in the chat and shows up
|
||||
/// as missing, again as it does in a copy.
|
||||
/// </remarks>
|
||||
/// <param name="thread">The imported chat thread, after the paths of its extracted files were pointed to it.</param>
|
||||
/// <param name="chatDirectory">The directory of the imported chat.</param>
|
||||
private static void CopyTranscriptsStoredElsewhere(ChatThread thread, string chatDirectory)
|
||||
{
|
||||
var transcriptDirectory = ManagedTranscriptAttachment.GetTranscriptDirectory(chatDirectory);
|
||||
var copiedPaths = new Dictionary<string, ManagedTranscriptAttachment>(PathTools.COMPARER);
|
||||
|
||||
//
|
||||
// Only a transcript in the data directory can belong to another chat, and only there
|
||||
// does deleting a chat delete its transcripts. Any other path is left alone, so that a
|
||||
// backup cannot get files from elsewhere on this computer copied into a chat:
|
||||
//
|
||||
thread.ReplaceAttachments(attachment => attachment is ManagedTranscriptAttachment transcript
|
||||
&& !PathTools.IsInsideDirectory(chatDirectory, transcript.FilePath)
|
||||
&& PathTools.IsInsideDirectory(SettingsManager.DataDirectory!, transcript.FilePath)
|
||||
? WorkspaceBehaviour.CopyManagedTranscriptAttachment(thread, transcript, transcriptDirectory, copiedPaths)
|
||||
: attachment);
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LOG.LogWarning(exception, "Failed to clean up the incomplete chat directory '{ChatDirectory}'.", directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user