diff --git a/SessionZero.sln b/SessionZero.sln index a8146a5..d56de21 100644 --- a/SessionZero.sln +++ b/SessionZero.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SessionZero.Client", "src\S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SessionZero.Server", "src\SessionZero.Server\SessionZero.Server.csproj", "{54824F5A-0499-4BC9-AC8E-88E943C66256}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "szpack", "tools\szpack\szpack.csproj", "{70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,5 +26,9 @@ Global {54824F5A-0499-4BC9-AC8E-88E943C66256}.Debug|Any CPU.Build.0 = Debug|Any CPU {54824F5A-0499-4BC9-AC8E-88E943C66256}.Release|Any CPU.ActiveCfg = Release|Any CPU {54824F5A-0499-4BC9-AC8E-88E943C66256}.Release|Any CPU.Build.0 = Release|Any CPU + {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70FDB950-CAE6-4C38-A476-3FC10BFCF6E6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/SessionZero.Shared/Services/DatapackService.cs b/src/SessionZero.Shared/Services/DatapackService.cs new file mode 100644 index 0000000..b657d63 --- /dev/null +++ b/src/SessionZero.Shared/Services/DatapackService.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using SessionZero.Shared.Models; +using System.IO; +using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.IO.Compression; + +namespace SessionZero.Shared.Services; + +public static class DatapackService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Creates a new Datapack model with required metadata. + /// + /// The display name of the datapack. + /// The initial version string (e.g., "1.0.0"). + /// The pack creator's name. + /// The license (e.g., "CC-BY-SA-4.0"). + /// A fully initialized Datapack model. + public static Datapack CreateEmptyDatapack(string name, string version, string author, string license) + { + return new Datapack + { + Id = Guid.NewGuid(), + Name = name, + Version = version, + Author = author, + License = license, + Description = String.Empty, + CreatedAt = DateTime.UtcNow, + SessionZeroVersion = "0.0.1", + Dependencies = new() + }; + } + + /// + /// Serializes a Datapack model and saves it to a file named 'szpack.json' + /// inside a new subdirectory named after the datapack's slug. + /// + /// The Datapack object to save. + /// The directory where the new datapack folder will be created. + /// The full path to the created szpack.json file. + public static async Task SaveDatapackMetadataAsync(Datapack datapack, string parentDirectoryPath) + { + var packDirectoryName = datapack.Name.ToLower().Replace(' ', '-').Trim(); + var packRootDirectory = Path.Combine(parentDirectoryPath, packDirectoryName); + + Directory.CreateDirectory(packRootDirectory); + + var filePath = Path.Combine(packRootDirectory, "szpack.json"); + + var jsonString = JsonSerializer.Serialize(datapack, JsonOptions); + await File.WriteAllTextAsync(filePath, jsonString); + + return filePath; + } + + /// + /// Serializes an SzObject (Dataset, Template, etc.) and saves it to a file. + /// + /// The type of the object, must inherit from SzObject. + /// The object to save. + /// The specific subdirectory (e.g., 'datasets') within the datapack root. + /// The full path to the created JSON file. + public static async Task SaveSzObjectAsync(T szObject, string directoryPath) where T : SzObject + { + Directory.CreateDirectory(directoryPath); + + var fileName = $"{szObject.Id}.json"; + var filePath = Path.Combine(directoryPath, fileName); + var jsonString = JsonSerializer.Serialize(szObject, JsonOptions); + await File.WriteAllTextAsync(filePath, jsonString); + + return filePath; + } + + /// + /// Defines and creates the standard directory structure for a SessionZero datapack. + /// + /// The root directory path of the new datapack. + /// A dictionary containing the standard folder names and their absolute paths. + public static Dictionary CreateDatapackDirectoryStructure(string rootPath) + { + var structure = new Dictionary + { + { "datasets", Path.Combine(rootPath, "datasets") }, + { "character_templates", Path.Combine(rootPath, "characters") }, + { "session_templates", Path.Combine(rootPath, "sessions") }, + { "media", Path.Combine(rootPath, "media") }, + { "images", Path.Combine(rootPath, "media", "images") } + }; + + foreach (var path in structure.Values) + { + Directory.CreateDirectory(path); + } + + return structure; + } + + /// + /// Compresses a datapack directory into a .szpack zip archive. + /// + /// The path to the datapack's root directory. + /// The full path and filename for the output .szpack file. + public static void PackDatapack(string sourceDirectoryPath, string outputFilePath) + { + if (!Directory.Exists(sourceDirectoryPath)) + { + throw new DirectoryNotFoundException($"Source directory not found: {sourceDirectoryPath}"); + } + + if (File.Exists(outputFilePath)) + { + File.Delete(outputFilePath); + } + + ZipFile.CreateFromDirectory(sourceDirectoryPath, outputFilePath); + } + + /// + /// Extracts a .szpack zip archive into a target directory. + /// + /// The path to the .szpack file. + /// The directory where the contents will be extracted. + public static void UnpackDatapack(string sourceFilePath, string destinationDirectoryPath) + { + if (!File.Exists(sourceFilePath)) + { + throw new FileNotFoundException($"Datapack file not found: {sourceFilePath}"); + } + + Directory.CreateDirectory(destinationDirectoryPath); + + ZipFile.ExtractToDirectory(sourceFilePath, destinationDirectoryPath, overwriteFiles: true); + } + + /// + /// Deserializes and loads the core Datapack metadata (szpack.json) from a directory. + /// + /// The path to the datapack's root directory. + /// A Datapack model containing the metadata. + /// Thrown if szpack.json is not found. + /// Thrown if deserialization fails. + public static async Task LoadDatapackMetadataAsync(string datapackRootPath) + { + var filePath = Path.Combine(datapackRootPath, "szpack.json"); + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"The required metadata file 'szpack.json' was not found in: {datapackRootPath}", filePath); + } + + var jsonString = await File.ReadAllTextAsync(filePath); + + var datapack = JsonSerializer.Deserialize(jsonString, JsonOptions); + + if (datapack is null) + { + throw new JsonException($"Failed to deserialize szpack.json from {filePath}. The file may be corrupt or invalid."); + } + + return datapack; + } +} \ No newline at end of file diff --git a/src/SessionZero.Shared/Validation/DatapackValidator.cs b/src/SessionZero.Shared/Validation/DatapackValidator.cs new file mode 100644 index 0000000..828e9b2 --- /dev/null +++ b/src/SessionZero.Shared/Validation/DatapackValidator.cs @@ -0,0 +1,45 @@ +using SessionZero.Shared.Models; + +namespace SessionZero.Shared.Validation; + +public static class DatapackValidator +{ + public static List ValidateDatapack(Datapack datapack) + { + var errors = new List(); + + if (datapack.Id == Guid.Empty) errors.Add("Datapack 'Id' is required and must not be an empty GUID."); + + ValidateRequiredString(errors, datapack.Name, nameof(datapack.Name)); + ValidateRequiredString(errors, datapack.Version, nameof(datapack.Version)); + ValidateRequiredString(errors, datapack.Author, nameof(datapack.Author)); + ValidateRequiredString(errors, datapack.License, nameof(datapack.License)); + ValidateRequiredString(errors, datapack.SessionZeroVersion, nameof(datapack.SessionZeroVersion)); + + if (datapack.CreatedAt == DateTime.MinValue) errors.Add("Datapack 'CreatedAt' is required and must not be an empty DateTime."); + + return errors; + } + + public static List ValidateSzObject(SzObject szObject) + { + var errors = new List(); + var objectName = szObject.Id; + + ValidateRequiredString(errors, szObject.Id, $"{objectName}'s Id"); + ValidateRequiredString(errors, szObject.Name, $"{objectName}'s Name"); + ValidateRequiredString(errors, szObject.SzType, $"{objectName}'s SzType"); + ValidateRequiredString(errors, szObject.Version, $"{objectName}'s Version"); + ValidateRequiredString(errors, szObject.SchemaVersion, $"{objectName}'s SchemaVersion"); + + // 2. Validate Icon path format (optional/future: e.g., only allows alphanumeric + slashes + extension) + // For now, we'll only check if it's not null, which is done by the property definition in SzObject. + + return errors; + } + + private static void ValidateRequiredString(List errors, string? value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) errors.Add($"'{fieldName}' is required and must not be empty."); + } +} \ No newline at end of file diff --git a/tools/szpack/CreateCommand.cs b/tools/szpack/CreateCommand.cs new file mode 100644 index 0000000..6a1fd25 --- /dev/null +++ b/tools/szpack/CreateCommand.cs @@ -0,0 +1,107 @@ +/* + * WARNING: + * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project. + * It was created to be used as a quick and dirty validation tool for the szpack format. +*/ + +using SessionZero.Shared.Models; +using SessionZero.Shared.Services; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace SessionZero.Tools.Packer; + +// Settings class defines the command-line arguments +public class CreateSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("The display name of the datapack (e.g., 'My Fantasy Spells').")] + public required string Name { get; init; } + + [CommandArgument(1, "[author]")] + [Description("The author's name.")] + [DefaultValue("Test Author")] + public string Author { get; init; } = "Test Author"; + + [CommandOption("-o|--output")] + [Description("The parent directory where the new pack folder will be created.")] + [DefaultValue(".")] + public string Output { get; init; } = Environment.CurrentDirectory; +} + +public class CreateCommand : AsyncCommand +{ + public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] CreateSettings settings, CancellationToken cancellationToken) + { + AnsiConsole.MarkupLine($"\n[bold white on blue] --- Creating Datapack: '{settings.Name}' --- [/]"); + + try + { + // 1. Create the top-level model and save szpack.json + var newPack = DatapackService.CreateEmptyDatapack(settings.Name, "1.0.0", settings.Author, "MIT"); + var szpackPath = await DatapackService.SaveDatapackMetadataAsync(newPack, settings.Output); + var packRootDirectory = Path.GetDirectoryName(szpackPath)!; + + AnsiConsole.MarkupLine($"[green]✅ Created metadata file at: {szpackPath}[/]"); + + // 2. Create the standard directory structure + var structure = DatapackService.CreateDatapackDirectoryStructure(packRootDirectory); + AnsiConsole.MarkupLine($"[green]✅ Created standard directories at: {packRootDirectory}[/]"); + + // 3. Create and save test objects + await CreateTestObjects(packRootDirectory, structure); + + AnsiConsole.MarkupLine("\n[bold]Creation Complete![/] Use 'szpack pack' to compress it."); + return 0; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"\n[bold white on red]❌ ERROR:[/] Failed to create datapack. Details: {ex.Message}"); + return 1; + } + } + + // Helper method for creating test objects (copied from previous Program.cs) + private static async Task CreateTestObjects(string rootPath, Dictionary structure) + { + // ... (Test object creation logic remains the same, ensure you have the necessary usings in this file) + + // --- Test object creation logic (omitted for brevity, assume the previous logic is here) --- + var testDataset = new Dataset { /* ... test data ... */ Id = "basic-attributes", Name = "Basic Character Attributes", SzType = "Dataset", Version = "1.0.0", SchemaVersion = "1.0.0", DatasetType = "Attribute", Entries = new() }; + var dsPath = await DatapackService.SaveSzObjectAsync(testDataset, structure["datasets"]); + AnsiConsole.MarkupLine($" -> Saved Test Dataset: [yellow]{Path.GetFileName(dsPath)}[/]"); + + var testCharTemplate = new CharacterTemplate { /* ... test data ... */ Id = "default-char-sheet", Name = "Default Character Template", SzType = "Template", Version = "1.0.0", SchemaVersion = "1.0.0", Sections = new() }; + var charPath = await DatapackService.SaveSzObjectAsync(testCharTemplate, structure["character_templates"]); + AnsiConsole.MarkupLine($" -> Saved Test Character Template: [yellow]{Path.GetFileName(charPath)}[/]"); + + var testSessionTemplate = new SessionTemplate + { + /* ... test data ... */ Id = "basic-encounter", + Name = "Basic Encounter Template", + SzType = "Template", + Version = "1.0.0", + SchemaVersion = "1.0.0", + CharacterTemplateLink = new() + { + DatapackId = Guid.Empty, + TemplateId = testCharTemplate.Id, + Version = testCharTemplate.Version + }, + RequiredDatasets = new() + { + new() + { + DatapackId = Guid.Empty, + DatasetId = testDataset.Id, + Version = testDataset.Version + } + }, + Sections = new() + }; + var sessionPath = await DatapackService.SaveSzObjectAsync(testSessionTemplate, structure["session_templates"]); + AnsiConsole.MarkupLine($" -> Saved Test Session Template: [yellow]{Path.GetFileName(sessionPath)}[/]"); + } +} \ No newline at end of file diff --git a/tools/szpack/PackCommand.cs b/tools/szpack/PackCommand.cs new file mode 100644 index 0000000..7e6c411 --- /dev/null +++ b/tools/szpack/PackCommand.cs @@ -0,0 +1,43 @@ +/* + * WARNING: + * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project. + * It was created to be used as a quick and dirty validation tool for the szpack format. +*/ + +using SessionZero.Shared.Services; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace SessionZero.Tools.Packer; + +public class PackSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("The root directory of the datapack to pack.")] + public required string Input { get; init; } +} + +public class PackCommand : Command +{ + public override int Execute([NotNull] CommandContext context, [NotNull] PackSettings settings, CancellationToken cancellationToken) + { + AnsiConsole.MarkupLine($"\n[bold white on blue] --- Packing Datapack: {settings.Input} --- [/]"); + try + { + var packDirectoryName = new DirectoryInfo(settings.Input).Name; + var outputFilePath = Path.Combine(Path.GetDirectoryName(settings.Input) ?? Environment.CurrentDirectory, $"{packDirectoryName}.szpack"); + + DatapackService.PackDatapack(settings.Input, outputFilePath); + + AnsiConsole.MarkupLine($"[green]✅ Successfully created archive: {outputFilePath}[/]"); + return 0; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"\n[bold white on red]❌ ERROR:[/] Failed to pack datapack. Details: {ex.Message}"); + return 1; + } + } +} \ No newline at end of file diff --git a/tools/szpack/Program.cs b/tools/szpack/Program.cs new file mode 100644 index 0000000..eb8b3ee --- /dev/null +++ b/tools/szpack/Program.cs @@ -0,0 +1,36 @@ +/* + * WARNING: + * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project. + * It was created to be used as a quick and dirty validation tool for the szpack format. +*/ + +using Spectre.Console.Cli; + +namespace SessionZero.Tools.Packer; + +internal class Program +{ + static int Main(string[] args) + { + var app = new CommandApp(); + + app.Configure(config => + { + config.SetApplicationName("szpack"); + + config.AddCommand("create") + .WithDescription("Creates a new datapack directory with test objects."); + + config.AddCommand("pack") + .WithDescription("Compresses a datapack directory into a .szpack file."); + + config.AddCommand("unpack") + .WithDescription("Extracts a .szpack file and displays its metadata."); + + // Optional: Set a default command if no command is specified + // config.SetDefaultCommand(); + }); + + return app.Run(args); + } +} \ No newline at end of file diff --git a/tools/szpack/UnpackCommand.cs b/tools/szpack/UnpackCommand.cs new file mode 100644 index 0000000..6509b2d --- /dev/null +++ b/tools/szpack/UnpackCommand.cs @@ -0,0 +1,55 @@ +/* + * WARNING: + * This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project. + * It was created to be used as a quick and dirty validation tool for the szpack format. +*/ + +using SessionZero.Shared.Services; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace SessionZero.Tools.Packer; + +public class UnpackSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("The path to the .szpack file.")] + public required string Input { get; init; } + + [CommandOption("-o|--output")] + [Description("The directory where the pack will be extracted.")] + [DefaultValue("unpacked")] + public string Output { get; init; } = "unpacked"; +} + +public class UnpackCommand : AsyncCommand +{ + public override async Task ExecuteAsync([NotNull] CommandContext context, [NotNull] UnpackSettings settings, CancellationToken cancellationToken) + { + AnsiConsole.MarkupLine($"\n[bold white on blue] --- Unpacking Datapack: {settings.Input} --- [/]"); + try + { + // Unpack the archive + DatapackService.UnpackDatapack(settings.Input, settings.Output); + AnsiConsole.MarkupLine($"[green]✅ Successfully unpacked archive to: {settings.Output}[/]"); + + // Load and display the core data + var datapack = await DatapackService.LoadDatapackMetadataAsync(settings.Output); + + AnsiConsole.MarkupLine("\n[bold]--- Datapack Info (szpack.json) ---[/]"); + AnsiConsole.MarkupLine($"[yellow]ID:[/]\t\t{datapack.Id}"); + AnsiConsole.MarkupLine($"[yellow]Name:[/]\t{datapack.Name}"); + AnsiConsole.MarkupLine($"[yellow]Version:[/]\t{datapack.Version}"); + AnsiConsole.MarkupLine($"[yellow]Author:[/]\t{datapack.Author}"); + + return 0; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"\n[bold white on red]❌ ERROR:[/] Failed to unpack or load datapack. Details: {ex.Message}"); + return 1; + } + } +} \ No newline at end of file diff --git a/tools/szpack/readme.md b/tools/szpack/readme.md new file mode 100644 index 0000000..4575808 --- /dev/null +++ b/tools/szpack/readme.md @@ -0,0 +1,10 @@ +# SZPACK CLI Tool + +## ***WARNING:*** +This tool was created by an LLM based on the SessionZero shared lib project, therefore it is subject to errors and does not reflect the architecture of the SessionZero project. +It was created to be used as a quick and dirty validation tool for the szpack format. + +## Usage +* `szpack create `: Creates a sample datapack directory with a given name +* `szpack pack `: Packs a datapack directory into a szpack file (must be a valid datapack) +* `szpack unpack `: Unpacks a .szpack file into a datapack directory and displays some metadata \ No newline at end of file diff --git a/tools/szpack/szpack.csproj b/tools/szpack/szpack.csproj new file mode 100644 index 0000000..2437a23 --- /dev/null +++ b/tools/szpack/szpack.csproj @@ -0,0 +1,19 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + +