diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs index 63c1481..b7d0683 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseEntityMetadataService.cs @@ -24,7 +24,9 @@ public sealed record EntityAttributeRecord( bool IsPrimaryId, bool IsPrimaryName, int? MaxLength, - string? Description); + string? Description, + string? OptionSetName = null, + string? OptionValues = null); /// /// Relationship summary for an entity, returned by diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseOptionSetService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseOptionSetService.cs index b8cb895..bb7153a 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseOptionSetService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IDataverseOptionSetService.cs @@ -31,29 +31,29 @@ Task CreateGlobalOptionSetAsync( CancellationToken ct); /// - /// Inserts a new option value into a local or global option set. + /// Inserts a new option value into an option set. /// For a local option set, provide and . - /// For a global option set, provide . + /// For a global option set, provide . /// Task InsertOptionAsync( string? profileName, string? entityName, string? attributeName, - string? globalOptionSetName, + string? optionSetName, string label, int? value, CancellationToken ct); /// - /// Deletes an option value from a local or global option set. + /// Deletes an option value from an option set. /// For a local option set, provide and . - /// For a global option set, provide . + /// For a global option set, provide . /// Task DeleteOptionAsync( string? profileName, string? entityName, string? attributeName, - string? globalOptionSetName, + string? optionSetName, int value, CancellationToken ct); @@ -71,9 +71,65 @@ Task DeleteGlobalOptionSetAsync( Task> ListGlobalOptionSetsAsync( string? profileName, CancellationToken ct); + + /// + /// Describes a specific global option set — returns its options (value + label pairs). + /// + /// Optional LCID for label language (e.g. 1033=English, 1029=Czech). Null = user's language. + Task DescribeGlobalOptionSetAsync( + string? profileName, + string optionSetName, + int? languageCode, + CancellationToken ct); } /// /// Input DTO for a single option (label + optional value) used when creating option sets. /// -public sealed record OptionMetadataInput(string Label, int Value); +public sealed record OptionMetadataInput(string Label, int Value) +{ + /// + /// Parses a comma-separated options string into items. + /// Supports "Label:Value" pairs or plain "Label" (auto-valued starting at 100000000). + /// + public static OptionMetadataInput[] ParseCsv(string csv) + { + var entries = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (entries.Length == 0) + return Array.Empty(); + + var results = new OptionMetadataInput[entries.Length]; + int autoValue = 100_000_000; + + for (int i = 0; i < entries.Length; i++) + { + var parts = entries[i].Split(':', 2); + if (parts.Length == 2 && int.TryParse(parts[1].Trim(), out int v)) + { + results[i] = new OptionMetadataInput(parts[0].Trim(), v); + } + else + { + results[i] = new OptionMetadataInput(parts[0].Trim(), autoValue++); + } + } + + return results; + } +}; + +/// +/// Detail record for a global option set, including all option values and labels. +/// +public sealed record GlobalOptionSetDetailRecord( + string Name, + string? DisplayName, + string? Description, + string OptionSetType, + bool IsCustomOptionSet, + IReadOnlyList Options); + +/// +/// A single option value within an option set. +/// +public sealed record OptionValueRecord(int Value, string? Label, string? Description); diff --git a/src/TALXIS.CLI.Features.Environment/Data/Bulk/EnvDataBulkCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Data/Bulk/EnvDataBulkCreateCliCommand.cs index ce57782..c9cb232 100644 --- a/src/TALXIS.CLI.Features.Environment/Data/Bulk/EnvDataBulkCreateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Data/Bulk/EnvDataBulkCreateCliCommand.cs @@ -14,7 +14,7 @@ namespace TALXIS.CLI.Features.Environment.Data.Bulk; [CliIdempotent] [CliCommand( Name = "create", - Description = "Creates multiple Dataverse records in a single batch request on the LIVE connected environment. Requires an active profile. Accepts JSON array via --data or --file." + Description = "Creates multiple Dataverse records in a single batch request on the LIVE connected environment. Requires an active profile. Accepts JSON array via --data or --file. Column types are auto-detected: option sets accept plain integers (e.g. 375970000), money fields accept decimals, lookups accept {Id,LogicalName} objects or a bare GUID string (single-target lookups)." )] public class EnvDataBulkCreateCliCommand : ProfiledCliCommand { diff --git a/src/TALXIS.CLI.Features.Environment/Data/Record/EnvDataRecordCreateCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Data/Record/EnvDataRecordCreateCliCommand.cs index ba5a39e..33414c9 100644 --- a/src/TALXIS.CLI.Features.Environment/Data/Record/EnvDataRecordCreateCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Data/Record/EnvDataRecordCreateCliCommand.cs @@ -14,7 +14,7 @@ namespace TALXIS.CLI.Features.Environment.Data.Record; [CliIdempotent] [CliCommand( Name = "create", - Description = "Creates a single Dataverse record in the LIVE connected environment from inline JSON or file. Requires an active profile. For LOCAL component scaffolding, use 'workspace component create' instead." + Description = "Creates a single Dataverse record in the LIVE connected environment from inline JSON or file. Requires an active profile. Column types are auto-detected: option sets accept plain integers (e.g. 375970000), money fields accept decimals, lookups accept {Id,LogicalName} objects or a bare GUID string (single-target lookups). For LOCAL component scaffolding, use 'workspace component create' instead." )] #pragma warning disable TXC003 public class EnvDataRecordCreateCliCommand : StagedCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs index 2b162b1..b9ad966 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Entity/EntityCliCommand.cs @@ -9,7 +9,7 @@ namespace TALXIS.CLI.Features.Environment.Entity; [CliCommand( Name = "entity", Description = "Entity discovery and schema metadata for the live environment.", - Children = new[] { typeof(EntityListCliCommand), typeof(EntityDescribeCliCommand), typeof(EntityGetCliCommand), typeof(EntityUpdateCliCommand), typeof(EntityCreateCliCommand), typeof(EntityDeleteCliCommand), typeof(EntityAttributeCliCommand), typeof(EntityRelationshipCliCommand), typeof(EntityOptionSetCliCommand) } + Children = new[] { typeof(EntityListCliCommand), typeof(EntityDescribeCliCommand), typeof(EntityGetCliCommand), typeof(EntityUpdateCliCommand), typeof(EntityCreateCliCommand), typeof(EntityDeleteCliCommand), typeof(EntityAttributeCliCommand), typeof(EntityRelationshipCliCommand) } )] public class EntityCliCommand { diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityDescribeCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Entity/EntityDescribeCliCommand.cs index 06ac735..fa026fe 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityDescribeCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Entity/EntityDescribeCliCommand.cs @@ -52,6 +52,10 @@ private static void PrintAttributesTable(IReadOnlyList ro int pkWidth = 2; int nameWidth = 4; int maxLenWidth = 10; + bool hasOptionSets = rows.Any(r => r.OptionSetName is not null); + int optSetWidth = hasOptionSets + ? Math.Clamp(rows.Where(r => r.OptionSetName is not null).Max(r => r.OptionSetName!.Length), 9, 30) + : 0; string header = $"{"Logical Name".PadRight(logicalWidth)} | " + @@ -61,6 +65,8 @@ private static void PrintAttributesTable(IReadOnlyList ro $"{"PK".PadRight(pkWidth)} | " + $"{"Name".PadRight(nameWidth)} | " + $"{"Max Length".PadRight(maxLenWidth)}"; + if (hasOptionSets) + header += $" | {"OptionSet".PadRight(optSetWidth)}"; OutputWriter.WriteLine(header); OutputWriter.WriteLine(new string('-', header.Length)); @@ -74,14 +80,17 @@ private static void PrintAttributesTable(IReadOnlyList ro string name = r.IsPrimaryName ? "*" : ""; string maxLen = r.MaxLength.HasValue ? r.MaxLength.Value.ToString() : ""; - OutputWriter.WriteLine( + string line = $"{logical.PadRight(logicalWidth)} | " + $"{type.PadRight(typeWidth)} | " + $"{display.PadRight(displayWidth)} | " + $"{custom.PadRight(customWidth)} | " + $"{pk.PadRight(pkWidth)} | " + $"{name.PadRight(nameWidth)} | " + - $"{maxLen.PadRight(maxLenWidth)}"); + $"{maxLen.PadRight(maxLenWidth)}"; + if (hasOptionSets) + line += $" | {Truncate(r.OptionSetName ?? "", optSetWidth).PadRight(optSetWidth)}"; + OutputWriter.WriteLine(line); } } #pragma warning restore TXC003 diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetCliCommand.cs deleted file mode 100644 index 8b5bf39..0000000 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetCliCommand.cs +++ /dev/null @@ -1,70 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Environment.Entity; - -/// -/// Parent command for option set operations. -/// Usage: txc environment entity optionset [global|option] -/// -[CliCommand( - Name = "optionset", - Description = "Create and manage option sets (choices).", - Children = new[] - { - typeof(EntityOptionSetGlobalCliCommand), - typeof(EntityOptionSetOptionCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class EntityOptionSetCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} - -/// -/// Group command for global option set operations. -/// Usage: txc environment entity optionset global [create|delete|list] -/// -[CliCommand( - Name = "global", - Description = "Manage global option sets.", - Children = new[] - { - typeof(EntityOptionSetCreateGlobalCliCommand), - typeof(EntityOptionSetDeleteGlobalCliCommand), - typeof(EntityOptionSetListGlobalCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class EntityOptionSetGlobalCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} - -/// -/// Group command for individual option (value) operations. -/// Usage: txc environment entity optionset option [add|delete] -/// -[CliCommand( - Name = "option", - Description = "Add or remove individual options (values) in an option set.", - Children = new[] - { - typeof(EntityOptionSetAddOptionCliCommand), - typeof(EntityOptionSetDeleteOptionCliCommand) - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None -)] -public class EntityOptionSetOptionCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index baf0d7b..7b3fd0b 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetAddOptionCliCommand.cs b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetAddOptionCliCommand.cs similarity index 53% rename from src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetAddOptionCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetAddOptionCliCommand.cs index f11c947..1496ef9 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetAddOptionCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetAddOptionCliCommand.cs @@ -6,57 +6,55 @@ using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Entity; +namespace TALXIS.CLI.Features.Environment.OptionSet; /// -/// Adds an option value to a local or global option set. -/// Usage: txc environment entity optionset option add --label <text> (--global-optionset <name> | --entity <name> --attribute <name>) [--value <int>] +/// Adds an option value to a global or local option set. +/// Global: txc environment optionset add-option --name <schema-name> --label <text> +/// Local: txc environment optionset add-option --entity <name> --attribute <name> --label <text> /// [CliIdempotent] [CliCommand( Name = "add", - Description = "Add an option value to a local or global option set." + Description = "Add an option value to a global or local option set." )] #pragma warning disable TXC003 -public class EntityOptionSetAddOptionCliCommand : StagedCliCommand +public class OptionSetAddOptionCliCommand : StagedCliCommand { - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityOptionSetAddOptionCliCommand)); + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(OptionSetAddOptionCliCommand)); - [CliOption(Name = "--entity", Description = "The logical name of the entity (for local option sets).", Required = false)] + [CliOption(Name = "--name", Description = "Schema name of the global option set.", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--entity", Description = "Entity logical name (for local option sets).", Required = false)] public string? Entity { get; set; } - [CliOption(Name = "--attribute", Description = "The logical name of the attribute (for local option sets).", Required = false)] + [CliOption(Name = "--attribute", Description = "Attribute logical name (for local option sets).", Required = false)] public string? Attribute { get; set; } - [CliOption(Name = "--global-optionset", Description = "The name of the global option set (mutually exclusive with --entity/--attribute).", Required = false)] - public string? GlobalOptionset { get; set; } - - [CliOption(Name = "--label", Description = "The label for the new option.", Required = true)] + [CliOption(Name = "--label", Description = "Label for the new option.", Required = true)] public string Label { get; set; } = null!; - [CliOption(Name = "--value", Description = "The integer value for the new option (auto-generated if not provided).", Required = false)] + [CliOption(Name = "--value", Description = "Integer value for the new option (auto-generated if omitted).", Required = false)] public int? Value { get; set; } protected override async Task ExecuteAsync() { ValidateExecutionMode(); - // Validate mutually exclusive options. - bool hasGlobal = !string.IsNullOrWhiteSpace(GlobalOptionset); + bool hasGlobal = !string.IsNullOrWhiteSpace(Name); bool hasLocal = !string.IsNullOrWhiteSpace(Entity) || !string.IsNullOrWhiteSpace(Attribute); if (hasGlobal && hasLocal) { - Logger.LogError("Specify either --global-optionset or --entity/--attribute, not both."); + Logger.LogError("Specify either --name (global) or --entity + --attribute (local), not both."); return ExitError; } - if (!hasGlobal && !hasLocal) { - Logger.LogError("Specify --global-optionset for a global option set, or --entity and --attribute for a local one."); + Logger.LogError("Specify --name for a global option set, or --entity and --attribute for a local one."); return ExitError; } - if (hasLocal && (string.IsNullOrWhiteSpace(Entity) || string.IsNullOrWhiteSpace(Attribute))) { Logger.LogError("Both --entity and --attribute are required for local option sets."); @@ -65,20 +63,20 @@ protected override async Task ExecuteAsync() if (Stage) { - string stageTarget = hasGlobal ? GlobalOptionset! : $"{Entity}.{Attribute}"; + string stageTarget = hasGlobal ? Name! : $"{Entity}.{Attribute}"; var store = TxcServices.Get(); store.Add(new StagedOperation { Category = "schema", OperationType = "CREATE", - TargetType = "optionset", + TargetType = "optionset-option", TargetDescription = stageTarget, Details = $"add option: \"{Label}\"" + (Value.HasValue ? $" ({Value})" : ""), Parameters = new Dictionary { ["entity"] = Entity, ["attribute"] = Attribute, - ["globalOptionset"] = GlobalOptionset, + ["name"] = Name, ["label"] = Label, ["value"] = Value } @@ -89,10 +87,10 @@ protected override async Task ExecuteAsync() var service = TxcServices.Get(); await service.InsertOptionAsync( - Profile, Entity, Attribute, GlobalOptionset, Label, Value, CancellationToken.None + Profile, Entity, Attribute, Name, Label, Value, CancellationToken.None ).ConfigureAwait(false); - string target = hasGlobal ? $"global option set '{GlobalOptionset}'" : $"attribute '{Attribute}' on entity '{Entity}'"; + string target = hasGlobal ? $"global option set '{Name}'" : $"attribute '{Attribute}' on entity '{Entity}'"; OutputWriter.WriteLine($"Option '{Label}' added to {target}."); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetCliCommand.cs new file mode 100644 index 0000000..7bf6474 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetCliCommand.cs @@ -0,0 +1,51 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.OptionSet; + +/// +/// Parent command for option set (choice) operations. +/// Covers both global option sets and local (entity-attribute) option sets. +/// Usage: txc environment optionset [list|describe|create|delete|add-option|remove-option] +/// +[CliCommand( + Name = "optionset", + Description = "Manage option sets (choices) — global and local.", + Children = new[] + { + typeof(OptionSetListCliCommand), + typeof(OptionSetShowCliCommand), + typeof(OptionSetCreateCliCommand), + typeof(OptionSetDeleteCliCommand), + typeof(OptionSetOptionCliCommand) + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class OptionSetCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} + +/// +/// Sub-resource for individual option values within an option set. +/// Usage: txc environment optionset option [add|remove] +/// +[CliCommand( + Name = "option", + Description = "Add or remove individual values in an option set.", + Children = new[] + { + typeof(OptionSetAddOptionCliCommand), + typeof(OptionSetRemoveOptionCliCommand) + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class OptionSetOptionCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetCreateGlobalCliCommand.cs b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetCreateCliCommand.cs similarity index 50% rename from src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetCreateGlobalCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetCreateCliCommand.cs index 59e022a..144f2a6 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetCreateGlobalCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetCreateCliCommand.cs @@ -6,11 +6,11 @@ using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Entity; +namespace TALXIS.CLI.Features.Environment.OptionSet; /// /// Creates a new global option set (choice) in Dataverse. -/// Usage: txc environment entity optionset global create --name <schema-name> --display-name <label> --options <csv> [--description <text>] [--solution <name>] +/// Usage: txc environment optionset create --name <schema-name> --display-name <label> --options <csv> /// [CliIdempotent] [CliCommand( @@ -18,23 +18,23 @@ namespace TALXIS.CLI.Features.Environment.Entity; Description = "Create a new global option set (choice)." )] #pragma warning disable TXC003 -public class EntityOptionSetCreateGlobalCliCommand : StagedCliCommand +public class OptionSetCreateCliCommand : StagedCliCommand { - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityOptionSetCreateGlobalCliCommand)); + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(OptionSetCreateCliCommand)); - [CliOption(Name = "--name", Description = "The schema name of the global option set.", Required = true)] + [CliOption(Name = "--name", Description = "Schema name of the global option set.", Required = true)] public string Name { get; set; } = null!; - [CliOption(Name = "--display-name", Description = "The display name (label) for the option set.", Required = true)] + [CliOption(Name = "--display-name", Description = "Display name (label) for the option set.", Required = true)] public string DisplayName { get; set; } = null!; [CliOption(Name = "--options", Description = "Comma-separated options: \"Label1:100000000,Label2:100000001\" or \"Label1,Label2\" (auto-value).", Required = true)] public string Options { get; set; } = null!; - [CliOption(Name = "--description", Description = "The description for the option set.", Required = false)] + [CliOption(Name = "--description", Description = "Description for the option set.", Required = false)] public string? Description { get; set; } - [CliOption(Name = "--solution", Description = "The unique name of the solution to add the option set to.", Required = false)] + [CliOption(Name = "--solution", Description = "Solution unique name to register the option set in.", Required = false)] public string? Solution { get; set; } protected override async Task ExecuteAsync() @@ -64,45 +64,14 @@ protected override async Task ExecuteAsync() return ExitSuccess; } - OptionMetadataInput[] parsed = ParseOptions(Options); + OptionMetadataInput[] parsed = OptionMetadataInput.ParseCsv(Options); var service = TxcServices.Get(); await service.CreateGlobalOptionSetAsync( Profile, Name, DisplayName, Description, parsed, Solution, CancellationToken.None ).ConfigureAwait(false); - OutputWriter.WriteLine($"Global option set '{Name}' created successfully."); + OutputWriter.WriteLine($"Global option set '{Name}' created."); return ExitSuccess; } - - /// - /// Parses a comma-separated options string into items. - /// Supports "Label:Value" pairs or plain "Label" (auto-valued starting at 100000000). - /// - internal static OptionMetadataInput[] ParseOptions(string csv) - { - var entries = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (entries.Length == 0) - throw new FormatException("--options must contain at least one option."); - - var results = new OptionMetadataInput[entries.Length]; - int autoValue = 100_000_000; - - for (int i = 0; i < entries.Length; i++) - { - var parts = entries[i].Split(':', 2); - if (parts.Length == 2) - { - if (!int.TryParse(parts[1].Trim(), out int v)) - throw new FormatException($"Invalid option value '{parts[1].Trim()}' in '{entries[i]}'. Expected an integer."); - results[i] = new OptionMetadataInput(parts[0].Trim(), v); - } - else - { - results[i] = new OptionMetadataInput(parts[0].Trim(), autoValue++); - } - } - - return results; - } } diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetDeleteGlobalCliCommand.cs b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetDeleteCliCommand.cs similarity index 60% rename from src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetDeleteGlobalCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetDeleteCliCommand.cs index 311a11b..1a8199b 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetDeleteGlobalCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetDeleteCliCommand.cs @@ -6,26 +6,22 @@ using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Entity; +namespace TALXIS.CLI.Features.Environment.OptionSet; -/// -/// Deletes an existing global option set (choice) from Dataverse. -/// Usage: txc environment entity optionset global delete --name <schema-name> [-p profile] --apply -/// -[CliDestructive("Permanently deletes the global option set from the remote environment.")] +[CliDestructive("Permanently deletes the global option set from the environment.")] [CliCommand( Name = "delete", Description = "Delete a global option set (choice)." )] #pragma warning disable TXC003 -public class EntityOptionSetDeleteGlobalCliCommand : StagedCliCommand, IDestructiveCommand +public class OptionSetDeleteCliCommand : StagedCliCommand, IDestructiveCommand { - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityOptionSetDeleteGlobalCliCommand)); + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(OptionSetDeleteCliCommand)); - [CliOption(Name = "--yes", Description = "Skip interactive confirmation for this destructive operation.", Required = false)] + [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] public bool Yes { get; set; } - [CliOption(Name = "--name", Description = "The schema name of the global option set to delete.", Required = true)] + [CliOption(Name = "--name", Description = "Schema name of the global option set to delete.", Required = true)] public string Name { get; set; } = null!; protected override async Task ExecuteAsync() @@ -42,21 +38,16 @@ protected override async Task ExecuteAsync() TargetType = "optionset", TargetDescription = Name, Details = $"global optionset: \"{Name}\"", - Parameters = new Dictionary - { - ["name"] = Name - } + Parameters = new Dictionary { ["name"] = Name } }); OutputWriter.WriteLine($"Staged: DELETE global optionset '{Name}'"); return ExitSuccess; } var service = TxcServices.Get(); - await service.DeleteGlobalOptionSetAsync( - Profile, Name, CancellationToken.None - ).ConfigureAwait(false); + await service.DeleteGlobalOptionSetAsync(Profile, Name, CancellationToken.None).ConfigureAwait(false); - OutputWriter.WriteLine($"Global option set '{Name}' deleted successfully."); + OutputWriter.WriteLine($"Global option set '{Name}' deleted."); return ExitSuccess; } } diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetListGlobalCliCommand.cs b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetListCliCommand.cs similarity index 84% rename from src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetListGlobalCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetListCliCommand.cs index 94014d8..a350f1e 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetListGlobalCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetListCliCommand.cs @@ -1,16 +1,15 @@ using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; -using TALXIS.CLI.Core.Abstractions; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Entity; +namespace TALXIS.CLI.Features.Environment.OptionSet; /// /// Lists all global option sets in the environment. -/// Usage: txc environment entity optionset global list [--format json] +/// Usage: txc environment optionset list [--format json] /// [CliReadOnly] [CliCommand( @@ -18,9 +17,9 @@ namespace TALXIS.CLI.Features.Environment.Entity; Description = "List all global option sets in the environment." )] #pragma warning disable TXC003 -public class EntityOptionSetListGlobalCliCommand : ProfiledCliCommand +public class OptionSetListCliCommand : ProfiledCliCommand { - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityOptionSetListGlobalCliCommand)); + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(OptionSetListCliCommand)); protected override async Task ExecuteAsync() { @@ -42,8 +41,8 @@ private static void PrintOptionSetsTable(IReadOnlyList r.Name.Length), 4, 60); int displayWidth = Math.Clamp(rows.Max(r => (r.DisplayName ?? "").Length), 12, 48); int typeWidth = Math.Clamp(rows.Max(r => r.OptionSetType.Length), 4, 16); - int countWidth = 7; // "Options" - int customWidth = 6; // "Custom" + int countWidth = 7; + int customWidth = 6; string header = $"{"Name".PadRight(nameWidth)} | " + @@ -71,7 +70,6 @@ private static void PrintOptionSetsTable(IReadOnlyListTruncate a string to fit the column width, appending a dot if trimmed. private static string Truncate(string value, int maxWidth) => value.Length > maxWidth ? value[..(maxWidth - 1)] + "." : value; } diff --git a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetDeleteOptionCliCommand.cs b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetRemoveOptionCliCommand.cs similarity index 50% rename from src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetDeleteOptionCliCommand.cs rename to src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetRemoveOptionCliCommand.cs index 28bebba..c5f818e 100644 --- a/src/TALXIS.CLI.Features.Environment/Entity/EntityOptionSetDeleteOptionCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetRemoveOptionCliCommand.cs @@ -6,57 +6,55 @@ using TALXIS.CLI.Core.DependencyInjection; using TALXIS.CLI.Logging; -namespace TALXIS.CLI.Features.Environment.Entity; +namespace TALXIS.CLI.Features.Environment.OptionSet; /// -/// Deletes an option value from a local or global option set. -/// Usage: txc environment entity optionset option delete --value <int> (--global-optionset <name> | --entity <name> --attribute <name>) +/// Removes an option value from a global or local option set. +/// Global: txc environment optionset remove-option --name <schema-name> --value <int> +/// Local: txc environment optionset remove-option --entity <name> --attribute <name> --value <int> /// -[CliDestructive("Permanently deletes the option from the option set.")] +[CliDestructive("Permanently removes the option value from the option set.")] [CliCommand( - Name = "delete", - Description = "Delete an option value from a local or global option set." + Name = "remove", + Description = "Remove an option value from a global or local option set." )] #pragma warning disable TXC003 -public class EntityOptionSetDeleteOptionCliCommand : StagedCliCommand, IDestructiveCommand +public class OptionSetRemoveOptionCliCommand : StagedCliCommand, IDestructiveCommand { - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityOptionSetDeleteOptionCliCommand)); + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(OptionSetRemoveOptionCliCommand)); - [CliOption(Name = "--yes", Description = "Skip interactive confirmation for this destructive operation.", Required = false)] + [CliOption(Name = "--yes", Description = "Skip interactive confirmation.", Required = false)] public bool Yes { get; set; } - [CliOption(Name = "--entity", Description = "The logical name of the entity (for local option sets).", Required = false)] + [CliOption(Name = "--name", Description = "Schema name of the global option set.", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--entity", Description = "Entity logical name (for local option sets).", Required = false)] public string? Entity { get; set; } - [CliOption(Name = "--attribute", Description = "The logical name of the attribute (for local option sets).", Required = false)] + [CliOption(Name = "--attribute", Description = "Attribute logical name (for local option sets).", Required = false)] public string? Attribute { get; set; } - [CliOption(Name = "--global-optionset", Description = "The name of the global option set (mutually exclusive with --entity/--attribute).", Required = false)] - public string? GlobalOptionset { get; set; } - - [CliOption(Name = "--value", Description = "The integer value of the option to remove.", Required = true)] + [CliOption(Name = "--value", Description = "Integer value of the option to remove.", Required = true)] public int Value { get; set; } protected override async Task ExecuteAsync() { ValidateExecutionMode(); - // Validate mutually exclusive options. - bool hasGlobal = !string.IsNullOrWhiteSpace(GlobalOptionset); + bool hasGlobal = !string.IsNullOrWhiteSpace(Name); bool hasLocal = !string.IsNullOrWhiteSpace(Entity) || !string.IsNullOrWhiteSpace(Attribute); if (hasGlobal && hasLocal) { - Logger.LogError("Specify either --global-optionset or --entity/--attribute, not both."); + Logger.LogError("Specify either --name (global) or --entity + --attribute (local), not both."); return ExitError; } - if (!hasGlobal && !hasLocal) { - Logger.LogError("Specify --global-optionset for a global option set, or --entity and --attribute for a local one."); + Logger.LogError("Specify --name for a global option set, or --entity and --attribute for a local one."); return ExitError; } - if (hasLocal && (string.IsNullOrWhiteSpace(Entity) || string.IsNullOrWhiteSpace(Attribute))) { Logger.LogError("Both --entity and --attribute are required for local option sets."); @@ -65,33 +63,33 @@ protected override async Task ExecuteAsync() if (Stage) { - string stageTarget = hasGlobal ? GlobalOptionset! : $"{Entity}.{Attribute}"; + string stageTarget = hasGlobal ? Name! : $"{Entity}.{Attribute}"; var store = TxcServices.Get(); store.Add(new StagedOperation { Category = "schema", OperationType = "DELETE", - TargetType = "optionset", + TargetType = "optionset-option", TargetDescription = stageTarget, Details = $"remove option value: {Value}", Parameters = new Dictionary { ["entity"] = Entity, ["attribute"] = Attribute, - ["globalOptionset"] = GlobalOptionset, + ["name"] = Name, ["value"] = Value } }); - OutputWriter.WriteLine($"Staged: DELETE option {Value} from {stageTarget}"); + OutputWriter.WriteLine($"Staged: REMOVE option {Value} from {stageTarget}"); return ExitSuccess; } var service = TxcServices.Get(); await service.DeleteOptionAsync( - Profile, Entity, Attribute, GlobalOptionset, Value, CancellationToken.None + Profile, Entity, Attribute, Name, Value, CancellationToken.None ).ConfigureAwait(false); - string target = hasGlobal ? $"global option set '{GlobalOptionset}'" : $"attribute '{Attribute}' on entity '{Entity}'"; + string target = hasGlobal ? $"global option set '{Name}'" : $"attribute '{Attribute}' on entity '{Entity}'"; OutputWriter.WriteLine($"Option value {Value} removed from {target}."); return ExitSuccess; } diff --git a/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetShowCliCommand.cs b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetShowCliCommand.cs new file mode 100644 index 0000000..77c443e --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/OptionSet/OptionSetShowCliCommand.cs @@ -0,0 +1,201 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.OptionSet; + +/// +/// Shows all values and labels for an option set — global or local. +/// Usage: txc environment optionset describe --name <schema-name> +/// Usage: txc environment optionset describe --entity <name> --attribute <name> +/// +[CliReadOnly] +[CliCommand( + Name = "show", + Description = "Show values and labels for a global or local option set." +)] +#pragma warning disable TXC003 +public class OptionSetShowCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(OptionSetShowCliCommand)); + + [CliOption(Name = "--name", Description = "Schema name of a global option set.", Required = false)] + public string? Name { get; set; } + + [CliOption(Name = "--entity", Description = "Entity logical name (for local option sets).", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--attribute", Description = "Attribute logical name (for local option sets).", Required = false)] + public string? Attribute { get; set; } + + [CliOption(Name = "--language", Description = "Language code (LCID) for labels, e.g. 1033 (English), 1029 (Czech). Defaults to the connection user's language.", Required = false)] + public int? Language { get; set; } + + [CliOption(Name = "--label", Description = "Look up the integer value for a specific label text (case-insensitive). Outputs only the value for piping.", Required = false)] + public string? LabelLookup { get; set; } + + [CliOption(Name = "--value", Description = "Look up the label text for a specific integer value. Outputs only the label for piping.", Required = false)] + public int? ValueLookup { get; set; } + + protected override async Task ExecuteAsync() + { + bool hasGlobal = !string.IsNullOrWhiteSpace(Name); + bool hasLocal = !string.IsNullOrWhiteSpace(Entity) || !string.IsNullOrWhiteSpace(Attribute); + + if (hasGlobal && hasLocal) + { + Logger.LogError("Specify either --name (global) or --entity + --attribute (local), not both."); + return ExitError; + } + if (!hasGlobal && !hasLocal) + { + Logger.LogError("Specify --name for a global option set, or --entity and --attribute for a local one."); + return ExitError; + } + if (hasLocal && (string.IsNullOrWhiteSpace(Entity) || string.IsNullOrWhiteSpace(Attribute))) + { + Logger.LogError("Both --entity and --attribute are required for local option sets."); + return ExitError; + } + + // Collect options from global or local source + IReadOnlyList options; + + if (hasGlobal) + { + var service = TxcServices.Get(); + var detail = await service.DescribeGlobalOptionSetAsync(Profile, Name!, Language, CancellationToken.None).ConfigureAwait(false); + options = detail.Options; + + if (LabelLookup is null && ValueLookup is null) + { + OutputFormatter.WriteData(detail, PrintGlobalDetail); + return ExitSuccess; + } + } + else + { + var metaService = TxcServices.Get(); + var attrDetail = await metaService.GetAttributeDetailAsync(Profile, Entity!, Attribute!, CancellationToken.None).ConfigureAwait(false); + + // Extract options from attribute detail + options = ExtractOptionsFromAttributeDetail(attrDetail); + + if (LabelLookup is null && ValueLookup is null) + { + OutputFormatter.WriteData(attrDetail, PrintAttributeDetail); + return ExitSuccess; + } + } + + // Label → Value lookup + if (LabelLookup is not null) + { + var match = options.FirstOrDefault(o => + string.Equals(o.Label, LabelLookup, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + Logger.LogError("No option found with label '{Label}'.", LabelLookup); + return ExitError; + } + OutputWriter.WriteLine(match.Value.ToString()); + return ExitSuccess; + } + + // Value → Label lookup + if (ValueLookup is not null) + { + var match = options.FirstOrDefault(o => o.Value == ValueLookup.Value); + if (match is null) + { + Logger.LogError("No option found with value {Value}.", ValueLookup.Value); + return ExitError; + } + OutputWriter.WriteLine(match.Label ?? ""); + return ExitSuccess; + } + + return ExitSuccess; + } + + private static void PrintGlobalDetail(GlobalOptionSetDetailRecord detail) + { + OutputWriter.WriteLine($"Name: {detail.Name}"); + OutputWriter.WriteLine($"Display Name: {detail.DisplayName ?? "-"}"); + OutputWriter.WriteLine($"Description: {detail.Description ?? "-"}"); + OutputWriter.WriteLine($"Type: {detail.OptionSetType}"); + OutputWriter.WriteLine($"Custom: {detail.IsCustomOptionSet}"); + OutputWriter.WriteLine(); + PrintOptions(detail.Options); + } + + private static void PrintAttributeDetail(Dictionary detail) + { + if (detail.TryGetValue("Logical Name", out var ln)) OutputWriter.WriteLine($"Attribute: {ln}"); + if (detail.TryGetValue("Option Set Name", out var osn)) OutputWriter.WriteLine($"Option Set: {osn}"); + if (detail.TryGetValue("Is Global Option Set", out var ig)) OutputWriter.WriteLine($"Global: {ig}"); + OutputWriter.WriteLine(); + + if (detail.TryGetValue("Options", out var opts) && opts is IEnumerable options) + { + var records = new List(); + foreach (var opt in options) + { + if (opt is Dictionary dict) + { + var label = dict.GetValueOrDefault("Label")?.ToString(); + var value = dict.GetValueOrDefault("Value") is int v ? v : 0; + records.Add(new OptionValueRecord(value, label, null)); + } + } + PrintOptions(records); + } + else + { + OutputWriter.WriteLine(" (not an option set attribute)"); + } + } + + private static IReadOnlyList ExtractOptionsFromAttributeDetail(Dictionary detail) + { + if (detail.TryGetValue("Options", out var opts) && opts is IEnumerable options) + { + var records = new List(); + foreach (var opt in options) + { + if (opt is Dictionary dict) + { + var label = dict.GetValueOrDefault("Label")?.ToString(); + var value = dict.GetValueOrDefault("Value") is int v ? v : 0; + records.Add(new OptionValueRecord(value, label, null)); + } + } + return records; + } + return Array.Empty(); + } + + private static void PrintOptions(IReadOnlyList options) + { + OutputWriter.WriteLine($"Options ({options.Count}):"); + if (options.Count == 0) + { + OutputWriter.WriteLine(" (none)"); + return; + } + + int valueWidth = Math.Max(5, options.Max(o => o.Value.ToString().Length)); + int labelWidth = Math.Clamp(options.Max(o => (o.Label ?? "").Length), 5, 60); + + OutputWriter.WriteLine($" {"Value".PadRight(valueWidth)} | {"Label".PadRight(labelWidth)}"); + OutputWriter.WriteLine($" {new string('-', valueWidth + labelWidth + 3)}"); + + foreach (var opt in options) + { + OutputWriter.WriteLine($" {opt.Value.ToString().PadRight(valueWidth)} | {(opt.Label ?? "-").PadRight(labelWidth)}"); + } + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs index ad8b887..a561d4d 100644 --- a/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/AddProjectsToSlnPostActionProcessor.cs @@ -177,16 +177,19 @@ private static bool AddProjectsToSolution(string slnFile, IEnumerable pr }; process.Start(); + // Read streams before WaitForExit to avoid deadlock when pipe buffer fills. + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); process.WaitForExit(); if (process.ExitCode == 0) { + if (!string.IsNullOrEmpty(stdout)) + _logger.LogInformation("dotnet sln add: {Output}", stdout.Trim()); _logger.LogInformation("Successfully added {Count} project(s) to solution", projectFilesList.Count); return true; } - var stdout = process.StandardOutput.ReadToEnd(); - var stderr = process.StandardError.ReadToEnd(); _logger.LogError("dotnet sln add failed (exit code {ExitCode})", process.ExitCode); if (!string.IsNullOrEmpty(stdout)) _logger.LogError("stdout: {Output}", stdout); if (!string.IsNullOrEmpty(stderr)) _logger.LogError("stderr: {Output}", stderr); diff --git a/src/TALXIS.CLI.MCP/RootsService.cs b/src/TALXIS.CLI.MCP/RootsService.cs index 23f24f5..000678a 100644 --- a/src/TALXIS.CLI.MCP/RootsService.cs +++ b/src/TALXIS.CLI.MCP/RootsService.cs @@ -62,7 +62,10 @@ public RootsService(McpServer server, ILogger? logger = null) } if (result.Roots is not { Count: > 0 }) + { + _logger?.LogWarning("MCP client returned no workspace roots. Relative paths will resolve against the server process directory ({Cwd}). Use absolute paths to avoid unexpected resolution.", Environment.CurrentDirectory); return null; + } return ConvertFileUriToPath(result.Roots[0].Uri); } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs index 5b99155..21f5f26 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEntityMetadataService.cs @@ -87,16 +87,44 @@ public async Task> DescribeEntityAsync( return attributes .OrderBy(a => a.LogicalName, StringComparer.OrdinalIgnoreCase) - .Select(a => new EntityAttributeRecord( - LogicalName: a.LogicalName, - SchemaName: a.SchemaName, - DisplayName: a.DisplayName?.UserLocalizedLabel?.Label, - AttributeTypeName: a.AttributeTypeName?.Value ?? a.AttributeType?.ToString() ?? "Unknown", - IsCustomAttribute: a.IsCustomAttribute == true, - IsPrimaryId: a.LogicalName == entityMeta.PrimaryIdAttribute, - IsPrimaryName: a.LogicalName == entityMeta.PrimaryNameAttribute, - MaxLength: a is StringAttributeMetadata strAttr ? strAttr.MaxLength : null, - Description: a.Description?.UserLocalizedLabel?.Label)) + .Select(a => + { + string? optionSetName = null; + string? optionValues = null; + if (a is PicklistAttributeMetadata p && p.OptionSet != null) + { + optionSetName = p.OptionSet.Name; + optionValues = FormatOptionValues(p.OptionSet.Options); + } + else if (a is StatusAttributeMetadata st && st.OptionSet != null) + { + optionSetName = st.OptionSet.Name; + optionValues = FormatOptionValues(st.OptionSet.Options); + } + else if (a is StateAttributeMetadata sa && sa.OptionSet != null) + { + optionSetName = sa.OptionSet.Name; + optionValues = FormatOptionValues(sa.OptionSet.Options); + } + else if (a is MultiSelectPicklistAttributeMetadata ms && ms.OptionSet != null) + { + optionSetName = ms.OptionSet.Name; + optionValues = FormatOptionValues(ms.OptionSet.Options); + } + + return new EntityAttributeRecord( + LogicalName: a.LogicalName, + SchemaName: a.SchemaName, + DisplayName: a.DisplayName?.UserLocalizedLabel?.Label, + AttributeTypeName: a.AttributeTypeName?.Value ?? a.AttributeType?.ToString() ?? "Unknown", + IsCustomAttribute: a.IsCustomAttribute == true, + IsPrimaryId: a.LogicalName == entityMeta.PrimaryIdAttribute, + IsPrimaryName: a.LogicalName == entityMeta.PrimaryNameAttribute, + MaxLength: a is StringAttributeMetadata strAttr ? strAttr.MaxLength : null, + Description: a.Description?.UserLocalizedLabel?.Label, + OptionSetName: optionSetName, + OptionValues: optionValues); + }) .ToList(); } @@ -824,6 +852,16 @@ public async Task UpdateEntityAsync( // ===== Private helpers ===== + /// Formats option set options as "value:label, value:label" for compact display. + private static string? FormatOptionValues(OptionMetadataCollection? options) + { + if (options is null or { Count: 0 }) + return null; + return string.Join(", ", options + .Where(o => o.Value.HasValue) + .Select(o => $"{o.Value!.Value}:{o.Label?.UserLocalizedLabel?.Label ?? "?"}")); + } + /// Case-insensitive contains check that handles null values safely. private static bool Contains(string? value, string search) => value is not null && value.Contains(search, StringComparison.OrdinalIgnoreCase); diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseOptionSetService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseOptionSetService.cs index 0715b03..1a65acd 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseOptionSetService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseOptionSetService.cs @@ -53,7 +53,7 @@ public async Task InsertOptionAsync( string? profileName, string? entityName, string? attributeName, - string? globalOptionSetName, + string? optionSetName, string label, int? value, CancellationToken ct) @@ -68,10 +68,9 @@ public async Task InsertOptionAsync( if (value.HasValue) request.Value = value.Value; - if (!string.IsNullOrWhiteSpace(globalOptionSetName)) + if (!string.IsNullOrWhiteSpace(optionSetName)) { - // Global option set — set OptionSetName only. - request.OptionSetName = globalOptionSetName; + request.OptionSetName = optionSetName; } else { @@ -88,7 +87,7 @@ public async Task DeleteOptionAsync( string? profileName, string? entityName, string? attributeName, - string? globalOptionSetName, + string? optionSetName, int value, CancellationToken ct) { @@ -99,9 +98,9 @@ public async Task DeleteOptionAsync( Value = value }; - if (!string.IsNullOrWhiteSpace(globalOptionSetName)) + if (!string.IsNullOrWhiteSpace(optionSetName)) { - request.OptionSetName = globalOptionSetName; + request.OptionSetName = optionSetName; } else { @@ -124,6 +123,42 @@ public async Task DeleteGlobalOptionSetAsync( await conn.Client.ExecuteAsync(request, ct).ConfigureAwait(false); } + /// + public async Task DescribeGlobalOptionSetAsync( + string? profileName, + string optionSetName, + int? languageCode, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + + conn.Client.ForceServerMetadataCacheConsistency = true; + + var request = new RetrieveOptionSetRequest { Name = optionSetName }; + var response = (RetrieveOptionSetResponse) + await conn.Client.ExecuteAsync(request, ct).ConfigureAwait(false); + + var os = response.OptionSetMetadata; + var options = os is OptionSetMetadata osm + ? osm.Options + .Where(o => o.Value.HasValue) + .OrderBy(o => o.Value!.Value) + .Select(o => new OptionValueRecord( + Value: o.Value!.Value, + Label: LabelHelper.GetLabel(o.Label, languageCode), + Description: LabelHelper.GetLabel(o.Description, languageCode))) + .ToList() + : new List(); + + return new GlobalOptionSetDetailRecord( + Name: os.Name, + DisplayName: LabelHelper.GetLabel(os.DisplayName, languageCode), + Description: LabelHelper.GetLabel(os.Description, languageCode), + OptionSetType: os.OptionSetType?.ToString() ?? "Unknown", + IsCustomOptionSet: os.IsCustomOptionSet == true, + Options: options); + } + /// public async Task> ListGlobalOptionSetsAsync( string? profileName, diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/LabelHelper.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/LabelHelper.cs new file mode 100644 index 0000000..306aedc --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/LabelHelper.cs @@ -0,0 +1,29 @@ +using Microsoft.Xrm.Sdk; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Services; + +/// +/// Resolves localized labels from Dataverse metadata, supporting specific language codes. +/// +internal static class LabelHelper +{ + /// + /// Gets the label text for a specific language, falling back to . + /// + /// The Dataverse label containing localized strings. + /// Optional LCID (e.g. 1033 for English, 1029 for Czech). Null = user's language. + internal static string? GetLabel(Label? label, int? languageCode = null) + { + if (label is null) return null; + + if (languageCode.HasValue && label.LocalizedLabels is { Count: > 0 }) + { + var match = label.LocalizedLabels + .FirstOrDefault(l => l.LanguageCode == languageCode.Value); + if (match?.Label is not null) + return match.Label; + } + + return label.UserLocalizedLabel?.Label; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs index e2b772b..b8efeaf 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/ChangesetApplier.cs @@ -1133,37 +1133,8 @@ private static OptionMetadataInput[] GetOptionsArray(StagedOperation op) // CSV string: "Label1:100000000,Label2:100000001" or "Label1,Label2" (auto-valued) if (raw is string csv && !string.IsNullOrWhiteSpace(csv)) - return ParseOptionsCsv(csv); + return OptionMetadataInput.ParseCsv(csv); return Array.Empty(); } - - /// - /// Parses a CSV options string into items. - /// Matches the format used by EntityOptionSetCreateGlobalCliCommand.ParseOptions. - /// - private static OptionMetadataInput[] ParseOptionsCsv(string csv) - { - var entries = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (entries.Length == 0) - return Array.Empty(); - - var results = new OptionMetadataInput[entries.Length]; - int autoValue = DefaultOptionValueStart; - - for (int i = 0; i < entries.Length; i++) - { - var parts = entries[i].Split(':', 2); - if (parts.Length == 2 && int.TryParse(parts[1].Trim(), out int v)) - { - results[i] = new OptionMetadataInput(parts[0].Trim(), v); - } - else - { - results[i] = new OptionMetadataInput(parts[0].Trim(), autoValue++); - } - } - - return results; - } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseBulkService.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseBulkService.cs index 2ef0301..9207a59 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseBulkService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseBulkService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; +using Microsoft.Xrm.Sdk.Metadata; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Platform.Dataverse.Runtime; @@ -22,8 +23,9 @@ public async Task CreateMultipleAsync( { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var metadata = await RetrieveAttributeMetadataAsync(conn, entityLogicalName, ct).ConfigureAwait(false); var entities = new EntityCollection( - records.Select(r => EntityJsonConverter.JsonToEntity(entityLogicalName, r)).ToList()) + records.Select(r => EntityJsonConverter.JsonToEntity(entityLogicalName, r, metadata)).ToList()) { EntityName = entityLogicalName }; @@ -46,13 +48,14 @@ public async Task UpdateMultipleAsync( { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var metadata = await RetrieveAttributeMetadataAsync(conn, entityLogicalName, ct).ConfigureAwait(false); // Each record must contain the entity primary ID field so the SDK // knows which record to update. The ID is resolved inside // EntityJsonConverter.JsonToEntity via the standard attribute mapping; // the caller is responsible for including the primary key field // (e.g. "accountid") in the JSON payload. var entities = new EntityCollection( - records.Select(r => EntityJsonConverter.JsonToEntity(entityLogicalName, r)).ToList()) + records.Select(r => EntityJsonConverter.JsonToEntity(entityLogicalName, r, metadata)).ToList()) { EntityName = entityLogicalName }; @@ -76,8 +79,9 @@ public async Task UpsertMultipleAsync( { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var metadata = await RetrieveAttributeMetadataAsync(conn, entityLogicalName, ct).ConfigureAwait(false); var entities = new EntityCollection( - records.Select(r => EntityJsonConverter.JsonToEntity(entityLogicalName, r)).ToList()) + records.Select(r => EntityJsonConverter.JsonToEntity(entityLogicalName, r, metadata)).ToList()) { EntityName = entityLogicalName }; @@ -109,4 +113,17 @@ public async Task UpsertMultipleAsync( FailedCount: records.Count - (created + updated), CreatedIds: createdIds); } + + private static async Task RetrieveAttributeMetadataAsync( + DataverseConnection conn, string entityLogicalName, CancellationToken ct) + { + var request = new RetrieveEntityRequest + { + LogicalName = entityLogicalName, + EntityFilters = EntityFilters.Attributes, + RetrieveAsIfPublished = true + }; + var response = (RetrieveEntityResponse)await conn.Client.ExecuteAsync(request, ct).ConfigureAwait(false); + return response.EntityMetadata; + } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseRecordService.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseRecordService.cs index 6836b54..4ffa47f 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseRecordService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/DataverseRecordService.cs @@ -1,5 +1,7 @@ using System.Text.Json; using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; +using Microsoft.Xrm.Sdk.Metadata; using Microsoft.Xrm.Sdk.Query; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Platform.Dataverse.Runtime; @@ -40,7 +42,8 @@ public async Task CreateAsync( { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); - var entity = EntityJsonConverter.JsonToEntity(entityLogicalName, attributes); + var metadata = await RetrieveAttributeMetadataAsync(conn, entityLogicalName, ct).ConfigureAwait(false); + var entity = EntityJsonConverter.JsonToEntity(entityLogicalName, attributes, metadata); return await conn.Client.CreateAsync(entity, ct).ConfigureAwait(false); } @@ -54,7 +57,8 @@ public async Task UpdateAsync( { using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); - var entity = EntityJsonConverter.JsonToEntity(entityLogicalName, attributes, recordId); + var metadata = await RetrieveAttributeMetadataAsync(conn, entityLogicalName, ct).ConfigureAwait(false); + var entity = EntityJsonConverter.JsonToEntity(entityLogicalName, attributes, metadata, recordId); await conn.Client.UpdateAsync(entity, ct).ConfigureAwait(false); } @@ -69,4 +73,17 @@ public async Task DeleteAsync( await conn.Client.DeleteAsync(entityLogicalName, recordId, ct).ConfigureAwait(false); } + + private static async Task RetrieveAttributeMetadataAsync( + DataverseConnection conn, string entityLogicalName, CancellationToken ct) + { + var request = new RetrieveEntityRequest + { + LogicalName = entityLogicalName, + EntityFilters = EntityFilters.Attributes, + RetrieveAsIfPublished = true + }; + var response = (RetrieveEntityResponse)await conn.Client.ExecuteAsync(request, ct).ConfigureAwait(false); + return response.EntityMetadata; + } } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/EntityJsonConverter.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/EntityJsonConverter.cs index f83c549..024ec54 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Data/EntityJsonConverter.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/EntityJsonConverter.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Metadata; namespace TALXIS.CLI.Platform.Dataverse.Data; @@ -16,10 +17,38 @@ internal static class EntityJsonConverter /// A JSON object whose properties map to entity attributes. /// Optional explicit record ID; when set, is assigned. public static Entity JsonToEntity(string entityLogicalName, JsonElement json, Guid? id = null) + => JsonToEntity(entityLogicalName, json, metadata: null, id: id); + + /// + /// Builds a Dataverse from a flat JSON object, using entity metadata + /// to correctly wrap special types (OptionSetValue, Money, EntityReference). + /// + /// Logical name of the target entity (e.g. account). + /// A JSON object whose properties map to entity attributes. + /// + /// Optional entity metadata (with ). + /// When provided, numeric values are automatically wrapped as + /// or based on the attribute type. Not cached — callers should fetch + /// fresh metadata per operation since the CLI itself can mutate schema. + /// + /// Optional explicit record ID; when set, is assigned. + public static Entity JsonToEntity(string entityLogicalName, JsonElement json, EntityMetadata? metadata, Guid? id = null) { if (json.ValueKind != JsonValueKind.Object) throw new ArgumentException("Expected a JSON object representing a single record.", nameof(json)); + // Build a lookup from attribute logical name to metadata for type-aware conversion. + Dictionary? attrLookup = null; + if (metadata?.Attributes is { Length: > 0 }) + { + attrLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var attr in metadata.Attributes) + { + if (attr.LogicalName is not null) + attrLookup[attr.LogicalName] = attr; + } + } + var entity = new Entity(entityLogicalName); if (id.HasValue) @@ -27,7 +56,9 @@ public static Entity JsonToEntity(string entityLogicalName, JsonElement json, Gu foreach (var prop in json.EnumerateObject()) { - var value = ConvertJsonValue(prop.Value); + AttributeMetadata? attrMeta = null; + attrLookup?.TryGetValue(prop.Name, out attrMeta); + var value = ConvertJsonValue(prop.Value, attrMeta); if (value is null) continue; @@ -89,15 +120,35 @@ public static JsonElement EntityToJson(Entity entity, bool includeAnnotations = return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); } - private static object? ConvertJsonValue(JsonElement element) + private static object? ConvertJsonValue(JsonElement element, AttributeMetadata? attrMeta = null) { switch (element.ValueKind) { case JsonValueKind.String: + // When metadata indicates a lookup and the value is a bare GUID string, + // wrap it as an EntityReference (single-target lookups only). + if (attrMeta is LookupAttributeMetadata lookup + && lookup.Targets is { Length: 1 } + && Guid.TryParse(element.GetString(), out var lookupGuid)) + { + return new EntityReference(lookup.Targets[0], lookupGuid); + } return element.GetString(); case JsonValueKind.Number: - // Try integer types first, then floating-point. + // Use attribute metadata to wrap special Dataverse types. + if (attrMeta is PicklistAttributeMetadata or StatusAttributeMetadata or StateAttributeMetadata) + { + if (element.TryGetInt32(out var optVal)) + return new OptionSetValue(optVal); + } + if (attrMeta is MoneyAttributeMetadata) + { + if (element.TryGetDecimal(out var moneyVal)) + return new Money(moneyVal); + } + + // Default: try integer types first, then floating-point. if (element.TryGetInt32(out var i32)) return i32; if (element.TryGetInt64(out var i64)) return i64; if (element.TryGetDecimal(out var dec)) return dec; @@ -127,7 +178,17 @@ public static JsonElement EntityToJson(Entity entity, bool includeAnnotations = return null; case JsonValueKind.Array: - // Arrays are not directly supported as attribute values. + // Multi-select picklists: JSON array of integers → OptionSetValueCollection + if (attrMeta is MultiSelectPicklistAttributeMetadata) + { + var collection = new OptionSetValueCollection(); + foreach (var item in element.EnumerateArray()) + { + if (item.TryGetInt32(out var val)) + collection.Add(new OptionSetValue(val)); + } + return collection.Count > 0 ? collection : null; + } return null; default: @@ -178,10 +239,17 @@ private static void WriteAttribute(Utf8JsonWriter writer, string key, object? va case Money money: writer.WriteNumber(key, money.Value); break; + case OptionSetValueCollection osvc: + writer.WriteStartArray(key); + foreach (var item in osvc) + writer.WriteNumberValue(item.Value); + writer.WriteEndArray(); + break; default: // Fall back to ToString() for types we don't explicitly handle. writer.WriteString(key, value.ToString()); break; } } + }