Private/Merge-AACDrawio.ps1

function Merge-AACDrawio {
    <#
    .SYNOPSIS
        Refreshes a draw.io diagram you have edited with a newly generated one
        (ConvertTo-AACDrawio), keeping your changes.
    .DESCRIPTION
        Shapes are matched by ID (ConvertTo-AACDrawio derives them from the
        resource IDs), page by page (by page ID, else by name):
          kept a resource in both: where you put it, the box you moved
                   it into, your styling - and your label if you changed it
                   (the label it was generated with is kept in aacLabel, to
                   tell); its data (type, location, tooltip, portal link) is
                   refreshed
          added a resource new in Azure: placed in its box under what is
                   there (the box grows to fit), or beside the page's drawing,
                   and marked new (green) until the next refresh
          removed a resource no longer in Azure: faded and red, with a
                   tooltip saying when it went - or, with -Prune, deleted
          yours shapes, notes and connections you added (IDs not starting
                   aac-) are left as they are
        Connections are matched the same way: kept with your waypoints and
        style, added, or faded (deleted with -Prune, or when an end is gone).
        Pages only in the old file stay; pages only in the new one are added;
        the Legend page is replaced. A page draw.io saved compressed is read
        and written back uncompressed (draw.io reads both).
        Returns @{ Xml; Kept; Added; Removed; Pruned; Pages }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        # The diagram as it is now (the file's text).
        [Parameter(Mandatory)]
        [string] $Existing,

        # ConvertTo-AACDrawio's newly generated Xml.
        [Parameter(Mandatory)]
        [string] $Generated,

        [switch] $Prune,

        [datetime] $Now = [datetime]::UtcNow
    )

    $newMark = 'aacNew=1;fontColor=#008A6E;'
    $newBoxMark = 'aacNew=1;strokeColor=#008A6E;strokeWidth=2;'
    $removedMark = 'aacRemoved=1;opacity=35;fontColor=#D13447;'
    $removedEdgeMark = 'aacRemoved=1;opacity=35;dashed=1;'
    $pad, $gap, $header = 18, 24, 34

    try { $old = [xml]$Existing } catch { throw "Azure Admin Console can't read the diagram to update - it isn't draw.io XML ($($_.Exception.Message)). Save it from draw.io as .drawio (uncompressed or compressed), or write a new one without -Update." }
    $new = [xml]$Generated
    if ($old.DocumentElement.LocalName -ne 'mxfile') { throw "Azure Admin Console can't update that file: it isn't a draw.io diagram (no <mxfile>). Write a new one without -Update." }

    # A page's model: its <mxGraphModel>, inflating a compressed page (base64, raw deflate, URL-encoded) first.
    $modelOf = {
        param([System.Xml.XmlElement] $Diagram)
        $model = $Diagram.SelectSingleNode('mxGraphModel')
        if ($model) { return $model }
        $text = $Diagram.InnerText.Trim()
        if (-not $text) { return $null }
        $bytes = [Convert]::FromBase64String($text)
        $deflate = [System.IO.Compression.DeflateStream]::new([System.IO.MemoryStream]::new($bytes), [System.IO.Compression.CompressionMode]::Decompress)
        $reader = [System.IO.StreamReader]::new($deflate, [Text.Encoding]::UTF8)
        $inner = [Uri]::UnescapeDataString($reader.ReadToEnd())
        $reader.Dispose()
        $part = [xml]$inner
        $imported = $Diagram.OwnerDocument.ImportNode($part.DocumentElement, $true)
        $Diagram.InnerText = ''
        [void]$Diagram.AppendChild($imported)
        $imported
    }
    # The cells on a page by ID: the element under <root> (an <mxCell>, or an <object> / <UserObject> holding one).
    $cellsOf = {
        param($Root)
        $map = [ordered]@{}
        foreach ($el in @($Root.ChildNodes | Where-Object { $_ -is [System.Xml.XmlElement] })) { $id = $el.GetAttribute('id'); if ($id) { $map[$id] = $el } }
        $map
    }
    $mxOf = { param([System.Xml.XmlElement] $El) if ($El.LocalName -eq 'mxCell') { $El } else { $El.SelectSingleNode('mxCell') } }
    $geoOf = { param([System.Xml.XmlElement] $El) $mx = & $mxOf $El; if ($mx) { $mx.SelectSingleNode('mxGeometry') } }
    $num = { param($Geo, [string] $Name) if ($Geo -and $Geo.GetAttribute($Name)) { [double]$Geo.GetAttribute($Name) } else { 0.0 } }
    $styleAdd = { param([System.Xml.XmlElement] $Mx, [string] $Mark) $s = $Mx.GetAttribute('style'); if (-not $s.Contains($Mark)) { $Mx.SetAttribute('style', ($s.TrimEnd(';') + ';' + $Mark)) } }
    $styleDrop = { param([System.Xml.XmlElement] $Mx, [string] $Mark) $s = $Mx.GetAttribute('style'); if ($s.Contains($Mark)) { $Mx.SetAttribute('style', $s.Replace(';' + $Mark, ';').Replace($Mark, '').Replace(';;', ';')) } }
    $isOurs = { param([string] $Id) $Id -like 'aac-*' -and $Id -notlike 'aac-legend-*' }
    $isEdge = { param([System.Xml.XmlElement] $El) $mx = & $mxOf $El; $mx -and $mx.GetAttribute('edge') -eq '1' }
    $isBox = { param([System.Xml.XmlElement] $El) $mx = & $mxOf $El; $mx -and $mx.GetAttribute('style') -like 'swimlane*' }

    $kept = 0; $added = 0; $removed = 0; $pruned = 0
    $pageNames = [System.Collections.Generic.List[string]]::new()
    $oldDiagrams = @($old.DocumentElement.SelectNodes('diagram'))
    foreach ($nd in @($new.DocumentElement.SelectNodes('diagram'))) {
        $name = $nd.GetAttribute('name'); $pageId = $nd.GetAttribute('id')
        $pageNames.Add($name)
        $od = @($oldDiagrams | Where-Object { $_.GetAttribute('id') -eq $pageId })[0]
        if (-not $od) { $od = @($oldDiagrams | Where-Object { $_.GetAttribute('name') -eq $name })[0] }
        if (-not $od -or $pageId -eq 'aac-page-legend') {
            # A new page, or the legend: as generated.
            $imported = $old.ImportNode($nd, $true)
            if ($od) { [void]$old.DocumentElement.ReplaceChild($imported, $od) } else { [void]$old.DocumentElement.AppendChild($imported); $added += @($nd.SelectNodes('.//object')).Count }
            continue
        }
        $oldModel = & $modelOf $od
        $newModel = & $modelOf $nd
        $oldRoot = $oldModel.SelectSingleNode('root'); $newRoot = $newModel.SelectSingleNode('root')
        $oldCells = & $cellsOf $oldRoot; $newCells = & $cellsOf $newRoot
        # Where new things go in a box: under its children, in rows.
        $cursor = @{}
        $childrenOf = { param([string] $Parent) @($oldRoot.ChildNodes | Where-Object { $_ -is [System.Xml.XmlElement] -and (& $mxOf $_) -and (& $mxOf $_).GetAttribute('parent') -eq $Parent -and -not (& $isEdge $_) }) }
        $grow = $null
        $grow = {
            # A box grows to hold its children, and the box holding it to hold it.
            param([string] $Id)
            if (-not $oldCells.Contains($Id)) { return }
            $geo = & $geoOf $oldCells[$Id]
            if (-not $geo) { return }
            $right = 0.0; $bottom = 0.0
            foreach ($child in & $childrenOf $Id) { $g = & $geoOf $child; $right = [Math]::Max($right, (& $num $g 'x') + (& $num $g 'width')); $bottom = [Math]::Max($bottom, (& $num $g 'y') + (& $num $g 'height')) }
            $changed = $false
            if ($right + $pad -gt (& $num $geo 'width')) { $geo.SetAttribute('width', [string]($right + $pad)); $changed = $true }
            if ($bottom + $pad -gt (& $num $geo 'height')) { $geo.SetAttribute('height', [string]($bottom + $pad)); $changed = $true }
            if ($changed) { $parent = (& $mxOf $oldCells[$Id]).GetAttribute('parent'); if ($parent -and $parent -ne '1') { & $grow $parent } }
        }

        foreach ($id in @($newCells.Keys)) {
            if (-not (& $isOurs $id)) { continue }
            $gen = $newCells[$id]
            if (& $isEdge $gen) {
                $genMx = & $mxOf $gen
                if ($oldCells.Contains($id)) {
                    $mx = & $mxOf $oldCells[$id]
                    & $styleDrop $mx $removedEdgeMark
                    $mx.SetAttribute('value', $genMx.GetAttribute('value'))
                    $kept++
                }
                else { [void]$oldRoot.AppendChild($old.ImportNode($gen, $true)); $oldCells[$id] = $oldRoot.LastChild }
                continue
            }
            if ($oldCells.Contains($id)) {
                # Kept: the user's geometry, box and style; Azure's data; the user's label if they changed it.
                $el = $oldCells[$id]
                $mx = & $mxOf $el
                & $styleDrop $mx $newMark; & $styleDrop $mx $newBoxMark; & $styleDrop $mx $removedMark
                if ($el.LocalName -eq 'object' -and $gen.LocalName -eq 'object') {
                    $edited = $el.GetAttribute('aacLabel') -and $el.GetAttribute('label') -ne $el.GetAttribute('aacLabel')
                    foreach ($attr in @($gen.Attributes)) {
                        if ($attr.Name -in 'id') { continue }
                        if ($attr.Name -eq 'label' -and $edited) { continue }
                        $el.SetAttribute($attr.Name, $attr.Value)
                    }
                    $el.RemoveAttribute('aacStatus')
                }
                # Its box was deleted (pruned, or by the user): back in the box Azure says.
                $parent = $mx.GetAttribute('parent')
                if ($parent -ne '1' -and -not $oldCells.Contains($parent)) { $mx.SetAttribute('parent', (& $mxOf $gen).GetAttribute('parent')) }
                $kept++
                continue
            }
            # Added: under what is in its box, or beside the drawing.
            $el = $old.ImportNode($gen, $true)
            $mx = & $mxOf $el
            $geo = & $geoOf $el
            $parent = $mx.GetAttribute('parent')
            $parentIsNew = $parent -ne '1' -and $newCells.Contains($parent) -and -not $oldCells.Contains($parent)
            if (-not $parentIsNew) {
                $w = & $num $geo 'width'; $h = & $num $geo 'height'
                if (-not $cursor.Contains($parent)) {
                    $bottom = if ($parent -eq '1') { 40.0 } else { $header + $pad - $gap }
                    $right = 40.0
                    foreach ($child in & $childrenOf $parent) { $g = & $geoOf $child; $bottom = [Math]::Max($bottom, (& $num $g 'y') + (& $num $g 'height')); $right = [Math]::Max($right, (& $num $g 'x') + (& $num $g 'width')) }
                    $cursor[$parent] = if ($parent -eq '1') { @{ x = $right + 80; y = 40.0; rowH = 0.0; left = $right + 80; limit = $right + 2400 } }
                    else { $pgeo = & $geoOf $oldCells[$parent]; @{ x = [double]$pad; y = $bottom + $gap; rowH = 0.0; left = [double]$pad; limit = [Math]::Max(400, (& $num $pgeo 'width') - $pad) } }
                }
                $c = $cursor[$parent]
                if ($c.x -gt $c.left -and $c.x + $w -gt $c.limit) { $c.x = $c.left; $c.y += $c.rowH + $gap; $c.rowH = 0 }
                # An icon's label hangs below it: leave room.
                $geo.SetAttribute('x', [string]$c.x); $geo.SetAttribute('y', [string]$c.y)
                $c.x += [Math]::Max($w, 140) + $gap; $c.rowH = [Math]::Max($c.rowH, $(if (& $isBox $el) { $h } else { $h + 52 }))
            }
            & $styleAdd $mx $(if (& $isBox $el) { $newBoxMark } else { $newMark })
            if ($el.LocalName -eq 'object') { $el.SetAttribute('aacStatus', "new $($Now.ToString('yyyy-MM-dd'))") }
            [void]$oldRoot.AppendChild($el)
            $oldCells[$id] = $el
            if (-not $parentIsNew -and $parent -ne '1') { & $grow $parent }
            $added++
        }

        # Removed from Azure: faded (or deleted with -Prune); their connections too.
        $gone = @($oldCells.Keys | Where-Object { (& $isOurs $_) -and -not $newCells.Contains($_) })
        $goneShapes = @($gone | Where-Object { -not (& $isEdge $oldCells[$_]) })
        $goneSet = [System.Collections.Generic.HashSet[string]]@($goneShapes)
        foreach ($id in $goneShapes) {
            $el = $oldCells[$id]
            if ($Prune) {
                # Its children that stay in Azure move to where Azure has them; the rest go with it.
                foreach ($child in & $childrenOf $id) { $cid = $child.GetAttribute('id'); if (-not $goneSet.Contains($cid) -and $newCells.Contains($cid)) { (& $mxOf $child).SetAttribute('parent', (& $mxOf $newCells[$cid]).GetAttribute('parent')) } }
                [void]$oldRoot.RemoveChild($el); $pruned++
                continue
            }
            if ($el.LocalName -eq 'object' -and $el.GetAttribute('aacStatus') -notlike 'removed*') {
                & $styleDrop (& $mxOf $el) $newMark; & $styleDrop (& $mxOf $el) $newBoxMark
                & $styleAdd (& $mxOf $el) $removedMark
                $el.SetAttribute('aacStatus', "removed $($Now.ToString('yyyy-MM-dd'))")
                $el.SetAttribute('tooltip', "Not found in Azure on $($Now.ToString('yyyy-MM-dd')) - deleted or moved. $($el.GetAttribute('tooltip'))")
                $removed++
            }
        }
        foreach ($id in @($gone | Where-Object { & $isEdge $oldCells[$_] })) {
            $el = $oldCells[$id]
            $mx = & $mxOf $el
            $endGone = -not $el.ParentNode -or @($mx.GetAttribute('source'), $mx.GetAttribute('target') | Where-Object { $_ -and -not ($oldRoot.SelectSingleNode("*[@id='$_']")) }).Count
            if ($Prune -or $endGone) { if ($el.ParentNode) { [void]$oldRoot.RemoveChild($el) } }
            else { & $styleAdd $mx $removedEdgeMark }
        }
    }

    $old.DocumentElement.SetAttribute('modified', $Now.ToString('o'))
    $writer = [System.IO.StringWriter]::new()
    $settings = [System.Xml.XmlWriterSettings]::new()
    $settings.Indent = $true
    $settings.OmitXmlDeclaration = $true
    $xw = [System.Xml.XmlWriter]::Create($writer, $settings)
    $old.Save($xw)
    $xw.Dispose()
    @{ Xml = $writer.ToString() + "`n"; Kept = $kept; Added = $added; Removed = $removed; Pruned = $pruned; Pages = $pageNames.ToArray() }
}