Public/Sync-XliffFile.ps1

function Sync-XliffFile {
<#
.SYNOPSIS
    Synchronizes a translated XLIFF file with a generated source XLIFF file.

.DESCRIPTION
    Keeps a language file aligned with a Business Central generated `.g.xlf`
    source file after captions, labels, pages, tables, or reports change.

    For each `<trans-unit>` in the source file:

    - **Missing in target** -> unit is copied into the target with an empty target
      and state `needs-translation` (or the value of **-NewUnitState**).
    - **Same id, different source** -> source text in the target is updated, the
      existing French translation is preserved, and the unit is marked for review.
    - **Same id, same source** -> existing target text and metadata are left alone.

    When **-RemoveObsolete** is specified, units that exist only in the target
    file are removed because they no longer exist in the generated source.

    This is the primary command for recurring localization maintenance:

        Sync-XliffFile `
            -SourcePath .\Translations\Systemization.g.xlf `
            -TargetPath .\Translations\Systemization.fr-FR.xlf `
            -RemoveObsolete `
            -PassThru

.PARAMETER SourcePath
    Generated source `.g.xlf` file produced by AL compilation.

.PARAMETER TargetPath
    Translated language file to update, for example `Systemization.fr-FR.xlf`.

.PARAMETER OutputPath
    Optional output path. Defaults to **TargetPath** (in-place update).

.PARAMETER RemoveObsolete
    Removes `<trans-unit>` entries from the target when their **Id** no longer
    exists in the source file.

.PARAMETER NewUnitState
    Target state assigned to newly added units. Default: `needs-translation`.

.PARAMETER ChangedSourceState
    Target state assigned when source text changed but an existing translation
    should be kept for review. Default: `needs-review`.

.PARAMETER PassThru
    Returns an **XliffParser.SyncReport** object describing added, removed, and
    source-changed units.

.OUTPUTS
    [pscustomobject]
        When `-PassThru` is specified. Properties include **AddedCount**,
        **RemovedCount**, **SourceChangedCount**, and string arrays for each change
        category.

.EXAMPLE
    Sync-XliffFile `
        -SourcePath .\Translations\Systemization.g.xlf `
        -TargetPath .\Translations\Systemization.fr-FR.xlf `
        -RemoveObsolete `
        -PassThru

    Updates the French file in place and returns a change report.

.EXAMPLE
    Sync-XliffFile `
        -SourcePath .\Translations\MyApp.g.xlf `
        -TargetPath .\Translations\MyApp.fr-FR.xlf `
        -OutputPath .\artifacts\MyApp.fr-FR.synced.xlf

    Writes a synchronized copy without overwriting the original target file.

.NOTES
    Author: XliffParser Contributors

    The target document structure (groups, notes, attributes) is preserved.
    New units are appended to the first `<group>` under `<body>`, matching
    Business Central XLIFF layout conventions.
#>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$SourcePath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$TargetPath,

        [string]$OutputPath,

        [switch]$RemoveObsolete,

        [ValidateSet('new', 'needs-translation', 'translated')]
        [string]$NewUnitState = 'needs-translation',

        [ValidateSet('needs-review', 'needs-review-translation', 'needs-adaptation')]
        [string]$ChangedSourceState = 'needs-review',

        [switch]$PassThru
    )

    $resolvedSourcePath = Resolve-XliffPath -Path $SourcePath
    $resolvedTargetPath = Resolve-XliffPath -Path $TargetPath
    $destination = if ($OutputPath) { $OutputPath } else { $resolvedTargetPath }

    Write-Verbose "Loading source XLIFF '$resolvedSourcePath'."
    $sourceDocument = Read-XliffXmlDocument -Path $resolvedSourcePath
    Write-Verbose "Loading target XLIFF '$resolvedTargetPath'."
    $targetDocument = Read-XliffXmlDocument -Path $resolvedTargetPath

    $sourceUnits = @(Get-XliffTranslationUnitsFromDocument -Document $sourceDocument -Path $resolvedSourcePath)
    $targetUnits = @(Get-XliffTranslationUnitsFromDocument -Document $targetDocument -Path $resolvedTargetPath)
    $targetMap = $targetUnits | Get-XliffUnitMap
    $sourceMap = $sourceUnits | Get-XliffUnitMap

    $added = [System.Collections.Generic.List[string]]::new()
    $removed = [System.Collections.Generic.List[string]]::new()
    $sourceChanged = [System.Collections.Generic.List[string]]::new()
    $savedPath = $null

    foreach ($sourceUnit in $sourceUnits) {
        if (-not $sourceUnit.Id) {
            continue
        }

        if ($targetMap.ContainsKey($sourceUnit.Id)) {
            $targetUnit = $targetMap[$sourceUnit.Id]
            if ($targetUnit.Source -ne $sourceUnit.Source) {
                Write-Verbose "Source text changed for '$($sourceUnit.Id)'. Marking for review."
                $sourceNode = Ensure-XliffChildElement -Parent ([System.Xml.XmlElement]$targetUnit.XmlNode) -LocalName 'source'
                $sourceNode.InnerText = $sourceUnit.Source
                Set-XliffUnitState -UnitNode ([System.Xml.XmlElement]$targetUnit.XmlNode) -State $ChangedSourceState
                $sourceChanged.Add($sourceUnit.Id)
            }

            continue
        }

        Write-Verbose "Adding missing translation unit '$($sourceUnit.Id)'."
        [void](Import-XliffUnitNode -SourceUnitNode ([System.Xml.XmlElement]$sourceUnit.XmlNode) -TargetDocument $targetDocument -InitialTarget '' -InitialState $NewUnitState)
        $added.Add($sourceUnit.Id)
    }

    if ($RemoveObsolete) {
        foreach ($targetUnit in $targetUnits) {
            if ($targetUnit.Id -and -not $sourceMap.ContainsKey($targetUnit.Id)) {
                Write-Verbose "Removing obsolete translation unit '$($targetUnit.Id)'."
                Remove-XliffUnitNode -UnitNode $targetUnit.XmlNode
                $removed.Add($targetUnit.Id)
            }
        }
    }

    if ($PSCmdlet.ShouldProcess($destination, 'Save synchronized XLIFF file')) {
        $savedPath = Save-XliffXmlDocument -Document $targetDocument -Path $destination
    }

    $report = [pscustomobject]@{
        PSTypeName          = 'XliffParser.SyncReport'
        SourcePath          = $resolvedSourcePath
        TargetPath          = $resolvedTargetPath
        OutputPath          = $savedPath
        AddedCount          = $added.Count
        RemovedCount        = $removed.Count
        SourceChangedCount  = $sourceChanged.Count
        Added               = $added.ToArray()
        Removed             = $removed.ToArray()
        SourceChanged       = $sourceChanged.ToArray()
    }

    if ($PassThru) {
        $report
    }
}