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