src/Solutions/Customization/Commands/Import-XrmRibbon.ps1

<#
    .SYNOPSIS
    Import ribbon customization XML for a table.

    .DESCRIPTION
    Import a modified RibbonDiffXml for a specific table by creating a temporary solution containing the table,
    exporting the solution, replacing the RibbonDiffXml node in customizations.xml, re-zipping, and importing.
    This allows modifying classic ribbon customizations (commands, display rules, enable rules) programmatically.
    The temporary solution uses the publisher given by PublisherUniqueName, or the organization default publisher (publisher of the Default solution). It is removed at the end, even on failure.

    .PARAMETER XrmClient
    Xrm connector initialized to target instance. Use latest one by default. (Dataverse ServiceClient)

    .PARAMETER EntityLogicalName
    Logical name of the table whose ribbon to update.

    .PARAMETER RibbonDiffXml
    The RibbonDiffXml content, as a string or as an XmlElement (e.g. the output of Export-XrmRibbon), containing CustomActions, CommandDefinitions, RuleDefinitions, etc.

    .PARAMETER SolutionUniqueName
    Existing solution unique name to use for import. If provided, uses this solution instead of creating a temporary one.

    .PARAMETER PublisherUniqueName
    Publisher of the temporary solution. Ignored when SolutionUniqueName is given. (Default: organization default publisher)

    .PARAMETER Publish
    Publish customizations after import. Default: true.

    .PARAMETER TargetSolutionUniqueName
    Unmanaged solution to add the table to after the import (the ribbon belongs to the table component), distinct from the temporary or exported solution.

    .OUTPUTS
    System.Void.

    .EXAMPLE
    $ribbonXml = Export-XrmRibbon -EntityLogicalName "account";
    # Modify $ribbonXml as needed...
    Import-XrmRibbon -EntityLogicalName "account" -RibbonDiffXml $ribbonXml;

    .EXAMPLE
    # Temporary solution with the publisher of the target solution, table added to the target afterwards
    Import-XrmRibbon -XrmClient $xrmClient -EntityLogicalName "account" -RibbonDiffXml $ribbonXml -PublisherUniqueName "contoso" -TargetSolutionUniqueName "ContosoCore";

    .LINK
    https://github.com/AymericM78/PowerDataOps/blob/main/documentation/commands/Import-XrmRibbon.md
#>

