UI for Pandoc integration (#393)

Co-authored-by: Thorsten Sommer
This commit is contained in:
nilskruthoffandThorsten Sommer authored and GitHub committed 2025-05-29 14:01:56 +02:00
1 parent 1d31fd9a22
commit 5b685193ef
15 files changed
+766 -4

No files matched your search

@@ -1,6 +1,6 @@
namespace AIStudio.Tools;
public readonly record struct Error(string Icon, string Message)
public readonly record struct DataErrorMessage(string Icon, string Message)
{
public void Show(ISnackbar snackbar)
{
@@ -0,0 +1,16 @@
namespace AIStudio.Tools;
public readonly record struct DataSuccessMessage(string Icon, string Message)
{
public void Show(ISnackbar snackbar)
{
var icon = this.Icon;
snackbar.Add(this.Message, Severity.Success, config =>
{
config.Icon = icon;
config.IconSize = Size.Large;
config.HideTransitionDuration = 600;
config.VisibleStateDuration = 10_000;
});
}
}
@@ -0,0 +1,16 @@
namespace AIStudio.Tools;
public readonly record struct DataWarningMessage(string Icon, string Message)
{
public void Show(ISnackbar snackbar)
{
var icon = this.Icon;
snackbar.Add(this.Message, Severity.Warning, config =>
{
config.Icon = icon;
config.IconSize = Size.Large;
config.HideTransitionDuration = 600;
config.VisibleStateDuration = 12_000;
});
}
}
+2
View File
@@ -11,6 +11,8 @@ public enum Event
STARTUP_PLUGIN_SYSTEM,
PLUGINS_RELOADED,
SHOW_ERROR,
SHOW_WARNING,
SHOW_SUCCESS,
// Update events:
USER_SEARCH_FOR_UPDATE,
+5 -1
View File
@@ -72,7 +72,11 @@ public sealed class MessageBus
}
}
public Task SendError(Error error) => this.SendMessage(null, Event.SHOW_ERROR, error);
public Task SendError(DataErrorMessage dataErrorMessage) => this.SendMessage(null, Event.SHOW_ERROR, dataErrorMessage);
public Task SendWarning(DataWarningMessage dataWarningMessage) => this.SendMessage(null, Event.SHOW_WARNING, dataWarningMessage);
public Task SendSuccess(DataSuccessMessage dataSuccessMessage) => this.SendMessage(null, Event.SHOW_SUCCESS, dataSuccessMessage);
public void DeferMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data = default)
{
+284
View File
@@ -0,0 +1,284 @@
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);
/// <summary>
/// Checks if pandoc is available on the system and can be started as a process or present in AiStudio's data dir
/// </summary>
/// <param name="rustService">Global rust service to access file system and data dir</param>
/// <param name="showMessages">Controls if snackbars are shown to the user</param>
/// <returns>True, if pandoc is available and the minimum required version is met, else False.</returns>
public static async Task<bool> 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;
}
}
/// <summary>
/// Automatically decompresses the latest pandoc archive into AiStudio's data directory
/// </summary>
/// <param name="rustService">Global rust service to access file system and data dir</param>
/// <returns>None</returns>
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.");
}
}
/// <summary>
/// Asynchronously fetch the content from Pandoc's latest release page and extract the latest version number
/// </summary>
/// <remarks>Version numbers can have the following formats: x.x, x.x.x or x.x.x.x</remarks>
/// <returns>Latest Pandoc version number</returns>
public static async Task<string> 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;
}
/// <summary>
/// Reads the systems architecture to find the correct archive
/// </summary>
/// <returns>Full URI to the right archive in Pandoc's repo</returns>
public static async Task<string> 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,
};
}
/// <summary>
/// Reads the systems architecture to find the correct Pandoc installer
/// </summary>
/// <returns>Full URI to the right installer in Pandoc's repo</returns>
public static async Task<string> 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;
}
}
/// <summary>
/// Reads the os platform to determine the used executable name
/// </summary>
/// <returns>Name of the pandoc executable</returns>
private static string GetPandocExecutableName() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "pandoc.exe" : "pandoc";
private static async Task<string> 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();
}