using System.Diagnostics; using System.IO.Compression; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using AIStudio.Tools.Services; namespace AIStudio.Tools; public static partial class Pandoc { private const string CPU_ARCHITECTURE = "win-x64"; private const string DOWNLOAD_URL = "https://github.com/jgm/pandoc/releases/download"; private const string LATEST_URL = "https://github.com/jgm/pandoc/releases/latest"; private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger("PandocService"); private static readonly Version MINIMUM_REQUIRED_VERSION = new (3, 6); private static readonly Version FALLBACK_VERSION = new (3, 7, 0, 1); /// /// Checks if pandoc is available on the system and can be started as a process or present in AiStudio's data dir /// /// Global rust service to access file system and data dir /// Controls if snackbars are shown to the user /// True, if pandoc is available and the minimum required version is met, else False. public static async Task CheckAvailabilityAsync(RustService rustService, bool showMessages = true) { var installDir = await GetPandocDataFolder(rustService); var subdirectories = Directory.GetDirectories(installDir); if (subdirectories.Length > 1) { await InstallAsync(rustService); return true; } if (HasPandoc(installDir)) return true; try { var startInfo = new ProcessStartInfo { FileName = GetPandocExecutableName(), Arguments = "--version", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) { if (showMessages) await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Help, "The pandoc process could not be started.")); LOG.LogInformation("The pandoc process was not started, it was null"); return false; } var output = await process.StandardOutput.ReadToEndAsync(); await process.WaitForExitAsync(); if (process.ExitCode != 0) { if (showMessages) await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, $"The pandoc process exited unexpectedly.")); LOG.LogError("The pandoc process was exited with code {ProcessExitCode}", process.ExitCode); return false; } var versionMatch = PandocCmdRegex().Match(output); if (!versionMatch.Success) { if (showMessages) await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Terminal, $"pandoc --version returned an invalid format.")); LOG.LogError("pandoc --version returned an invalid format:\n {Output}", output); return false; } var versions = versionMatch.Groups[1].Value; var installedVersion = Version.Parse(versions); if (installedVersion >= MINIMUM_REQUIRED_VERSION) { if (showMessages) await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, $"Pandoc {installedVersion.ToString()} is installed.")); return true; } if (showMessages) await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Build, $"Pandoc {installedVersion.ToString()} is installed, but it doesn't match the required version ({MINIMUM_REQUIRED_VERSION.ToString()}).")); LOG.LogInformation("Pandoc {Installed} is installed, but it does not match the required version ({Requirement})", installedVersion.ToString(), MINIMUM_REQUIRED_VERSION.ToString()); return false; } catch (Exception e) { if (showMessages) await MessageBus.INSTANCE.SendError(new (@Icons.Material.Filled.AppsOutage, "Pandoc is not installed.")); LOG.LogError("Pandoc is not installed and threw an exception:\n {Message}", e.Message); return false; } } private static bool HasPandoc(string pandocDirectory) { try { var subdirectories = Directory.GetDirectories(pandocDirectory); foreach (var subdirectory in subdirectories) { var pandocPath = Path.Combine(subdirectory, "pandoc.exe"); if (File.Exists(pandocPath)) { return true; } } return false; } catch (Exception ex) { LOG.LogInformation("Pandoc is not installed in the data directory and might have thrown and error:\n{ErrorMessage}", ex.Message); return false; } } /// /// Automatically decompresses the latest pandoc archive into AiStudio's data directory /// /// Global rust service to access file system and data dir /// None public static async Task InstallAsync(RustService rustService) { var installDir = await GetPandocDataFolder(rustService); ClearFolder(installDir); try { if (!Directory.Exists(installDir)) Directory.CreateDirectory(installDir); using var client = new HttpClient(); var uri = await GenerateUriAsync(); var response = await client.GetAsync(uri); if (!response.IsSuccessStatusCode) { await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, $"Pandoc was not installed successfully, because the download archive was not found.")); LOG.LogError("Pandoc was not installed, the release archive was not found (Status Code {StatusCode}):\n{Uri}\n{Message}", response.StatusCode, uri, response.RequestMessage); return; } var fileBytes = await response.Content.ReadAsByteArrayAsync(); if (uri.Contains(".zip")) { var tempZipPath = Path.Join(Path.GetTempPath(), "pandoc.zip"); await File.WriteAllBytesAsync(tempZipPath, fileBytes); ZipFile.ExtractToDirectory(tempZipPath, installDir); File.Delete(tempZipPath); } else if (uri.Contains(".tar.gz")) { var tempTarPath = Path.Join(Path.GetTempPath(), "pandoc.tar.gz"); await File.WriteAllBytesAsync(tempTarPath, fileBytes); ZipFile.ExtractToDirectory(tempTarPath, installDir); File.Delete(tempTarPath); } else { await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, $"Pandoc was not installed successfully, because the download archive type is unknown.")); LOG.LogError("Pandoc was not installed, the download archive is unknown:\n {Uri}", uri); return; } await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, $"Pandoc {await FetchLatestVersionAsync()} was installed successfully.")); } catch (Exception ex) { Console.WriteLine($"Fehler: {ex.Message}"); } } private static void ClearFolder(string path) { if (!Directory.Exists(path)) return; try { foreach (var dir in Directory.GetDirectories(path)) { Directory.Delete(dir, true); } } catch (Exception ex) { LOG.LogError(ex, "Error clearing pandoc folder."); } } /// /// Asynchronously fetch the content from Pandoc's latest release page and extract the latest version number /// /// Version numbers can have the following formats: x.x, x.x.x or x.x.x.x /// Latest Pandoc version number public static async Task FetchLatestVersionAsync() { using var client = new HttpClient(); var response = await client.GetAsync(LATEST_URL); if (!response.IsSuccessStatusCode) { LOG.LogError("Code {StatusCode}: Could not fetch Pandoc's latest page:\n {Response}", response.StatusCode, response.RequestMessage); await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, $"The latest pandoc version was not found, installing version {FALLBACK_VERSION.ToString()} instead.")); return FALLBACK_VERSION.ToString(); } var htmlContent = await response.Content.ReadAsStringAsync(); var versionMatch = LatestVersionRegex().Match(htmlContent); if (!versionMatch.Success) { LOG.LogError("The latest version regex returned nothing:\n {Value}", versionMatch.Groups.ToString()); await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, $"The latest pandoc version was not found, installing version {FALLBACK_VERSION.ToString()} instead.")); return FALLBACK_VERSION.ToString(); } var version = versionMatch.Groups[1].Value; return version; } /// /// Reads the systems architecture to find the correct archive /// /// Full URI to the right archive in Pandoc's repo public static async Task GenerateUriAsync() { var version = await FetchLatestVersionAsync(); var baseUri = $"{DOWNLOAD_URL}/{version}/pandoc-{version}-"; return CPU_ARCHITECTURE switch { "win-x64" => $"{baseUri}windows-x86_64.zip", "osx-x64" => $"{baseUri}x86_64-macOS.zip", "osx-arm64" => $"{baseUri}arm64-macOS.zip", "linux-x64" => $"{baseUri}linux-amd64.tar.gz", "linux-arm" => $"{baseUri}linux-arm64.tar.gz", _ => string.Empty, }; } /// /// Reads the systems architecture to find the correct Pandoc installer /// /// Full URI to the right installer in Pandoc's repo public static async Task GenerateInstallerUriAsync() { var version = await FetchLatestVersionAsync(); var baseUri = $"{DOWNLOAD_URL}/{version}/pandoc-{version}-"; switch (CPU_ARCHITECTURE) { case "win-x64": return $"{baseUri}windows-x86_64.msi"; case "osx-x64": return $"{baseUri}x86_64-macOS.pkg"; case "osx-arm64": return $"{baseUri}arm64-macOS.pkg\n"; default: await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Terminal, $"Installers are not available on {CPU_ARCHITECTURE} systems.")); return string.Empty; } } /// /// Reads the os platform to determine the used executable name /// /// Name of the pandoc executable private static string GetPandocExecutableName() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "pandoc.exe" : "pandoc"; private static async Task GetPandocDataFolder(RustService rustService) => Path.Join(await rustService.GetDataDirectory(), "pandoc"); [GeneratedRegex(@"pandoc(?:\.exe)?\s*([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:\.[0-9]+)?)")] private static partial Regex PandocCmdRegex(); [GeneratedRegex(@"pandoc(?:\.exe)?\s*([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:\.[0-9]+)?)")] private static partial Regex LatestVersionRegex(); }