function Import-XrmRibbon {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([System.Void])]
    param
    (
        [Parameter(Mandatory = $false, ValueFromPipeline)]
        [Microsoft.PowerPlatform.Dataverse.Client.ServiceClient]
        $XrmClient = $Global:XrmClient,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $EntityLogicalName,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [object]
        $RibbonDiffXml,

        [Parameter(Mandatory = $false)]
        [string]
        $SolutionUniqueName,

        [Parameter(Mandatory = $false)]
        [bool]
        $Publish = $true,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $PublisherUniqueName,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $TargetSolutionUniqueName
    )
    begin {
        $StopWatch = [System.Diagnostics.Stopwatch]::StartNew();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Start -Parameters ($MyInvocation.MyCommand.Parameters);
    }
    process {
        # An XmlElement converted to [string] gives its type name, not its XML
        $ribbonXmlText = if ($RibbonDiffXml -is [System.Xml.XmlNode]) { $RibbonDiffXml.OuterXml } else { [string]$RibbonDiffXml };

        $workPath = Join-Path $env:TEMP "RibbonImport_$([Guid]::NewGuid().ToString('N').Substring(0, 8))";
        $useTempSolution = (-not $PSBoundParameters.ContainsKey('SolutionUniqueName'));
        $tempSolutionName = "RibbonImport_$([Guid]::NewGuid().ToString('N').Substring(0, 8))";
        $tempSolutionCreated = $false;
        $solutionFilePath = $null;
        $importZipPath = $null;

        try {
            if ($useTempSolution) {
                if ($PSBoundParameters.ContainsKey('PublisherUniqueName')) {
                    $publisher = $XrmClient | Get-XrmPublisher -PublisherUniqueName $PublisherUniqueName;
                    if (-not $publisher) {
                        throw "Publisher '$PublisherUniqueName' not found.";
                    }
                    $publisherRef = $publisher.Reference;
                }
                else {
                    $defaultSolution = $XrmClient | Get-XrmSolution -SolutionUniqueName "Default" -Columns "publisherid";
                    $publisherRef = $defaultSolution.publisherid_Value;
                }
                $SolutionUniqueName = $tempSolutionName;

                $XrmClient | Add-XrmSolution -DisplayName $tempSolutionName -UniqueName $tempSolutionName -PublisherReference $publisherRef | Out-Null;
                $tempSolutionCreated = $true;

                $entityMetadata = $XrmClient | Get-XrmEntityMetadata -LogicalName $EntityLogicalName;
                $XrmClient | Add-XrmSolutionComponent -SolutionUniqueName $tempSolutionName -ComponentId $entityMetadata.MetadataId -ComponentType 1 -DoNotIncludeSubcomponents $true | Out-Null;
            }

            # Export solution
            $solutionFilePath = $XrmClient | Export-XrmSolution -SolutionUniqueName $SolutionUniqueName -Managed $false -ExportPath $env:TEMP;

            # Extract zip
            Expand-Archive -Path $solutionFilePath -DestinationPath $workPath -Force;

            # Modify customizations.xml
            $customizationsPath = Join-Path $workPath "customizations.xml";
            if (-not (Test-Path $customizationsPath)) {
                throw "customizations.xml not found in exported solution.";
            }

            [xml]$customizationsXml = Get-Content -Path $customizationsPath -Raw;

            $entityNode = $customizationsXml.ImportExportXml.Entities.Entity | Where-Object {
                $_.EntityInfo.entity.Name -eq $EntityLogicalName;
            };

            if (-not $entityNode) {
                throw "Entity '$EntityLogicalName' not found in customizations.xml.";
            }

            # Replace RibbonDiffXml
            if ($ribbonXmlText.TrimStart().StartsWith("<RibbonDiffXml")) {
                [xml]$ribbonFragment = $ribbonXmlText;
            }
            else {
                [xml]$ribbonFragment = "<RibbonDiffXml>$ribbonXmlText</RibbonDiffXml>";
            }

            $importedNode = $customizationsXml.ImportNode($ribbonFragment.DocumentElement, $true);
            $oldRibbonNode = $entityNode.SelectSingleNode("RibbonDiffXml");
            if ($oldRibbonNode) {
                $entityNode.ReplaceChild($importedNode, $oldRibbonNode) | Out-Null;
            }
            else {
                $entityNode.AppendChild($importedNode) | Out-Null;
            }

            $customizationsXml.Save($customizationsPath);

            # Re-zip
            $importZipPath = Join-Path $env:TEMP "$($SolutionUniqueName)_ribbon.zip";
            if (Test-Path $importZipPath) {
                Remove-Item -Path $importZipPath -Force;
            }
            [System.IO.Compression.ZipFile]::CreateFromDirectory($workPath, $importZipPath);

            # Import solution
            $XrmClient | Import-XrmSolution -SolutionUniqueName $SolutionUniqueName -SolutionFilePath $importZipPath -OverwriteUnmanagedCustomizations $true;
            if ($PSBoundParameters.ContainsKey('TargetSolutionUniqueName')) {
                $tableMetadata = $XrmClient | Get-XrmEntityMetadata -LogicalName $EntityLogicalName -Filter Entity;
                $XrmClient | Add-XrmSolutionComponent -SolutionUniqueName $TargetSolutionUniqueName -ComponentId $tableMetadata.MetadataId -ComponentType 1 -DoNotIncludeSubcomponents $true | Out-Null;
            }

            # Publish
            if ($Publish) {
                $XrmClient | Publish-XrmCustomizations;
            }
        }
        finally {
            # Cleanup temp solution: a cleanup failure is reported but does not hide the original error
            if ($tempSolutionCreated) {
                try {
                    $XrmClient | Uninstall-XrmSolution -SolutionUniqueName $tempSolutionName;
                }
                catch {
                    Write-Warning "Temporary solution '$tempSolutionName' was not removed: $($_.Exception.Message)";
                }
            }

            # Cleanup work files
            if (Test-Path $workPath) {
                Remove-Item -Path $workPath -Recurse -Force -ErrorAction SilentlyContinue;
            }
            if ($importZipPath -and (Test-Path $importZipPath)) {
                Remove-Item -Path $importZipPath -Force -ErrorAction SilentlyContinue;
            }
            if ($solutionFilePath -and (Test-Path $solutionFilePath)) {
                Remove-Item -Path $solutionFilePath -Force -ErrorAction SilentlyContinue;
            }
        }
    }
    end {
        $StopWatch.Stop();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Stop -StopWatch $StopWatch;
    }
}

Export-ModuleMember -Function Import-XrmRibbon -Alias *;

Register-ArgumentCompleter -CommandName Import-XrmRibbon -ParameterName "EntityLogicalName" -ScriptBlock {
    param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)
    $validLogicalNames = Get-XrmEntitiesLogicalName;
    return $validLogicalNames | Where-Object { $_ -like "$wordToComplete*" };
}