mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 17:13:48 +00:00
Merge branch 'main' into pr/996
# Conflicts: # app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
This commit is contained in:
27 files changed
+1616
-109
No files matched your search
@@ -103,6 +103,57 @@ public static class FileExportFormatExtensions
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads which format a model means when it names a language behind the opening fence of a
|
||||
/// code block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A model which answers with a finished file puts it into a code block and names its language,
|
||||
/// as in ```html. Models do not agree on the spelling, so we accept the usual names of a format
|
||||
/// in any case. Only formats which are plain text appear here: a code block holds text, never
|
||||
/// a Word document.
|
||||
/// </remarks>
|
||||
/// <param name="language">The language behind the opening fence, as Markdig reads it into
|
||||
/// FencedCodeBlock.Info.</param>
|
||||
/// <param name="format">The format the language names, or NONE when it names none of ours.</param>
|
||||
/// <returns>True, when the language names a format AI Studio writes.</returns>
|
||||
public static bool TryFromCodeFenceLanguage(string? language, out FileExportFormat format)
|
||||
{
|
||||
format = language?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"html" => FileExportFormat.HTML,
|
||||
"latex" or "tex" => FileExportFormat.LATEX,
|
||||
"markdown" or "md" => FileExportFormat.MARKDOWN,
|
||||
"csv" => FileExportFormat.CSV,
|
||||
"tsv" => FileExportFormat.TSV,
|
||||
|
||||
_ => FileExportFormat.NONE,
|
||||
};
|
||||
|
||||
return format is not FileExportFormat.NONE;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the format holds a table rather than a text.
|
||||
/// </summary>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True for the formats a spreadsheet opens.</returns>
|
||||
public static bool IsTabular(this FileExportFormat format) => format is FileExportFormat.CSV or FileExportFormat.TSV;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a file of the format is plain text, which AI Studio writes as it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// That holds for a web page and a LaTeX document as well, even though an entire answer needs
|
||||
/// Pandoc to become one: the answer is Markdown, whereas a page the model wrote is a finished
|
||||
/// file already. A Word or an OpenDocument file is an archive, and only Pandoc produces one. The
|
||||
/// list is spelled out on purpose, so a format added later counts as plain text only once
|
||||
/// somebody says so.
|
||||
/// </remarks>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True, when a text written as it is makes a valid file of the format.</returns>
|
||||
public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file name the save dialog starts with.
|
||||
/// </summary>
|
||||
@@ -204,6 +255,42 @@ public static class FileExportFormatExtensions
|
||||
_ => WITHOUT_BYTE_ORDER_MARK,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a text into a comment of the format: whoever opens the file in an editor reads it,
|
||||
/// while a browser or a LaTeX run skips it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A comment in HTML, and so in Markdown, ends at the first --> it holds, and a browser takes
|
||||
/// --!> for the same; the rest of the text would spill onto the page from there. The title of
|
||||
/// a web page may hold either, so a space goes in before the bracket, which keeps the text
|
||||
/// readable and ends nothing. Every other pair of dashes stays, because a web address may carry
|
||||
/// one, as in the xn-- of a domain with an umlaut. A LaTeX comment has no end to watch for: it
|
||||
/// runs to the end of its line, so every line starts one.
|
||||
/// </remarks>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <param name="text">The text to put into the comment.</param>
|
||||
/// <param name="comment">The comment, or an empty string when the format has none.</param>
|
||||
/// <returns>True, when the format knows comments.</returns>
|
||||
public static bool TryToComment(this FileExportFormat format, string text, out string comment)
|
||||
{
|
||||
var lines = text.TrimEnd().ReplaceLineEndings("\n").Split('\n');
|
||||
switch (format)
|
||||
{
|
||||
case FileExportFormat.HTML or FileExportFormat.MARKDOWN:
|
||||
var commentText = string.Join(Environment.NewLine, lines).Replace("-->", "-- >").Replace("--!>", "--! >");
|
||||
comment = $"<!--{Environment.NewLine}{commentText}{Environment.NewLine}-->";
|
||||
return true;
|
||||
|
||||
case FileExportFormat.LATEX:
|
||||
comment = string.Join(Environment.NewLine, lines.Select(line => line.Length is 0 ? "%" : $"% {line}"));
|
||||
return true;
|
||||
|
||||
default:
|
||||
comment = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a link into a local file may name the page it points at.
|
||||
/// </summary>
|
||||
@@ -224,6 +311,19 @@ public static class FileExportFormatExtensions
|
||||
_ => true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether Pandoc has to be told the title of a document in the format.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A web page shows its title in the browser tab. Without one, Pandoc names the page after its
|
||||
/// input file, which is a temporary file of ours with a random name. Word and OpenDocument show
|
||||
/// no such title, and handed one anyway, they keep it as a document property nobody asked for;
|
||||
/// verified with Pandoc 3.8.3 on 2026-09-23. LaTeX ignores it.
|
||||
/// </remarks>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True, when a document of this format needs a title besides its content.</returns>
|
||||
public static bool NeedsPageTitle(this FileExportFormat format) => format is FileExportFormat.HTML;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name Pandoc knows the format by.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// A file found in a message, ready to be written: a table the model wrote, or a code block the
|
||||
/// model marked as a format we write.
|
||||
/// </summary>
|
||||
/// <param name="Ordinal">Which table or which code block of the message this is, counting from one
|
||||
/// within its kind; the format tells the two kinds apart, see FileExportFormatExtensions.IsTabular.
|
||||
/// This is what tells two files of one kind apart even when they carry the same heading.</param>
|
||||
/// <param name="Caption">What the file is about: the heading above it, or else the first column
|
||||
/// heading of a table. Empty for a code block without a heading above it.</param>
|
||||
/// <param name="Format">The format this content is written as.</param>
|
||||
/// <param name="Content">The finished file content.</param>
|
||||
public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content);
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// A table found in a message, ready to be written to a file.
|
||||
/// </summary>
|
||||
/// <param name="Ordinal">Which table of the message this is, counting from one. The same table
|
||||
/// appears once per format we offer for it, so this is what tells two tables apart even when they
|
||||
/// carry the same heading.</param>
|
||||
/// <param name="Caption">What the table is about, taken from its first column heading.</param>
|
||||
/// <param name="Format">The format this content is written as.</param>
|
||||
/// <param name="Content">The finished file content.</param>
|
||||
public sealed record MessageTable(int Ordinal, string Caption, FileExportFormat Format, string Content);
|
||||
@@ -43,14 +43,23 @@ public static class PandocExport
|
||||
await File.WriteAllTextAsync(tempMarkdownFilePath, markdownText, new UTF8Encoding(false), token);
|
||||
|
||||
// Call Pandoc to create the document:
|
||||
var pandoc = await PandocProcessBuilder
|
||||
var pandocBuilder = PandocProcessBuilder
|
||||
.Create()
|
||||
.UseStandaloneMode()
|
||||
.WithInputFormat("gfm+emoji+tex_math_dollars")
|
||||
.WithOutputFormat(format.ToPandocOutputFormat())
|
||||
.WithOutputFile(targetFilePath)
|
||||
.WithInputFile(tempMarkdownFilePath)
|
||||
.BuildAsync(rustService);
|
||||
.WithInputFile(tempMarkdownFilePath);
|
||||
|
||||
//
|
||||
// The document is named after the file it is written to. Set as metadata, the name
|
||||
// reaches the page as a string which Pandoc escapes; only a file named true or false
|
||||
// is read as a switch and keeps the temporary name.
|
||||
//
|
||||
if (format.NeedsPageTitle())
|
||||
pandocBuilder.AddArgument("-M").AddArgument($"pagetitle={Path.GetFileNameWithoutExtension(targetFilePath)}");
|
||||
|
||||
var pandoc = await pandocBuilder.BuildAsync(rustService);
|
||||
|
||||
using var process = Process.Start(pandoc.StartInfo);
|
||||
if (process is null)
|
||||
@@ -108,8 +117,10 @@ public static class PandocExport
|
||||
/// looking at, a chat message or the result of an assistant, so the caller names it.</param>
|
||||
/// <param name="format">The format to write. Must be a format which uses Pandoc.</param>
|
||||
/// <param name="markdownContent">The content to export.</param>
|
||||
/// <param name="fileName">What the document is about, used to suggest a name in the save dialog.
|
||||
/// Null falls back to a generic name.</param>
|
||||
/// <returns>True, when the document was written.</returns>
|
||||
public static async Task<bool> ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent)
|
||||
public static async Task<bool> ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent, string? fileName = null)
|
||||
{
|
||||
if (!format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter)
|
||||
throw new ArgumentOutOfRangeException(nameof(format), format, "Pandoc cannot write this format.");
|
||||
@@ -125,7 +136,7 @@ public static class PandocExport
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName());
|
||||
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
|
||||
if (response.UserCancelled)
|
||||
{
|
||||
LOGGER.LogInformation("User cancelled the save dialog.");
|
||||
|
||||
@@ -16,18 +16,21 @@ public static class PlainFileExport
|
||||
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PlainFileExport).Namespace, nameof(PlainFileExport));
|
||||
|
||||
/// <summary>
|
||||
/// Reads every table a message holds, in the order they appear in it.
|
||||
/// Reads every file a message holds, in the order they appear in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two kinds of tables end up in an answer. Almost always it is a Markdown table written with
|
||||
/// pipes, which is what a model produces on its own; we turn its cells into a file. Rarely a
|
||||
/// model answers with a fenced code block marked as csv or tsv, which already is the finished
|
||||
/// file: we hand that through untouched rather than taking it apart and reassembling it.
|
||||
/// Two kinds of files end up in an answer. Almost always it is a Markdown table written with
|
||||
/// pipes, which is what a model produces on its own; we turn its cells into a file. Besides,
|
||||
/// a model answers with a fenced code block marked as a format we write, such as html, latex,
|
||||
/// markdown, or csv, whenever it was asked for a web page, a document, or data. Such a block
|
||||
/// already is the finished file: we hand it through untouched rather than taking it apart and
|
||||
/// reassembling it. We do not judge what the block holds, either. A browser shows a fragment of
|
||||
/// HTML just as well as an entire page, and a LaTeX fragment is still what the user asked for.
|
||||
/// </remarks>
|
||||
/// <param name="markdown">The Markdown text of the message.</param>
|
||||
/// <param name="separator">The separator to write a Markdown table with, see CsvWriter.SeparatorFor.</param>
|
||||
/// <returns>The tables, or an empty list when the message holds none.</returns>
|
||||
public static IReadOnlyList<MessageTable> ExtractTables(string markdown, char separator)
|
||||
/// <returns>The files, or an empty list when the message holds none.</returns>
|
||||
public static IReadOnlyList<MessageFile> ExtractFiles(string markdown, char separator)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(markdown))
|
||||
return [];
|
||||
@@ -40,9 +43,10 @@ public static class PlainFileExport
|
||||
var document = Markdig.Markdown.Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE);
|
||||
|
||||
//
|
||||
// What a table is about stands above it, not in it: models introduce their tables with a
|
||||
// heading. We remember every heading with its line so that each table can take the last
|
||||
// one before it, and fall back to its own first column heading when there is none.
|
||||
// What a file is about stands above it, not in it: models introduce their tables and code
|
||||
// blocks with a heading. We remember every heading with its line so that each file can take
|
||||
// the last one before it. A table falls back to its own first column heading when there is
|
||||
// none; a code block has nothing comparable and stays without a caption.
|
||||
//
|
||||
var headings = document.Descendants<HeadingBlock>()
|
||||
.Select(heading => (heading.Line, Text: ToPlainText(heading)))
|
||||
@@ -56,11 +60,18 @@ public static class PlainFileExport
|
||||
var codeBlocks = document.Descendants<FencedCodeBlock>()
|
||||
.Select(block => (block.Line, Content: ToContent(block)));
|
||||
|
||||
//
|
||||
// Tables and code blocks are counted apart. The menu falls back to that number when a
|
||||
// heading cannot tell two files apart, and "Table 2" has to be the second table of the
|
||||
// answer, not the second entry of the menu.
|
||||
//
|
||||
var numberOfTables = 0;
|
||||
var numberOfCodeBlocks = 0;
|
||||
return tables.Concat(codeBlocks)
|
||||
.Where(entry => entry.Content is not null)
|
||||
.OrderBy(entry => entry.Line)
|
||||
.Select((entry, index) => new MessageTable(
|
||||
index + 1,
|
||||
.Select(entry => new MessageFile(
|
||||
entry.Content!.Value.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
|
||||
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.Content!.Value.Fallback,
|
||||
entry.Content!.Value.Format,
|
||||
entry.Content.Value.Text))
|
||||
@@ -90,22 +101,22 @@ public static class PlainFileExport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a fenced code block into a file, when the model marked it as tabular data.
|
||||
/// Turns a fenced code block into a file, when the model marked it as a format we write.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A block the model never closed is left out. That happens when an answer broke off, at the
|
||||
/// output limit of the model for example, and the file would end wherever the answer did: half
|
||||
/// a web page or half a table is nothing anybody wants to save.
|
||||
/// </remarks>
|
||||
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block)
|
||||
{
|
||||
var format = block.Info?.Trim() switch
|
||||
{
|
||||
"csv" => FileExportFormat.CSV,
|
||||
"tsv" => FileExportFormat.TSV,
|
||||
|
||||
_ => FileExportFormat.NONE,
|
||||
};
|
||||
|
||||
if (format is FileExportFormat.NONE)
|
||||
if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
|
||||
return null;
|
||||
|
||||
var content = block.Lines.ToString();
|
||||
if (!format.IsTabular())
|
||||
return (string.Empty, format, content);
|
||||
|
||||
var blockSeparator = format is FileExportFormat.TSV ? '\t' : ',';
|
||||
var firstLine = content.AsSpan();
|
||||
var lineEnd = firstLine.IndexOf('\n');
|
||||
@@ -167,20 +178,26 @@ public static class PlainFileExport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the given text to a plain text file and lets the user save it.
|
||||
/// Writes the given text to a plain text file as it is and lets the user save it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing is converted here, which is what sets this apart from PandocExport.ToDocument. A web
|
||||
/// page or a LaTeX document the model wrote is a finished file already and comes through here;
|
||||
/// an entire answer in one of these formats is Markdown and goes to Pandoc instead.
|
||||
/// </remarks>
|
||||
/// <param name="rustService">The Rust service, used for the save dialog.</param>
|
||||
/// <param name="dialogTitle">The title of the save dialog. The caller knows what the user is
|
||||
/// looking at, a chat message or the result of an assistant, so the caller names it.</param>
|
||||
/// <param name="format">The format to write. Must be a format which does not use Pandoc.</param>
|
||||
/// <param name="fileContent">What to write. The caller decides whether that is the entire
|
||||
/// message or one table out of it.</param>
|
||||
/// <param name="format">The format to write. Must be a plain text format, see
|
||||
/// FileExportFormatExtensions.IsPlainText.</param>
|
||||
/// <param name="fileContent">The finished file. The caller decides whether that is the entire
|
||||
/// message or one file out of it.</param>
|
||||
/// <param name="fileName">What the file is about, used to suggest a name in the save dialog.
|
||||
/// Null falls back to a generic name.</param>
|
||||
/// <returns>True, when the file was written.</returns>
|
||||
public static async Task<bool> ToFile(RustService rustService, string dialogTitle, FileExportFormat format, string fileContent, string? fileName = null)
|
||||
{
|
||||
if (format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter)
|
||||
if (!format.IsPlainText() || format.ToFileTypeFilter() is not { } fileTypeFilter)
|
||||
throw new ArgumentOutOfRangeException(nameof(format), format, "AI Studio cannot write this format itself.");
|
||||
|
||||
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
|
||||
|
||||
@@ -752,11 +752,16 @@ public static class WorkspaceBehaviour
|
||||
return Directory.Exists(chatPath);
|
||||
}
|
||||
|
||||
public static async Task StoreChatAsync(ChatThread chat)
|
||||
/// <summary>
|
||||
/// Stores a chat, unless another operation holds its lock for longer than the semaphore timeout.
|
||||
/// </summary>
|
||||
/// <param name="chat">The chat to store.</param>
|
||||
/// <returns>True when the chat was written; false when the operation was skipped to avoid a race.</returns>
|
||||
public static async Task<bool> StoreChatAsync(ChatThread chat)
|
||||
{
|
||||
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChatAsync));
|
||||
if (!acquired)
|
||||
return;
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -775,6 +780,7 @@ public static class WorkspaceBehaviour
|
||||
|
||||
var lastEditTime = File.GetLastWriteTimeUtc(chatPath);
|
||||
await UpdateCacheAfterChatStored(chat.WorkspaceId, chat.ChatId, chatDirectory, chat.Name, lastEditTime);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -782,6 +788,133 @@ public static class WorkspaceBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a chat into a new chat of the same workspace, including its managed transcript files,
|
||||
/// after asking the user for the name of the copy.
|
||||
/// </summary>
|
||||
/// <param name="dialogService">Used to ask for the name.</param>
|
||||
/// <param name="sourceChat">The chat to copy. Its own files and state stay untouched.</param>
|
||||
/// <returns>The persisted copy. Null when the user canceled the question, in which case nothing was copied.</returns>
|
||||
/// <remarks>
|
||||
/// This is the one place that asks for the name of a copy, so every way of copying a chat
|
||||
/// suggests the same name and words the question the same way.<br/><br/>
|
||||
///
|
||||
/// The copy is written before it is returned, so the caller may open it right away. Runtime-only
|
||||
/// state of the source is not part of the copy: it is rebuilt when the copy gets loaded. When
|
||||
/// the copy cannot be written, nothing of it stays behind and the error reaches the caller.
|
||||
/// </remarks>
|
||||
public static async Task<ChatThread?> CopyChatAsync(IDialogService dialogService, ChatThread sourceChat)
|
||||
{
|
||||
var sourceName = string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Unnamed chat") : sourceChat.Name;
|
||||
var dialogParameters = new DialogParameters<SingleInputDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(TB("Please enter a name for the copy of your chat '{0}':"), sourceName) },
|
||||
{ x => x.InputHeaderText, TB("Chat Name") },
|
||||
{ x => x.UserInput, string.Format(TB("Copy of {0}"), sourceName) },
|
||||
{ x => x.ConfirmText, TB("Copy") },
|
||||
{ x => x.ConfirmColor, Color.Info },
|
||||
{ x => x.AllowEmptyInput, false },
|
||||
{ x => x.EmptyInputErrorMessage, TB("Please enter a chat name.") },
|
||||
};
|
||||
|
||||
var dialogReference = await dialogService.ShowAsync<SingleInputDialog>(TB("Copy Chat"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return null;
|
||||
|
||||
var serializedChat = JsonSerializer.Serialize(sourceChat, JSON_OPTIONS);
|
||||
var copiedChat = JsonSerializer.Deserialize<ChatThread>(serializedChat, JSON_OPTIONS)
|
||||
?? throw new InvalidOperationException("The chat could not be copied.");
|
||||
copiedChat = copiedChat with
|
||||
{
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = (dialogResult.Data as string)!,
|
||||
};
|
||||
|
||||
var targetDirectory = GetChatDirectory(copiedChat.WorkspaceId, copiedChat.ChatId);
|
||||
try
|
||||
{
|
||||
CopyManagedTranscriptAttachments(copiedChat, targetDirectory);
|
||||
|
||||
//
|
||||
// Storing is skipped instead of failing when the chat lock cannot be taken. For a copy
|
||||
// that must not pass as success: the caller would open a chat which is not on disk,
|
||||
// while the transcript files copied above would stay behind as orphans.
|
||||
//
|
||||
if (!await StoreChatAsync(copiedChat))
|
||||
throw new IOException($"The copied chat could not be stored: '{targetDirectory}'.");
|
||||
|
||||
return copiedChat;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (Directory.Exists(targetDirectory))
|
||||
Directory.Delete(targetDirectory, true);
|
||||
|
||||
InvalidateWorkspaceTreeCache();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyManagedTranscriptAttachments(ChatThread chat, string targetChatDirectory)
|
||||
{
|
||||
var targetTranscriptDirectory = Path.Combine(targetChatDirectory, "attachments", "transcripts");
|
||||
var pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
var copiedPaths = new Dictionary<string, ManagedTranscriptAttachment>(pathComparer);
|
||||
|
||||
foreach (var content in chat.Blocks.Select(block => block.Content).OfType<ContentText>())
|
||||
{
|
||||
for (var index = 0; index < content.FileAttachments.Count; index++)
|
||||
{
|
||||
if (content.FileAttachments[index] is ManagedTranscriptAttachment transcript)
|
||||
content.FileAttachments[index] = CopyManagedTranscriptAttachment(chat, transcript, targetTranscriptDirectory, copiedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
for (var index = 0; index < chat.PendingMediaTranscripts.Count; index++)
|
||||
chat.PendingMediaTranscripts[index] = CopyManagedTranscriptAttachment(chat, chat.PendingMediaTranscripts[index], targetTranscriptDirectory, copiedPaths);
|
||||
}
|
||||
|
||||
private static ManagedTranscriptAttachment CopyManagedTranscriptAttachment(
|
||||
ChatThread chat,
|
||||
ManagedTranscriptAttachment source,
|
||||
string targetTranscriptDirectory,
|
||||
Dictionary<string, ManagedTranscriptAttachment> copiedPaths)
|
||||
{
|
||||
//
|
||||
// A thread which was edited outside the app may name a path which is not a path at all.
|
||||
// Such an attachment keeps pointing at whatever the source named: it is already broken in
|
||||
// the source chat, and letting it take the whole copy down would be worse.
|
||||
//
|
||||
string sourcePath;
|
||||
try
|
||||
{
|
||||
sourcePath = Path.GetFullPath(source.FilePath);
|
||||
}
|
||||
catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
LOG.LogWarning(e, "Could not resolve the transcript path '{FilePath}' while copying chat '{ChatId}'. The attachment is kept as it is.", source.FilePath, chat.ChatId);
|
||||
return source;
|
||||
}
|
||||
|
||||
if (copiedPaths.TryGetValue(sourcePath, out var existingCopy))
|
||||
return existingCopy;
|
||||
|
||||
Directory.CreateDirectory(targetTranscriptDirectory);
|
||||
var targetPath = NextTranscriptPath(chat, targetTranscriptDirectory, source.OriginalFileName);
|
||||
if (File.Exists(sourcePath))
|
||||
File.Copy(sourcePath, targetPath);
|
||||
|
||||
var copiedAttachment = new ManagedTranscriptAttachment(
|
||||
Path.GetFileName(targetPath),
|
||||
targetPath,
|
||||
File.Exists(targetPath) ? new FileInfo(targetPath).Length : source.FileSizeBytes,
|
||||
source.OriginalFileName,
|
||||
false);
|
||||
copiedPaths[sourcePath] = copiedAttachment;
|
||||
return copiedAttachment;
|
||||
}
|
||||
|
||||
/// <summary>Creates a transcript atomically inside an already persisted chat.</summary>
|
||||
/// <param name="chat">Persisted chat that owns the transcript counter.</param>
|
||||
/// <param name="originalPath">Original media path.</param>
|
||||
|
||||
Reference in new issue
Block a user