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; /// /// Adds the chats of a backup to the chats there are, and leaves everything else alone. /// /// /// 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.

/// /// The backup is opened for reading only, and every entry is read as a stream, cf. DataBackupReader. ///
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)); /// /// One entry of a backup, with its name checked and normalized. /// private sealed record BackupEntry(string Name, ZipArchiveEntry Entry); /// /// What one import did so far. /// /// /// 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. /// private sealed class ImportTally { public int ProcessedChats; public int ImportedWorkspaces; public int ImportedChats; public int SkippedChats; public int FailedChats; } /// /// Reads which chats a backup holds, without adding any. /// /// The backup to look into. /// Cancels the reading. /// The chats of the backup, or why the backup cannot be used. public static async Task 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)); } } /// /// Adds all chats of a backup to the chat storage. /// /// The backup. /// What to do with chats which exist already. /// Receives the progress. /// Cancels the import. Chats added before the cancellation are kept. /// What the import did. public static async Task ImportAsync(string archivePath, ChatArchiveCollisionBehavior collisionBehavior, IProgress? 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); } } /// /// Adds the chats of one workspace of the backup, or its temporary chats. /// private static async Task ImportChatsAsync(ImportTally tally, string entryPrefix, Guid targetWorkspaceId, IReadOnlyList chats, bool countsAsWorkspace, int totalChats, Dictionary> entriesByChat, ChatArchiveCollisionBehavior collisionBehavior, HashSet existingChatIds, IProgress? 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; } } } /// /// 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. /// /// /// An entry whose name could lead outside of the chat directory is left out, cf. ZipEntryNames. /// private static Dictionary> GroupEntriesByChat(ZipArchive archive) { var entriesByChat = new Dictionary>(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; } /// /// 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/". /// /// The chat prefix, or null when the entry belongs to no chat. 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]; } /// /// Lists the chats of a backup, with the names of their workspaces. /// private static async Task ListChatsAsync(ZipArchive archive, Dictionary> entriesByChat, CancellationToken token) { var workspaces = new Dictionary(); var temporaryChats = new List(); 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 ReadTextAsync(ZipArchiveEntry entry, CancellationToken token) { using var reader = new StreamReader(entry.Open()); return (await reader.ReadToEndAsync(token)).Trim(); } /// /// 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. /// /// The workspace of the backup. /// The workspaces which exist already. Newly created ones are added. private static async Task ResolveTargetWorkspaceAsync(ChatArchiveManifestWorkspace workspace, HashSet 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; } /// /// 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. /// private static async Task CreateUniqueWorkspaceNameAsync(string workspaceName) { var candidate = workspaceName; var counter = 1; while (await WorkspaceBehaviour.IsWorkspaceNameExistingAsync(candidate)) { counter++; candidate = $"{workspaceName} ({counter})"; } return candidate; } /// /// Imports one chat: extracts its files into a fresh chat directory, points the paths of /// those files to it, and stores the chat. /// /// The backup entries of the chat. /// The path of the chat within the backup, which every entry of the chat starts with. /// The workspace to import the chat into, or Guid.Empty for a temporary chat. /// The chat as the preview lists it. /// What to do when a chat with the same ID exists already. /// The IDs of all chats in the storage. An imported chat is added. /// Cancels the import. private static async Task ImportChatAsync(IReadOnlyList chatEntries, string chatEntryPrefix, Guid targetWorkspaceId, ChatArchiveManifestChat chat, ChatArchiveCollisionBehavior collisionBehavior, HashSet 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(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(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; } } /// /// Gives the imported chat its own copy of every transcript the backup did not carry. /// /// /// 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. /// /// The imported chat thread, after the paths of its extracted files were pointed to it. /// The directory of the imported chat. private static void CopyTranscriptsStoredElsewhere(ChatThread thread, string chatDirectory) { var transcriptDirectory = ManagedTranscriptAttachment.GetTranscriptDirectory(chatDirectory); var copiedPaths = new Dictionary(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); } } }