diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCmdlet.cs
new file mode 100644
index 000000000..62877a986
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCmdlet.cs
@@ -0,0 +1,123 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Messages;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Management.Automation;
+using System.Text;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Retrieves ribbon customizations from a Dataverse environment. Ribbons can be retrieved for specific entities or for the application-wide ribbon.
+ ///
+ [Cmdlet(VerbsCommon.Get, "DataverseRibbon")]
+ [OutputType(typeof(PSObject))]
+ public class GetDataverseRibbonCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to retrieve the ribbon.
+ /// If not specified, retrieves the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table for which to retrieve the ribbon. If not specified, retrieves the application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ string ribbonDiffXml = null;
+
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Retrieve entity-specific ribbon
+ WriteVerbose($"Retrieving ribbon for entity: {Entity}");
+ ribbonDiffXml = RetrieveEntityRibbon(Entity);
+ WriteVerbose($"Retrieved entity ribbon, length: {ribbonDiffXml?.Length ?? 0}");
+ }
+ else
+ {
+ // Retrieve application-wide ribbon
+ WriteVerbose("Retrieving application-wide ribbon");
+ ribbonDiffXml = RetrieveApplicationRibbon();
+ WriteVerbose($"Retrieved application ribbon, length: {ribbonDiffXml?.Length ?? 0}");
+ }
+
+ // Create output object
+ PSObject output = new PSObject();
+ output.Properties.Add(new PSNoteProperty("Entity", Entity));
+ output.Properties.Add(new PSNoteProperty("RibbonDiffXml", ribbonDiffXml));
+ output.Properties.Add(new PSNoteProperty("IsApplicationRibbon", string.IsNullOrEmpty(Entity)));
+
+ WriteObject(output);
+ }
+
+ private string RetrieveEntityRibbon(string entityLogicalName)
+ {
+ try
+ {
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = entityLogicalName,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+
+ if (response?.CompressedEntityXml == null)
+ {
+ WriteWarning($"Entity '{entityLogicalName}' not found or has no ribbon customizations.");
+ return null;
+ }
+
+ // Decompress the XML
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to retrieve ribbon for entity '{entityLogicalName}': {ex.Message}", ex);
+ }
+ }
+
+ private string RetrieveApplicationRibbon()
+ {
+ try
+ {
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+
+ if (response?.CompressedApplicationRibbonXml == null)
+ {
+ WriteWarning("No application ribbon found.");
+ return null;
+ }
+
+ // Decompress the XML
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to retrieve application ribbon: {ex.Message}", ex);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCommandDefinitionCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCommandDefinitionCmdlet.cs
new file mode 100644
index 000000000..a0d472e1a
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCommandDefinitionCmdlet.cs
@@ -0,0 +1,151 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Management.Automation;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Retrieves command definitions from a Dataverse ribbon (entity-specific or application-wide).
+ ///
+ [Cmdlet(VerbsCommon.Get, "DataverseRibbonCommandDefinition")]
+ [OutputType(typeof(PSObject))]
+ public class GetDataverseRibbonCommandDefinitionCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to retrieve ribbon command definitions.
+ /// If not specified, retrieves from the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table. If not specified, retrieves from application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the ID of a specific command definition to retrieve.
+ ///
+ [Parameter(HelpMessage = "ID of a specific command definition to retrieve")]
+ public string CommandId { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ // Retrieve the ribbon
+ string ribbonDiffXml = RetrieveRibbon();
+
+ if (string.IsNullOrEmpty(ribbonDiffXml))
+ {
+ WriteVerbose("No ribbon customizations found");
+ return;
+ }
+
+ // Parse the XML
+ XDocument doc = XDocument.Parse(ribbonDiffXml);
+ XNamespace ns = doc.Root.Name.Namespace;
+
+ var commandDefinitionsElement = doc.Root.Element(ns + "CommandDefinitions");
+ if (commandDefinitionsElement == null)
+ {
+ WriteVerbose("No CommandDefinitions element found in ribbon");
+ return;
+ }
+
+ var commandDefinitions = commandDefinitionsElement.Elements(ns + "CommandDefinition");
+
+ if (!string.IsNullOrEmpty(CommandId))
+ {
+ commandDefinitions = commandDefinitions.Where(cd => cd.Attribute("Id")?.Value == CommandId);
+ }
+
+ foreach (var commandDef in commandDefinitions)
+ {
+ PSObject output = new PSObject();
+ output.Properties.Add(new PSNoteProperty("Entity", Entity));
+ output.Properties.Add(new PSNoteProperty("Id", commandDef.Attribute("Id")?.Value));
+
+ // Get EnableRules
+ var enableRulesElement = commandDef.Element(ns + "EnableRules");
+ if (enableRulesElement != null)
+ {
+ var enableRules = enableRulesElement.Elements(ns + "EnableRule")
+ .Select(er => er.Attribute("Id")?.Value)
+ .Where(id => !string.IsNullOrEmpty(id))
+ .ToArray();
+ output.Properties.Add(new PSNoteProperty("EnableRules", enableRules));
+ }
+
+ // Get DisplayRules
+ var displayRulesElement = commandDef.Element(ns + "DisplayRules");
+ if (displayRulesElement != null)
+ {
+ var displayRules = displayRulesElement.Elements(ns + "DisplayRule")
+ .Select(dr => dr.Attribute("Id")?.Value)
+ .Where(id => !string.IsNullOrEmpty(id))
+ .ToArray();
+ output.Properties.Add(new PSNoteProperty("DisplayRules", displayRules));
+ }
+
+ // Get Actions
+ var actionsElement = commandDef.Element(ns + "Actions");
+ if (actionsElement != null)
+ {
+ var actions = actionsElement.Elements()
+ .Select(a => new { Type = a.Name.LocalName, Library = a.Attribute("Library")?.Value, FunctionName = a.Attribute("FunctionName")?.Value })
+ .ToArray();
+ output.Properties.Add(new PSNoteProperty("Actions", actions));
+ }
+
+ output.Properties.Add(new PSNoteProperty("Xml", commandDef.ToString()));
+
+ WriteObject(output);
+ }
+ }
+
+ private string RetrieveRibbon()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Retrieve entity-specific ribbon
+ WriteVerbose($"Retrieving ribbon for entity: {Entity}");
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = Entity,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ else
+ {
+ // Retrieve application-wide ribbon
+ WriteVerbose("Retrieving application-wide ribbon");
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCustomActionCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCustomActionCmdlet.cs
new file mode 100644
index 000000000..790a511da
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonCustomActionCmdlet.cs
@@ -0,0 +1,137 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Management.Automation;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Retrieves custom actions from a Dataverse ribbon (entity-specific or application-wide).
+ ///
+ [Cmdlet(VerbsCommon.Get, "DataverseRibbonCustomAction")]
+ [OutputType(typeof(PSObject))]
+ public class GetDataverseRibbonCustomActionCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to retrieve ribbon custom actions.
+ /// If not specified, retrieves from the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table. If not specified, retrieves from application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the ID of a specific custom action to retrieve.
+ ///
+ [Parameter(HelpMessage = "ID of a specific custom action to retrieve")]
+ public string CustomActionId { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ // Retrieve the ribbon
+ string ribbonDiffXml = RetrieveRibbon();
+
+ if (string.IsNullOrEmpty(ribbonDiffXml))
+ {
+ WriteVerbose("No ribbon customizations found");
+ return;
+ }
+
+ // Parse the XML
+ XDocument doc = XDocument.Parse(ribbonDiffXml);
+ XNamespace ns = doc.Root.Name.Namespace;
+
+ var customActionsElement = doc.Root.Element(ns + "CustomActions");
+ if (customActionsElement == null)
+ {
+ WriteVerbose("No CustomActions element found in ribbon");
+ return;
+ }
+
+ var customActions = customActionsElement.Elements(ns + "CustomAction");
+
+ if (!string.IsNullOrEmpty(CustomActionId))
+ {
+ customActions = customActions.Where(ca => ca.Attribute("Id")?.Value == CustomActionId);
+ }
+
+ foreach (var customAction in customActions)
+ {
+ PSObject output = new PSObject();
+ output.Properties.Add(new PSNoteProperty("Entity", Entity));
+ output.Properties.Add(new PSNoteProperty("Id", customAction.Attribute("Id")?.Value));
+ output.Properties.Add(new PSNoteProperty("Location", customAction.Attribute("Location")?.Value));
+ output.Properties.Add(new PSNoteProperty("Sequence", customAction.Attribute("Sequence")?.Value));
+ output.Properties.Add(new PSNoteProperty("Title", customAction.Attribute("Title")?.Value));
+
+ // Get CommandUIDefinition
+ var commandUIDef = customAction.Element(ns + "CommandUIDefinition");
+ if (commandUIDef != null)
+ {
+ // Get the first child element (Button, CheckBox, etc.)
+ var control = commandUIDef.Elements().FirstOrDefault();
+ if (control != null)
+ {
+ output.Properties.Add(new PSNoteProperty("ControlType", control.Name.LocalName));
+ output.Properties.Add(new PSNoteProperty("ControlId", control.Attribute("Id")?.Value));
+ output.Properties.Add(new PSNoteProperty("Command", control.Attribute("Command")?.Value));
+ output.Properties.Add(new PSNoteProperty("LabelText", control.Attribute("LabelText")?.Value));
+ }
+ }
+
+ output.Properties.Add(new PSNoteProperty("Xml", customAction.ToString()));
+
+ WriteObject(output);
+ }
+ }
+
+ private string RetrieveRibbon()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Retrieve entity-specific ribbon
+ WriteVerbose($"Retrieving ribbon for entity: {Entity}");
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = Entity,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ else
+ {
+ // Retrieve application-wide ribbon
+ WriteVerbose("Retrieving application-wide ribbon");
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonRuleDefinitionCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonRuleDefinitionCmdlet.cs
new file mode 100644
index 000000000..37722ad6d
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/GetDataverseRibbonRuleDefinitionCmdlet.cs
@@ -0,0 +1,151 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Management.Automation;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Retrieves rule definitions from a Dataverse ribbon (entity-specific or application-wide).
+ ///
+ [Cmdlet(VerbsCommon.Get, "DataverseRibbonRuleDefinition")]
+ [OutputType(typeof(PSObject))]
+ public class GetDataverseRibbonRuleDefinitionCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to retrieve ribbon rule definitions.
+ /// If not specified, retrieves from the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table. If not specified, retrieves from application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the ID of a specific rule definition to retrieve.
+ ///
+ [Parameter(HelpMessage = "ID of a specific rule definition to retrieve")]
+ public string RuleId { get; set; }
+
+ ///
+ /// Gets or sets the type of rules to retrieve (EnableRule or DisplayRule).
+ ///
+ [Parameter(HelpMessage = "Type of rules to retrieve (EnableRule or DisplayRule)")]
+ [ValidateSet("EnableRule", "DisplayRule")]
+ public string RuleType { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ string ribbonDiffXml = RetrieveRibbon();
+
+ if (string.IsNullOrEmpty(ribbonDiffXml))
+ {
+ WriteVerbose("No ribbon customizations found");
+ return;
+ }
+
+ XDocument doc = XDocument.Parse(ribbonDiffXml);
+ XNamespace ns = doc.Root.Name.Namespace;
+
+ var ruleDefinitionsElement = doc.Root.Element(ns + "RuleDefinitions");
+ if (ruleDefinitionsElement == null)
+ {
+ WriteVerbose("No RuleDefinitions element found in ribbon");
+ return;
+ }
+
+ // Process EnableRules if no type specified or EnableRule requested
+ if (string.IsNullOrEmpty(RuleType) || RuleType == "EnableRule")
+ {
+ var enableRulesElement = ruleDefinitionsElement.Element(ns + "EnableRules");
+ if (enableRulesElement != null)
+ {
+ ProcessRules(enableRulesElement, ns + "EnableRule", "EnableRule");
+ }
+ }
+
+ // Process DisplayRules if no type specified or DisplayRule requested
+ if (string.IsNullOrEmpty(RuleType) || RuleType == "DisplayRule")
+ {
+ var displayRulesElement = ruleDefinitionsElement.Element(ns + "DisplayRules");
+ if (displayRulesElement != null)
+ {
+ ProcessRules(displayRulesElement, ns + "DisplayRule", "DisplayRule");
+ }
+ }
+ }
+
+ private void ProcessRules(XElement rulesContainer, XName ruleName, string ruleType)
+ {
+ var rules = rulesContainer.Elements(ruleName);
+
+ if (!string.IsNullOrEmpty(RuleId))
+ {
+ rules = rules.Where(r => r.Attribute("Id")?.Value == RuleId);
+ }
+
+ foreach (var rule in rules)
+ {
+ PSObject output = new PSObject();
+ output.Properties.Add(new PSNoteProperty("Entity", Entity));
+ output.Properties.Add(new PSNoteProperty("RuleType", ruleType));
+ output.Properties.Add(new PSNoteProperty("Id", rule.Attribute("Id")?.Value));
+
+ // Get all child elements (the actual rule conditions)
+ var conditions = rule.Elements()
+ .Select(e => new { Type = e.Name.LocalName, Attributes = e.Attributes().ToDictionary(a => a.Name.LocalName, a => a.Value) })
+ .ToArray();
+ output.Properties.Add(new PSNoteProperty("Conditions", conditions));
+
+ output.Properties.Add(new PSNoteProperty("Xml", rule.ToString()));
+
+ WriteObject(output);
+ }
+ }
+
+ private string RetrieveRibbon()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ WriteVerbose($"Retrieving ribbon for entity: {Entity}");
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = Entity,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ else
+ {
+ WriteVerbose("Retrieving application-wide ribbon");
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/RemoveDataverseRibbonCommandDefinitionCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/RemoveDataverseRibbonCommandDefinitionCmdlet.cs
new file mode 100644
index 000000000..674aa2296
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/RemoveDataverseRibbonCommandDefinitionCmdlet.cs
@@ -0,0 +1,215 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Management.Automation;
+using System.Security;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Removes a command definition from a Dataverse ribbon (entity-specific or application-wide).
+ ///
+ [Cmdlet(VerbsCommon.Remove, "DataverseRibbonCommandDefinition", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class RemoveDataverseRibbonCommandDefinitionCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to remove the ribbon command definition.
+ /// If not specified, removes from the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table. If not specified, removes from application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the ID of the command definition to remove.
+ ///
+ [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true, HelpMessage = "ID of the command definition to remove")]
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets whether to publish the customizations after removing the command definition.
+ ///
+ [Parameter(HelpMessage = "Publish the customizations after removing the command definition")]
+ public SwitchParameter Publish { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ string target = string.IsNullOrEmpty(Entity)
+ ? $"command definition '{Id}' from application-wide ribbon"
+ : $"command definition '{Id}' from ribbon for entity '{Entity}'";
+
+ if (!ShouldProcess(target, "Remove"))
+ {
+ return;
+ }
+
+ WriteVerbose($"Removing command definition '{Id}' from ribbon");
+ RemoveCommandDefinition();
+ WriteVerbose("Successfully removed command definition");
+
+ if (Publish.IsPresent)
+ {
+ WriteVerbose("Publishing customizations");
+ PublishCustomizations();
+ WriteVerbose("Customizations published successfully");
+ }
+ }
+
+ private void RemoveCommandDefinition()
+ {
+ try
+ {
+ string ribbonDiffXml = RetrieveRibbon();
+
+ if (string.IsNullOrEmpty(ribbonDiffXml))
+ {
+ WriteWarning("No ribbon customizations found");
+ return;
+ }
+
+ XDocument doc = XDocument.Parse(ribbonDiffXml);
+ XNamespace ns = doc.Root.Name.Namespace;
+
+ var commandDefinitionsElement = doc.Root.Element(ns + "CommandDefinitions");
+ if (commandDefinitionsElement == null)
+ {
+ WriteWarning("No CommandDefinitions element found in ribbon");
+ return;
+ }
+
+ var commandDefToRemove = commandDefinitionsElement.Elements(ns + "CommandDefinition")
+ .FirstOrDefault(cd => cd.Attribute("Id")?.Value == Id);
+
+ if (commandDefToRemove == null)
+ {
+ WriteWarning($"Command definition '{Id}' not found in ribbon");
+ return;
+ }
+
+ commandDefToRemove.Remove();
+ WriteVerbose($"Removed command definition '{Id}' from ribbon XML");
+
+ SaveRibbon(doc.ToString());
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to remove command definition: {ex.Message}", ex);
+ }
+ }
+
+ private string RetrieveRibbon()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = Entity,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ else
+ {
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ }
+
+ private void SaveRibbon(string ribbonDiffXml)
+ {
+ WriteWarning("Setting ribbon customizations requires solution import/export workflow for production scenarios.");
+
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions = { new ConditionExpression("entity", ConditionOperator.Equal, Entity) }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+ if (results.Entities.Count > 0)
+ {
+ Entity ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ WriteWarning("No existing ribbon diff found for entity");
+ }
+ }
+ else
+ {
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions = { new ConditionExpression("entity", ConditionOperator.Null) }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+ if (results.Entities.Count > 0)
+ {
+ Entity ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ WriteWarning("No existing ribbon diff found for application");
+ }
+ }
+ }
+
+ private void PublishCustomizations()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ string parameterXml = $"{SecurityElement.Escape(Entity)}";
+ PublishXmlRequest publishRequest = new PublishXmlRequest { ParameterXml = parameterXml };
+ Connection.Execute(publishRequest);
+ }
+ else
+ {
+ PublishAllXmlRequest publishRequest = new PublishAllXmlRequest();
+ Connection.Execute(publishRequest);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/RemoveDataverseRibbonCustomActionCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/RemoveDataverseRibbonCustomActionCmdlet.cs
new file mode 100644
index 000000000..31260ca14
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/RemoveDataverseRibbonCustomActionCmdlet.cs
@@ -0,0 +1,234 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Management.Automation;
+using System.Security;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Removes a custom action from a Dataverse ribbon (entity-specific or application-wide).
+ ///
+ [Cmdlet(VerbsCommon.Remove, "DataverseRibbonCustomAction", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class RemoveDataverseRibbonCustomActionCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to remove the ribbon custom action.
+ /// If not specified, removes from the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table. If not specified, removes from application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the ID of the custom action to remove.
+ ///
+ [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true, HelpMessage = "ID of the custom action to remove")]
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets whether to publish the customizations after removing the custom action.
+ ///
+ [Parameter(HelpMessage = "Publish the customizations after removing the custom action")]
+ public SwitchParameter Publish { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ string target = string.IsNullOrEmpty(Entity)
+ ? $"custom action '{Id}' from application-wide ribbon"
+ : $"custom action '{Id}' from ribbon for entity '{Entity}'";
+
+ if (!ShouldProcess(target, "Remove"))
+ {
+ return;
+ }
+
+ WriteVerbose($"Removing custom action '{Id}' from ribbon");
+ RemoveCustomAction();
+ WriteVerbose("Successfully removed custom action");
+
+ if (Publish.IsPresent)
+ {
+ WriteVerbose("Publishing customizations");
+ PublishCustomizations();
+ WriteVerbose("Customizations published successfully");
+ }
+ }
+
+ private void RemoveCustomAction()
+ {
+ try
+ {
+ // Retrieve current ribbon
+ string ribbonDiffXml = RetrieveRibbon();
+
+ if (string.IsNullOrEmpty(ribbonDiffXml))
+ {
+ WriteWarning("No ribbon customizations found");
+ return;
+ }
+
+ XDocument doc = XDocument.Parse(ribbonDiffXml);
+ XNamespace ns = doc.Root.Name.Namespace;
+
+ var customActionsElement = doc.Root.Element(ns + "CustomActions");
+ if (customActionsElement == null)
+ {
+ WriteWarning("No CustomActions element found in ribbon");
+ return;
+ }
+
+ // Find and remove the custom action
+ var customActionToRemove = customActionsElement.Elements(ns + "CustomAction")
+ .FirstOrDefault(ca => ca.Attribute("Id")?.Value == Id);
+
+ if (customActionToRemove == null)
+ {
+ WriteWarning($"Custom action '{Id}' not found in ribbon");
+ return;
+ }
+
+ customActionToRemove.Remove();
+ WriteVerbose($"Removed custom action '{Id}' from ribbon XML");
+
+ // Save the updated ribbon
+ SaveRibbon(doc.ToString());
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to remove custom action: {ex.Message}", ex);
+ }
+ }
+
+ private string RetrieveRibbon()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Retrieve entity-specific ribbon
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = Entity,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ else
+ {
+ // Retrieve application-wide ribbon
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ }
+
+ private void SaveRibbon(string ribbonDiffXml)
+ {
+ WriteWarning("Setting ribbon customizations requires solution import/export workflow for production scenarios.");
+
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Save entity ribbon
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions =
+ {
+ new ConditionExpression("entity", ConditionOperator.Equal, Entity)
+ }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+
+ if (results.Entities.Count > 0)
+ {
+ Entity ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ WriteWarning("No existing ribbon diff found for entity");
+ }
+ }
+ else
+ {
+ // Save application ribbon
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions =
+ {
+ new ConditionExpression("entity", ConditionOperator.Null)
+ }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+
+ if (results.Entities.Count > 0)
+ {
+ Entity ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ WriteWarning("No existing ribbon diff found for application");
+ }
+ }
+ }
+
+ private void PublishCustomizations()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ string parameterXml = $"{SecurityElement.Escape(Entity)}";
+ PublishXmlRequest publishRequest = new PublishXmlRequest
+ {
+ ParameterXml = parameterXml
+ };
+ Connection.Execute(publishRequest);
+ }
+ else
+ {
+ PublishAllXmlRequest publishRequest = new PublishAllXmlRequest();
+ Connection.Execute(publishRequest);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCmdlet.cs
new file mode 100644
index 000000000..a43286b3e
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCmdlet.cs
@@ -0,0 +1,229 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Management.Automation;
+using System.Security;
+using System.Text;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Creates or updates ribbon customizations in a Dataverse environment. Ribbons can be set for specific entities or for the application-wide ribbon.
+ /// Note: Ribbon customizations typically require exporting/modifying/importing solutions. This cmdlet provides a simplified interface but may have limitations.
+ ///
+ [Cmdlet(VerbsCommon.Set, "DataverseRibbon", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class SetDataverseRibbonCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to set the ribbon.
+ /// If not specified, sets the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table for which to set the ribbon. If not specified, sets the application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the RibbonDiffXml content. This must be valid XML conforming to the Dataverse Ribbon schema.
+ ///
+ [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "RibbonDiffXml content. Must be valid XML conforming to the Dataverse Ribbon schema.")]
+ [Alias("RibbonXml", "Xml")]
+ public string RibbonDiffXml { get; set; }
+
+ ///
+ /// Gets or sets whether to publish the customizations after setting the ribbon.
+ ///
+ [Parameter(HelpMessage = "Publish the customizations after setting the ribbon")]
+ public SwitchParameter Publish { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ // Validate RibbonDiffXml is provided
+ if (string.IsNullOrWhiteSpace(RibbonDiffXml))
+ {
+ throw new ArgumentException("RibbonDiffXml cannot be null or empty.", nameof(RibbonDiffXml));
+ }
+
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Set entity-specific ribbon
+ string target = $"ribbon for entity '{Entity}'";
+ if (!ShouldProcess(target, "Update"))
+ {
+ return;
+ }
+
+ WriteVerbose($"Setting ribbon for entity: {Entity}");
+ SetEntityRibbon(Entity, RibbonDiffXml);
+ WriteVerbose($"Successfully set entity ribbon");
+
+ if (Publish.IsPresent)
+ {
+ WriteVerbose($"Publishing customizations for entity: {Entity}");
+ PublishEntity(Entity);
+ WriteVerbose("Customizations published successfully");
+ }
+ }
+ else
+ {
+ // Set application-wide ribbon
+ string target = "application-wide ribbon";
+ if (!ShouldProcess(target, "Update"))
+ {
+ return;
+ }
+
+ WriteVerbose("Setting application-wide ribbon");
+ SetApplicationRibbon(RibbonDiffXml);
+ WriteVerbose("Successfully set application ribbon");
+
+ if (Publish.IsPresent)
+ {
+ WriteVerbose("Publishing application customizations");
+ PublishAllXml();
+ WriteVerbose("Customizations published successfully");
+ }
+ }
+ }
+
+ private void SetEntityRibbon(string entityLogicalName, string ribbonDiffXml)
+ {
+ try
+ {
+ // Create/update the ribboncommand solution component for entity
+ // We'll store the ribbon diff in the ribbondiff table
+ WriteWarning("Setting entity ribbons requires solution import/export workflow. " +
+ "Consider using solution export, modification, and import for production scenarios.");
+
+ // Query for existing ribbon diff for this entity
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid", "entity"),
+ Criteria = new FilterExpression
+ {
+ Conditions =
+ {
+ new ConditionExpression("entity", ConditionOperator.Equal, entityLogicalName)
+ }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+
+ Entity ribbonDiffEntity;
+ if (results.Entities.Count > 0)
+ {
+ // Update existing ribbon diff
+ ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ WriteVerbose($"Updated existing ribbon diff for entity '{entityLogicalName}'");
+ }
+ else
+ {
+ // Create new ribbon diff
+ ribbonDiffEntity = new Entity("ribbondiff");
+ ribbonDiffEntity["entity"] = entityLogicalName;
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+
+ Connection.Create(ribbonDiffEntity);
+ WriteVerbose($"Created new ribbon diff for entity '{entityLogicalName}'");
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to set ribbon for entity '{entityLogicalName}': {ex.Message}", ex);
+ }
+ }
+
+ private void SetApplicationRibbon(string ribbonDiffXml)
+ {
+ try
+ {
+ WriteWarning("Setting application ribbons requires solution import/export workflow. " +
+ "Consider using solution export, modification, and import for production scenarios.");
+
+ // For application-level ribbons, entity field is null
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid", "entity"),
+ Criteria = new FilterExpression
+ {
+ Conditions =
+ {
+ new ConditionExpression("entity", ConditionOperator.Null)
+ }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+
+ Entity ribbonDiffEntity;
+ if (results.Entities.Count > 0)
+ {
+ // Update existing application ribbon diff
+ ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ WriteVerbose("Updated existing application ribbon diff");
+ }
+ else
+ {
+ // Create new application ribbon diff
+ ribbonDiffEntity = new Entity("ribbondiff");
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+
+ Connection.Create(ribbonDiffEntity);
+ WriteVerbose("Created new application ribbon diff");
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to set application ribbon: {ex.Message}", ex);
+ }
+ }
+
+ private void PublishEntity(string entityLogicalName)
+ {
+ try
+ {
+ string parameterXml = $"{SecurityElement.Escape(entityLogicalName)}";
+
+ PublishXmlRequest publishRequest = new PublishXmlRequest
+ {
+ ParameterXml = parameterXml
+ };
+
+ Connection.Execute(publishRequest);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to publish customizations for entity '{entityLogicalName}': {ex.Message}", ex);
+ }
+ }
+
+ private void PublishAllXml()
+ {
+ try
+ {
+ PublishAllXmlRequest publishRequest = new PublishAllXmlRequest();
+ Connection.Execute(publishRequest);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to publish all customizations: {ex.Message}", ex);
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCommandDefinitionCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCommandDefinitionCmdlet.cs
new file mode 100644
index 000000000..d4bf5274b
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCommandDefinitionCmdlet.cs
@@ -0,0 +1,259 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Management.Automation;
+using System.Security;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Creates or updates a command definition in a Dataverse ribbon (entity-specific or application-wide).
+ ///
+ [Cmdlet(VerbsCommon.Set, "DataverseRibbonCommandDefinition", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class SetDataverseRibbonCommandDefinitionCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to set the ribbon command definition.
+ /// If not specified, sets in the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table. If not specified, sets in application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the ID of the command definition.
+ ///
+ [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "ID of the command definition")]
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets the XML content for the command definition (EnableRules, DisplayRules, Actions elements).
+ ///
+ [Parameter(Mandatory = true, HelpMessage = "XML content for the command definition (e.g., .........)")]
+ public string CommandDefinitionXml { get; set; }
+
+ ///
+ /// Gets or sets whether to publish the customizations after setting the command definition.
+ ///
+ [Parameter(HelpMessage = "Publish the customizations after setting the command definition")]
+ public SwitchParameter Publish { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ string target = string.IsNullOrEmpty(Entity)
+ ? "command definition in application-wide ribbon"
+ : $"command definition in ribbon for entity '{Entity}'";
+
+ if (!ShouldProcess(target, "Update"))
+ {
+ return;
+ }
+
+ WriteVerbose($"Setting command definition '{Id}' in ribbon");
+ SetCommandDefinition();
+ WriteVerbose("Successfully set command definition");
+
+ if (Publish.IsPresent)
+ {
+ WriteVerbose("Publishing customizations");
+ PublishCustomizations();
+ WriteVerbose("Customizations published successfully");
+ }
+ }
+
+ private void SetCommandDefinition()
+ {
+ try
+ {
+ // Retrieve current ribbon
+ string ribbonDiffXml = RetrieveRibbon();
+ XDocument doc;
+
+ if (string.IsNullOrEmpty(ribbonDiffXml))
+ {
+ doc = new XDocument(new XElement("RibbonDiffXml"));
+ }
+ else
+ {
+ doc = XDocument.Parse(ribbonDiffXml);
+ }
+
+ XNamespace ns = doc.Root.Name.Namespace;
+
+ // Ensure CommandDefinitions element exists
+ var commandDefinitionsElement = doc.Root.Element(ns + "CommandDefinitions");
+ if (commandDefinitionsElement == null)
+ {
+ commandDefinitionsElement = new XElement(ns + "CommandDefinitions");
+ // Insert after CustomActions if it exists, otherwise as first element
+ var customActions = doc.Root.Element(ns + "CustomActions");
+ if (customActions != null)
+ {
+ customActions.AddAfterSelf(commandDefinitionsElement);
+ }
+ else
+ {
+ doc.Root.AddFirst(commandDefinitionsElement);
+ }
+ }
+
+ // Find existing command definition or create new
+ var existingCommand = commandDefinitionsElement.Elements(ns + "CommandDefinition")
+ .FirstOrDefault(cd => cd.Attribute("Id")?.Value == Id);
+
+ XElement commandDefElement;
+ if (existingCommand != null)
+ {
+ commandDefElement = existingCommand;
+ commandDefElement.RemoveAll();
+ WriteVerbose($"Updating existing command definition '{Id}'");
+ }
+ else
+ {
+ commandDefElement = new XElement(ns + "CommandDefinition");
+ commandDefinitionsElement.Add(commandDefElement);
+ WriteVerbose($"Creating new command definition '{Id}'");
+ }
+
+ // Set attributes
+ commandDefElement.SetAttributeValue("Id", Id);
+
+ // Parse and add the provided XML content
+ XElement contentElement = XElement.Parse($"{CommandDefinitionXml}");
+ foreach (var child in contentElement.Elements())
+ {
+ commandDefElement.Add(child);
+ }
+
+ // Save the updated ribbon
+ SaveRibbon(doc.ToString());
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to set command definition: {ex.Message}", ex);
+ }
+ }
+
+ private string RetrieveRibbon()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = Entity,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ else
+ {
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ }
+
+ private void SaveRibbon(string ribbonDiffXml)
+ {
+ WriteWarning("Setting ribbon customizations requires solution import/export workflow for production scenarios.");
+
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions = { new ConditionExpression("entity", ConditionOperator.Equal, Entity) }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+ Entity ribbonDiffEntity;
+ if (results.Entities.Count > 0)
+ {
+ ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ ribbonDiffEntity = new Entity("ribbondiff");
+ ribbonDiffEntity["entity"] = Entity;
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Create(ribbonDiffEntity);
+ }
+ }
+ else
+ {
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions = { new ConditionExpression("entity", ConditionOperator.Null) }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+ Entity ribbonDiffEntity;
+ if (results.Entities.Count > 0)
+ {
+ ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ ribbonDiffEntity = new Entity("ribbondiff");
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Create(ribbonDiffEntity);
+ }
+ }
+ }
+
+ private void PublishCustomizations()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ string parameterXml = $"{SecurityElement.Escape(Entity)}";
+ PublishXmlRequest publishRequest = new PublishXmlRequest { ParameterXml = parameterXml };
+ Connection.Execute(publishRequest);
+ }
+ else
+ {
+ PublishAllXmlRequest publishRequest = new PublishAllXmlRequest();
+ Connection.Execute(publishRequest);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCustomActionCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCustomActionCmdlet.cs
new file mode 100644
index 000000000..58a141a4f
--- /dev/null
+++ b/Rnwood.Dataverse.Data.PowerShell.Cmdlets/Commands/SetDataverseRibbonCustomActionCmdlet.cs
@@ -0,0 +1,299 @@
+using Microsoft.Crm.Sdk.Messages;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Management.Automation;
+using System.Security;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Rnwood.Dataverse.Data.PowerShell.Commands
+{
+ ///
+ /// Creates or updates a custom action in a Dataverse ribbon (entity-specific or application-wide).
+ ///
+ [Cmdlet(VerbsCommon.Set, "DataverseRibbonCustomAction", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class SetDataverseRibbonCustomActionCmdlet : OrganizationServiceCmdlet
+ {
+ ///
+ /// Gets or sets the logical name of the entity/table for which to set the ribbon custom action.
+ /// If not specified, sets in the application-wide ribbon.
+ ///
+ [Parameter(Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "Logical name of the entity/table. If not specified, sets in application-wide ribbon.")]
+ [ArgumentCompleter(typeof(TableNameArgumentCompleter))]
+ [Alias("EntityName", "TableName")]
+ public string Entity { get; set; }
+
+ ///
+ /// Gets or sets the ID of the custom action.
+ ///
+ [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "ID of the custom action")]
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets the location where the custom action should appear.
+ ///
+ [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, HelpMessage = "Location where the custom action should appear (e.g., Mscrm.HomepageGrid.contact.MainTab.Actions.Controls._children)")]
+ public string Location { get; set; }
+
+ ///
+ /// Gets or sets the sequence number that determines the order of the custom action.
+ ///
+ [Parameter(HelpMessage = "Sequence number (default: 100)")]
+ public int Sequence { get; set; } = 100;
+
+ ///
+ /// Gets or sets the title of the custom action.
+ ///
+ [Parameter(HelpMessage = "Title of the custom action")]
+ public string Title { get; set; }
+
+ ///
+ /// Gets or sets the XML content for the CommandUIDefinition.
+ ///
+ [Parameter(Mandatory = true, HelpMessage = "XML content for the CommandUIDefinition element (e.g., )")]
+ public string CommandUIDefinitionXml { get; set; }
+
+ ///
+ /// Gets or sets whether to publish the customizations after setting the custom action.
+ ///
+ [Parameter(HelpMessage = "Publish the customizations after setting the custom action")]
+ public SwitchParameter Publish { get; set; }
+
+ ///
+ /// Processes the cmdlet request.
+ ///
+ protected override void ProcessRecord()
+ {
+ base.ProcessRecord();
+
+ string target = string.IsNullOrEmpty(Entity)
+ ? "custom action in application-wide ribbon"
+ : $"custom action in ribbon for entity '{Entity}'";
+
+ if (!ShouldProcess(target, "Update"))
+ {
+ return;
+ }
+
+ WriteVerbose($"Setting custom action '{Id}' in ribbon");
+ SetCustomAction();
+ WriteVerbose("Successfully set custom action");
+
+ if (Publish.IsPresent)
+ {
+ WriteVerbose("Publishing customizations");
+ PublishCustomizations();
+ WriteVerbose("Customizations published successfully");
+ }
+ }
+
+ private void SetCustomAction()
+ {
+ try
+ {
+ // Retrieve current ribbon
+ string ribbonDiffXml = RetrieveRibbon();
+ XDocument doc;
+
+ if (string.IsNullOrEmpty(ribbonDiffXml))
+ {
+ // Create new ribbon document
+ doc = new XDocument(new XElement("RibbonDiffXml"));
+ }
+ else
+ {
+ doc = XDocument.Parse(ribbonDiffXml);
+ }
+
+ XNamespace ns = doc.Root.Name.Namespace;
+
+ // Ensure CustomActions element exists
+ var customActionsElement = doc.Root.Element(ns + "CustomActions");
+ if (customActionsElement == null)
+ {
+ customActionsElement = new XElement(ns + "CustomActions");
+ // Insert CustomActions as first element
+ doc.Root.AddFirst(customActionsElement);
+ }
+
+ // Find existing custom action or create new
+ var existingAction = customActionsElement.Elements(ns + "CustomAction")
+ .FirstOrDefault(ca => ca.Attribute("Id")?.Value == Id);
+
+ XElement customActionElement;
+ if (existingAction != null)
+ {
+ // Update existing
+ customActionElement = existingAction;
+ WriteVerbose($"Updating existing custom action '{Id}'");
+ }
+ else
+ {
+ // Create new
+ customActionElement = new XElement(ns + "CustomAction");
+ customActionsElement.Add(customActionElement);
+ WriteVerbose($"Creating new custom action '{Id}'");
+ }
+
+ // Set attributes
+ customActionElement.SetAttributeValue("Id", Id);
+ customActionElement.SetAttributeValue("Location", Location);
+ customActionElement.SetAttributeValue("Sequence", Sequence);
+ if (!string.IsNullOrEmpty(Title))
+ {
+ customActionElement.SetAttributeValue("Title", Title);
+ }
+
+ // Set CommandUIDefinition
+ var commandUIDefElement = customActionElement.Element(ns + "CommandUIDefinition");
+ if (commandUIDefElement == null)
+ {
+ commandUIDefElement = new XElement(ns + "CommandUIDefinition");
+ customActionElement.Add(commandUIDefElement);
+ }
+
+ // Parse and add the provided XML
+ XElement commandUIContent = XElement.Parse(CommandUIDefinitionXml);
+ commandUIDefElement.RemoveAll();
+ commandUIDefElement.Add(commandUIContent);
+
+ // Save the updated ribbon
+ SaveRibbon(doc.ToString());
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to set custom action: {ex.Message}", ex);
+ }
+ }
+
+ private string RetrieveRibbon()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Retrieve entity-specific ribbon
+ RetrieveEntityRibbonRequest request = new RetrieveEntityRibbonRequest
+ {
+ EntityName = Entity,
+ RibbonLocationFilter = RibbonLocationFilters.All
+ };
+
+ RetrieveEntityRibbonResponse response = (RetrieveEntityRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedEntityXml);
+ }
+ else
+ {
+ // Retrieve application-wide ribbon
+ RetrieveApplicationRibbonRequest request = new RetrieveApplicationRibbonRequest();
+ RetrieveApplicationRibbonResponse response = (RetrieveApplicationRibbonResponse)Connection.Execute(request);
+ return DecompressXml(response.CompressedApplicationRibbonXml);
+ }
+ }
+
+ private void SaveRibbon(string ribbonDiffXml)
+ {
+ WriteWarning("Setting ribbon customizations requires solution import/export workflow for production scenarios.");
+
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ // Save entity ribbon
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions =
+ {
+ new ConditionExpression("entity", ConditionOperator.Equal, Entity)
+ }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+
+ Entity ribbonDiffEntity;
+ if (results.Entities.Count > 0)
+ {
+ ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ ribbonDiffEntity = new Entity("ribbondiff");
+ ribbonDiffEntity["entity"] = Entity;
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Create(ribbonDiffEntity);
+ }
+ }
+ else
+ {
+ // Save application ribbon
+ QueryExpression query = new QueryExpression("ribbondiff")
+ {
+ ColumnSet = new ColumnSet("ribbondiffid"),
+ Criteria = new FilterExpression
+ {
+ Conditions =
+ {
+ new ConditionExpression("entity", ConditionOperator.Null)
+ }
+ },
+ TopCount = 1
+ };
+
+ EntityCollection results = Connection.RetrieveMultiple(query);
+
+ Entity ribbonDiffEntity;
+ if (results.Entities.Count > 0)
+ {
+ ribbonDiffEntity = results.Entities[0];
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Update(ribbonDiffEntity);
+ }
+ else
+ {
+ ribbonDiffEntity = new Entity("ribbondiff");
+ ribbonDiffEntity["ribbondiffxml"] = ribbonDiffXml;
+ Connection.Create(ribbonDiffEntity);
+ }
+ }
+ }
+
+ private void PublishCustomizations()
+ {
+ if (!string.IsNullOrEmpty(Entity))
+ {
+ string parameterXml = $"{SecurityElement.Escape(Entity)}";
+ PublishXmlRequest publishRequest = new PublishXmlRequest
+ {
+ ParameterXml = parameterXml
+ };
+ Connection.Execute(publishRequest);
+ }
+ else
+ {
+ PublishAllXmlRequest publishRequest = new PublishAllXmlRequest();
+ Connection.Execute(publishRequest);
+ }
+ }
+
+ private string DecompressXml(byte[] compressedData)
+ {
+ if (compressedData == null || compressedData.Length == 0)
+ return null;
+
+ using (MemoryStream memoryStream = new MemoryStream(compressedData))
+ using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
+ using (StreamReader reader = new StreamReader(gzipStream, Encoding.UTF8))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+ }
+}
diff --git a/tests/DataverseRibbon.Tests.ps1 b/tests/DataverseRibbon.Tests.ps1
new file mode 100644
index 000000000..147d637dc
--- /dev/null
+++ b/tests/DataverseRibbon.Tests.ps1
@@ -0,0 +1,249 @@
+. "$PSScriptRoot\Common.ps1"
+
+Describe 'Dataverse Ribbon Cmdlets' {
+ BeforeAll {
+ # Mock connection with request interceptor for ribbon operations
+ $requestInterceptor = {
+ param($request)
+
+ # Handle RetrieveEntityRibbonRequest
+ if ($request.GetType().Name -eq 'RetrieveEntityRibbonRequest') {
+ $response = New-Object Microsoft.Crm.Sdk.Messages.RetrieveEntityRibbonResponse
+
+ # Sample ribbon XML for testing
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ # Compress the XML using GZip
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ribbonXml)
+ $memoryStream = New-Object System.IO.MemoryStream
+ $gzipStream = New-Object System.IO.Compression.GZipStream($memoryStream, [System.IO.Compression.CompressionMode]::Compress)
+ $gzipStream.Write($xmlBytes, 0, $xmlBytes.Length)
+ $gzipStream.Close()
+ $compressedXml = $memoryStream.ToArray()
+ $memoryStream.Close()
+
+ $response.Results.Add("CompressedEntityXml", $compressedXml)
+ return $response
+ }
+
+ # Handle RetrieveApplicationRibbonRequest
+ if ($request.GetType().Name -eq 'RetrieveApplicationRibbonRequest') {
+ $response = New-Object Microsoft.Crm.Sdk.Messages.RetrieveApplicationRibbonResponse
+
+ # Sample application ribbon XML
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+"@
+
+ # Compress the XML
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ribbonXml)
+ $memoryStream = New-Object System.IO.MemoryStream
+ $gzipStream = New-Object System.IO.Compression.GZipStream($memoryStream, [System.IO.Compression.CompressionMode]::Compress)
+ $gzipStream.Write($xmlBytes, 0, $xmlBytes.Length)
+ $gzipStream.Close()
+ $compressedXml = $memoryStream.ToArray()
+ $memoryStream.Close()
+
+ $response.Results.Add("CompressedApplicationRibbonXml", $compressedXml)
+ return $response
+ }
+
+ return $null
+ }
+
+ # Only need contact metadata, not ribbondiff (ribbondiff uses normal CRUD operations)
+ $connection = getMockConnection -RequestInterceptor $requestInterceptor -Entities @('contact')
+ }
+
+ Context 'Get-DataverseRibbon' {
+ It 'Retrieves entity-specific ribbon' {
+ $result = Get-DataverseRibbon -Connection $connection -Entity 'contact'
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Entity | Should -Be 'contact'
+ $result.IsApplicationRibbon | Should -Be $false
+ $result.RibbonDiffXml | Should -Not -BeNullOrEmpty
+ $result.RibbonDiffXml | Should -Match 'RibbonDiffXml'
+ $result.RibbonDiffXml | Should -Match 'TestAction'
+ $result.RibbonDiffXml | Should -Match 'TestButton'
+ }
+
+ It 'Retrieves application-wide ribbon' {
+ $result = Get-DataverseRibbon -Connection $connection
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Entity | Should -BeNullOrEmpty
+ $result.IsApplicationRibbon | Should -Be $true
+ $result.RibbonDiffXml | Should -Not -BeNullOrEmpty
+ $result.RibbonDiffXml | Should -Match 'RibbonDiffXml'
+ $result.RibbonDiffXml | Should -Match 'AppAction'
+ $result.RibbonDiffXml | Should -Match 'AppButton'
+ }
+
+ It 'Accepts Entity parameter from pipeline' {
+ $entityObject = [PSCustomObject]@{
+ Entity = 'contact'
+ }
+
+ $result = $entityObject | Get-DataverseRibbon -Connection $connection
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Entity | Should -Be 'contact'
+ }
+
+ It 'Decompresses GZip-compressed XML correctly' {
+ $result = Get-DataverseRibbon -Connection $connection -Entity 'contact'
+
+ # Verify XML is valid and decompressed
+ $result.RibbonDiffXml | Should -Not -BeNullOrEmpty
+ { [xml]$result.RibbonDiffXml } | Should -Not -Throw
+
+ $xml = [xml]$result.RibbonDiffXml
+ $xml.RibbonDiffXml | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Set-DataverseRibbon' {
+ It 'Sets entity-specific ribbon' -Skip {
+ # Skip: FakeXrmEasy doesn't support ribbondiff table CRUD operations
+ # This would require a real Dataverse environment to test
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+"@
+
+ { Set-DataverseRibbon -Connection $connection -Entity 'contact' -RibbonDiffXml $ribbonXml -Confirm:$false } | Should -Not -Throw
+ }
+
+ It 'Sets application-wide ribbon' -Skip {
+ # Skip: FakeXrmEasy doesn't support ribbondiff table CRUD operations
+ # This would require a real Dataverse environment to test
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+"@
+
+ { Set-DataverseRibbon -Connection $connection -RibbonDiffXml $ribbonXml -Confirm:$false } | Should -Not -Throw
+ }
+
+ It 'Requires RibbonDiffXml parameter' {
+ # PowerShell prompts for mandatory parameters when they're missing
+ # We can test this by checking the parameter is indeed mandatory
+ $cmdlet = Get-Command Set-DataverseRibbon
+ $ribbonDiffXmlParam = $cmdlet.Parameters['RibbonDiffXml']
+ $ribbonDiffXmlParam.Attributes.Mandatory | Should -Contain $true
+ }
+
+ It 'Accepts RibbonDiffXml from pipeline' -Skip {
+ # Skip: FakeXrmEasy doesn't support ribbondiff table CRUD operations
+ $ribbonObject = [PSCustomObject]@{
+ Entity = 'contact'
+ RibbonDiffXml = ''
+ }
+
+ { $ribbonObject | Set-DataverseRibbon -Connection $connection -Confirm:$false } | Should -Not -Throw
+ }
+
+ It 'Supports WhatIf' {
+ $ribbonXml = ''
+
+ { Set-DataverseRibbon -Connection $connection -Entity 'contact' -RibbonDiffXml $ribbonXml -WhatIf } | Should -Not -Throw
+ }
+ }
+
+ Context 'Ribbon XML Schema' {
+ It 'Accepts valid ribbon schema with CustomActions' -Skip {
+ # Skip: FakeXrmEasy doesn't support ribbondiff table CRUD operations
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+
+"@
+
+ { Set-DataverseRibbon -Connection $connection -Entity 'contact' -RibbonDiffXml $ribbonXml -Confirm:$false } | Should -Not -Throw
+ }
+
+ It 'Accepts valid ribbon schema with CommandDefinitions and RuleDefinitions' -Skip {
+ # Skip: FakeXrmEasy doesn't support ribbondiff table CRUD operations
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ { Set-DataverseRibbon -Connection $connection -Entity 'contact' -RibbonDiffXml $ribbonXml -Confirm:$false } | Should -Not -Throw
+ }
+ }
+}
diff --git a/tests/DataverseRibbonCommandAndRuleDefinition.Tests.ps1 b/tests/DataverseRibbonCommandAndRuleDefinition.Tests.ps1
new file mode 100644
index 000000000..f675ef924
--- /dev/null
+++ b/tests/DataverseRibbonCommandAndRuleDefinition.Tests.ps1
@@ -0,0 +1,188 @@
+. "$PSScriptRoot\Common.ps1"
+
+Describe 'Dataverse RibbonCommandDefinition Cmdlets' {
+ BeforeAll {
+ # Mock connection with request interceptor for ribbon operations
+ $requestInterceptor = {
+ param($request)
+
+ # Handle RetrieveEntityRibbonRequest
+ if ($request.GetType().Name -eq 'RetrieveEntityRibbonRequest') {
+ $response = New-Object Microsoft.Crm.Sdk.Messages.RetrieveEntityRibbonResponse
+
+ # Sample ribbon XML with CommandDefinitions for testing
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ # Compress the XML using GZip
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ribbonXml)
+ $memoryStream = New-Object System.IO.MemoryStream
+ $gzipStream = New-Object System.IO.Compression.GZipStream($memoryStream, [System.IO.Compression.CompressionMode]::Compress)
+ $gzipStream.Write($xmlBytes, 0, $xmlBytes.Length)
+ $gzipStream.Close()
+ $compressedXml = $memoryStream.ToArray()
+ $memoryStream.Close()
+
+ $response.Results.Add("CompressedEntityXml", $compressedXml)
+ return $response
+ }
+
+ return $null
+ }
+
+ $connection = getMockConnection -RequestInterceptor $requestInterceptor -Entities @('contact')
+ }
+
+ Context 'Get-DataverseRibbonCommandDefinition' {
+ It 'Retrieves all command definitions from entity ribbon' {
+ $results = Get-DataverseRibbonCommandDefinition -Connection $connection -Entity 'contact'
+
+ $results | Should -Not -BeNullOrEmpty
+ $results.Count | Should -Be 2
+ $results[0].Id | Should -Be 'TestCommand1'
+ $results[0].Entity | Should -Be 'contact'
+ $results[0].EnableRules | Should -Contain 'EnableRule1'
+ $results[0].DisplayRules | Should -Contain 'DisplayRule1'
+ $results[0].Actions | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Retrieves specific command definition by ID' {
+ $result = Get-DataverseRibbonCommandDefinition -Connection $connection -Entity 'contact' -CommandId 'TestCommand2'
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Id | Should -Be 'TestCommand2'
+ }
+
+ It 'Returns XML property with full command definition element' {
+ $result = Get-DataverseRibbonCommandDefinition -Connection $connection -Entity 'contact' -CommandId 'TestCommand1'
+
+ $result.Xml | Should -Not -BeNullOrEmpty
+ $result.Xml | Should -Match '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ribbonXml)
+ $memoryStream = New-Object System.IO.MemoryStream
+ $gzipStream = New-Object System.IO.Compression.GZipStream($memoryStream, [System.IO.Compression.CompressionMode]::Compress)
+ $gzipStream.Write($xmlBytes, 0, $xmlBytes.Length)
+ $gzipStream.Close()
+ $compressedXml = $memoryStream.ToArray()
+ $memoryStream.Close()
+
+ $response.Results.Add("CompressedEntityXml", $compressedXml)
+ return $response
+ }
+
+ return $null
+ }
+
+ $connection = getMockConnection -RequestInterceptor $requestInterceptor -Entities @('contact')
+ }
+
+ Context 'Get-DataverseRibbonRuleDefinition' {
+ It 'Retrieves all rule definitions from entity ribbon' {
+ $results = Get-DataverseRibbonRuleDefinition -Connection $connection -Entity 'contact'
+
+ $results | Should -Not -BeNullOrEmpty
+ $results.Count | Should -Be 3 # 2 EnableRules + 1 DisplayRule
+ }
+
+ It 'Filters by rule type - EnableRule' {
+ $results = Get-DataverseRibbonRuleDefinition -Connection $connection -Entity 'contact' -RuleType 'EnableRule'
+
+ $results | Should -Not -BeNullOrEmpty
+ $results.Count | Should -Be 2
+ $results[0].RuleType | Should -Be 'EnableRule'
+ }
+
+ It 'Filters by rule type - DisplayRule' {
+ $results = Get-DataverseRibbonRuleDefinition -Connection $connection -Entity 'contact' -RuleType 'DisplayRule'
+
+ $results | Should -Not -BeNullOrEmpty
+ $results.Count | Should -Be 1
+ $results[0].RuleType | Should -Be 'DisplayRule'
+ }
+
+ It 'Filters by specific rule ID' {
+ $result = Get-DataverseRibbonRuleDefinition -Connection $connection -Entity 'contact' -RuleId 'EnableRule1'
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Id | Should -Be 'EnableRule1'
+ $result.RuleType | Should -Be 'EnableRule'
+ }
+
+ It 'Returns conditions with rule' {
+ $result = Get-DataverseRibbonRuleDefinition -Connection $connection -Entity 'contact' -RuleId 'EnableRule1'
+
+ $result.Conditions | Should -Not -BeNullOrEmpty
+ $result.Conditions[0].Type | Should -Be 'FormStateRule'
+ }
+ }
+}
diff --git a/tests/DataverseRibbonCustomAction.Tests.ps1 b/tests/DataverseRibbonCustomAction.Tests.ps1
new file mode 100644
index 000000000..814ba3fb2
--- /dev/null
+++ b/tests/DataverseRibbonCustomAction.Tests.ps1
@@ -0,0 +1,150 @@
+. "$PSScriptRoot\Common.ps1"
+
+Describe 'Dataverse RibbonCustomAction Cmdlets' {
+ BeforeAll {
+ # Mock connection with request interceptor for ribbon operations
+ $requestInterceptor = {
+ param($request)
+
+ # Handle RetrieveEntityRibbonRequest
+ if ($request.GetType().Name -eq 'RetrieveEntityRibbonRequest') {
+ $response = New-Object Microsoft.Crm.Sdk.Messages.RetrieveEntityRibbonResponse
+
+ # Sample ribbon XML with CustomActions for testing
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ # Compress the XML using GZip
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ribbonXml)
+ $memoryStream = New-Object System.IO.MemoryStream
+ $gzipStream = New-Object System.IO.Compression.GZipStream($memoryStream, [System.IO.Compression.CompressionMode]::Compress)
+ $gzipStream.Write($xmlBytes, 0, $xmlBytes.Length)
+ $gzipStream.Close()
+ $compressedXml = $memoryStream.ToArray()
+ $memoryStream.Close()
+
+ $response.Results.Add("CompressedEntityXml", $compressedXml)
+ return $response
+ }
+
+ # Handle RetrieveApplicationRibbonRequest
+ if ($request.GetType().Name -eq 'RetrieveApplicationRibbonRequest') {
+ $response = New-Object Microsoft.Crm.Sdk.Messages.RetrieveApplicationRibbonResponse
+
+ # Sample application ribbon XML with CustomActions
+ $ribbonXml = @"
+
+
+
+
+
+
+
+
+
+"@
+
+ # Compress the XML
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ribbonXml)
+ $memoryStream = New-Object System.IO.MemoryStream
+ $gzipStream = New-Object System.IO.Compression.GZipStream($memoryStream, [System.IO.Compression.CompressionMode]::Compress)
+ $gzipStream.Write($xmlBytes, 0, $xmlBytes.Length)
+ $gzipStream.Close()
+ $compressedXml = $memoryStream.ToArray()
+ $memoryStream.Close()
+
+ $response.Results.Add("CompressedApplicationRibbonXml", $compressedXml)
+ return $response
+ }
+
+ return $null
+ }
+
+ $connection = getMockConnection -RequestInterceptor $requestInterceptor -Entities @('contact')
+ }
+
+ Context 'Get-DataverseRibbonCustomAction' {
+ It 'Retrieves all custom actions from entity ribbon' {
+ $results = Get-DataverseRibbonCustomAction -Connection $connection -Entity 'contact'
+
+ $results | Should -Not -BeNullOrEmpty
+ $results.Count | Should -Be 2
+ $results[0].Id | Should -Be 'TestAction1'
+ $results[0].Entity | Should -Be 'contact'
+ $results[0].Location | Should -Be 'Mscrm.HomepageGrid.contact.MainTab.Actions.Controls._children'
+ $results[0].Sequence | Should -Be '10'
+ $results[0].Title | Should -Be 'Test Action 1'
+ $results[0].ControlType | Should -Be 'Button'
+ $results[0].ControlId | Should -Be 'TestButton1'
+ $results[0].Command | Should -Be 'TestCommand1'
+ $results[0].LabelText | Should -Be 'Test Button 1'
+ }
+
+ It 'Retrieves specific custom action by ID' {
+ $result = Get-DataverseRibbonCustomAction -Connection $connection -Entity 'contact' -CustomActionId 'TestAction2'
+
+ $result | Should -Not -BeNullOrEmpty
+ $result.Id | Should -Be 'TestAction2'
+ $result.ControlId | Should -Be 'TestButton2'
+ }
+
+ It 'Retrieves custom actions from application ribbon' {
+ $results = Get-DataverseRibbonCustomAction -Connection $connection
+
+ $results | Should -Not -BeNullOrEmpty
+ $results.Count | Should -Be 1
+ $results[0].Id | Should -Be 'AppAction1'
+ $results[0].Entity | Should -BeNullOrEmpty
+ $results[0].ControlId | Should -Be 'AppButton1'
+ }
+
+ It 'Returns XML property with full custom action element' {
+ $result = Get-DataverseRibbonCustomAction -Connection $connection -Entity 'contact' -CustomActionId 'TestAction1'
+
+ $result.Xml | Should -Not -BeNullOrEmpty
+ $result.Xml | Should -Match ''
+ { Set-DataverseRibbonCustomAction -Connection $connection -Entity 'contact' -Id 'NewAction' -Location 'TestLocation' -CommandUIDefinitionXml $buttonXml -WhatIf } | Should -Not -Throw
+ }
+ }
+
+ Context 'Remove-DataverseRibbonCustomAction' {
+ It 'Requires mandatory Id parameter' {
+ $cmd = Get-Command Remove-DataverseRibbonCustomAction
+ $cmd.Parameters['Id'].Attributes.Mandatory | Should -Contain $true
+ }
+
+ It 'Supports WhatIf' -Skip {
+ # Skip: Would require mocking the entire ribbon retrieval and save process
+ { Remove-DataverseRibbonCustomAction -Connection $connection -Entity 'contact' -Id 'TestAction1' -WhatIf } | Should -Not -Throw
+ }
+ }
+}