using System.Diagnostics; using System.Text; using AIStudio.Chat; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Services; namespace AIStudio.Tools; public static class PandocExport { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(PandocExport)); private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PandocExport).Namespace, nameof(PandocExport)); /// /// Converts the given Markdown text into a document at the given path. /// /// /// This says nothing to the user: it reports what happened and lets the caller decide. A batch /// run over hundreds of documents would otherwise bury the user under notifications. Pandoc /// must be available, which PandocAvailabilityService.EnsureAvailabilityAsync takes care of. /// /// The Rust service, used to build the Pandoc call. /// The Markdown text to convert. /// Where to write the document. /// The format to write. Must be a format which uses Pandoc. /// The token to cancel the conversion. /// True, when the document was written. public static async Task ConvertAsync(RustService rustService, string markdownText, string targetFilePath, FileExportFormat format, CancellationToken token = default) { if (!format.UsesPandoc()) throw new ArgumentOutOfRangeException(nameof(format), format, "Pandoc cannot write this format."); var tempMarkdownFilePath = string.Empty; try { var tempMarkdownFile = Guid.NewGuid().ToString(); tempMarkdownFilePath = Path.Combine(Path.GetTempPath(), tempMarkdownFile); // Write text content to a temporary file. Pandoc expects UTF-8 without a byte order // mark; a mark would end up as a stray character at the start of the document: await File.WriteAllTextAsync(tempMarkdownFilePath, markdownText, new UTF8Encoding(false), token); // Call Pandoc to create the document: var pandoc = await PandocProcessBuilder .Create() .UseStandaloneMode() .WithInputFormat("gfm+emoji+tex_math_dollars") .WithOutputFormat(format.ToPandocOutputFormat()) .WithOutputFile(targetFilePath) .WithInputFile(tempMarkdownFilePath) .BuildAsync(rustService); using var process = Process.Start(pandoc.StartInfo); if (process is null) { LOGGER.LogError("Failed to start Pandoc process."); return false; } // Read output streams asynchronously while the process runs (prevents deadlock): var outputTask = process.StandardOutput.ReadToEndAsync(token); var errorTask = process.StandardError.ReadToEndAsync(token); // Wait for the process to exit AND for streams to be fully read: await process.WaitForExitAsync(token); await outputTask; var error = await errorTask; if (process.ExitCode is not 0) { LOGGER.LogError("Pandoc failed with exit code {ProcessExitCode}: '{ErrorText}'", process.ExitCode, error); return false; } LOGGER.LogInformation("Pandoc conversion to {ExportFormat} successful.", format); return true; } catch (Exception ex) { LOGGER.LogError(ex, "Error during {ExportFormat} conversion.", format); return false; } finally { // Try to remove the temp file: if (!string.IsNullOrWhiteSpace(tempMarkdownFilePath)) { try { File.Delete(tempMarkdownFilePath); } catch { LOGGER.LogWarning("Was not able to delete the temporary file '{TempFilePath}'.", tempMarkdownFilePath); } } } } /// /// Converts the given content to a document using Pandoc and lets the user save it. /// /// The Rust service, used for the save dialog and for Pandoc. /// Makes sure Pandoc is there and offers its installation. /// 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. /// The format to write. Must be a format which uses Pandoc. /// The content to export. /// True, when the document was written. public static async Task ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent) { if (!format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter) throw new ArgumentOutOfRangeException(nameof(format), format, "Pandoc cannot write this format."); // // We read the text before we ask for a path: when there is nothing to convert, the user // should learn that right away instead of picking a file first and getting an error afterwards. // if (!markdownContent.TryGetMarkdownText(out var markdownText)) { LOGGER.LogWarning("Cannot export the content as {ExportFormat}, because it carries no text.", format); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported."))); return false; } var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName()); if (response.UserCancelled) { LOGGER.LogInformation("User cancelled the save dialog."); return false; } LOGGER.LogInformation("The user chose the path '{SaveFilePath}' for the {ExportFormat} export.", response.SaveFilePath, format); // The service reports a missing Pandoc to the user itself, so we only act on the outcome: var pandocState = await pandocAvailability.EnsureAvailabilityAsync(showSuccessMessage: false, showDialog: true); if (!pandocState.IsAvailable) return false; if (!await ConvertAsync(rustService, markdownText, response.SaveFilePath, format)) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("The export failed."))); return false; } await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, TB("The export succeeded."))); return true; } }