Modules/businessdev.ALbuild.Apps/Public/Invoke-BcTranslationSync.ps1

function Invoke-BcTranslationSync {
    <#
    .SYNOPSIS
        Syncs a project's target-language XLFs from its generated .g.xlf and fills them from the
        translation memory (the orchestration behind `albuild translation sync`).
 
    .DESCRIPTION
        For each target language: locates the generated base file (<App>.g.xlf, from the AL compiler's
        TranslationFile feature) and the target XLF under the project's translations folder, runs
        Sync-BcTranslation (structural merge), then - unless -NoMemory - builds a prioritised translation
        memory (Import-BcTranslationMemory) from the configured sources and applies it
        (Merge-BcTranslationMemory). Deterministic and offline: only exact TM matches are filled; the rest
        stay needs-translation for a downstream consumer.
 
        Sources (priority: self > sibling apps > BC base):
          - self: the app's own *.<lang>.xlf (highest)
          - xlf: a path/glob to other apps' XLFs (from albuild.json translation.memory.sources)
          - bc-base: Microsoft Base/System Application <lang> XLFs, resolved from the BC artifact cache
                     of the project's target application version (best-effort: skipped with a warning
                     when not present in the cache - never triggers a download).
 
        With -Check nothing is written: each language is synced into a temp copy and compared to the
        committed XLF; a difference is reported as drift (for the CI 'check' pipeline mode).
 
    .PARAMETER ProjectFolder
        The AL app folder (contains app.json and a Translations subfolder).
 
    .PARAMETER WorkspaceRoot
        The repo root holding albuild.json (for translation config + memory sources). Defaults to ProjectFolder.
 
    .PARAMETER Language
        Target language(s) (e.g. de-DE). Default: albuild.json translation.targetLanguages, else the
        languages of the existing *.??-??.xlf files.
 
    .PARAMETER BaseFile
        Override the generated base .g.xlf (default: the single *.g.xlf under the translations folder).
 
    .PARAMETER DetectSourceChanges
        Passed to Sync-BcTranslation (mark changed sources needs-adaptation). Default: $true.
 
    .PARAMETER NoMemory
        Skip the translation-memory fill (structural sync only).
 
    .PARAMETER Check
        Do not write: report whether the committed XLF is out of sync with the .g.xlf (drift).
 
    .OUTPUTS
        PSCustomObject { skipped?, translationsFolder, baseFile, languages: [ { language, targetFile,
        added, kept, adapted, tm { filled, skipped, byOrigin }, drift? } ] }.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ProjectFolder,
        [string] $WorkspaceRoot,
        [string[]] $Language,
        [string] $BaseFile,
        [bool] $DetectSourceChanges = $true,
        [switch] $NoMemory,
        [switch] $Check
    )

    if (-not (Test-Path -LiteralPath $ProjectFolder)) { throw "Project folder '$ProjectFolder' does not exist." }
    if (-not $WorkspaceRoot) { $WorkspaceRoot = $ProjectFolder }
    $cfg = $null
    try { $cfg = Get-ALbuildProjectConfig -AppFolder $ProjectFolder -WorkspaceRoot $WorkspaceRoot }
    catch { Write-ALbuildLog -Level Verbose "No project config: $($_.Exception.Message)" }
    $trans = if ($cfg) { $cfg.Translation } else { $null }

    $prop = {
        param($obj, [string] $name, $default)
        if ($null -eq $obj) { return $default }
        $p = $obj.PSObject.Properties | Where-Object { $_.Name -ieq $name } | Select-Object -First 1
        if ($p -and $null -ne $p.Value) { return $p.Value }
        return $default
    }

    # Translations folder + base .g.xlf. No feature / no generated file => nothing to do (clean skip).
    $tf = Join-Path $ProjectFolder 'Translations'
    if (-not (Test-Path -LiteralPath $tf)) { $tf = $ProjectFolder }
    # NB: wrap the whole if-expression in @() - a bare `$x = if (...) { @(one) }` unwraps a single-element
    # array back to a scalar, and $x.Count then throws under Set-StrictMode -Version Latest.
    $gxlf = @(if ($BaseFile) { Get-Item -LiteralPath $BaseFile -ErrorAction SilentlyContinue }
        else { Get-ChildItem -LiteralPath $tf -Filter '*.g.xlf' -File -ErrorAction SilentlyContinue })
    if ($gxlf.Count -eq 0) {
        Write-ALbuildLog "No generated .g.xlf found under '$tf' - skipping translation sync (no TranslationFile feature or not built yet)."
        return [PSCustomObject]@{ skipped = 'no-g-xlf'; translationsFolder = $tf; baseFile = $null; languages = @() }
    }
    $baseXlf = $gxlf[0].FullName
    $appName = [regex]::Replace($gxlf[0].Name, '\.g\.xlf$', '')

    # Target languages: explicit -> config -> discovered from existing *.??-??.xlf.
    $languages = @($Language)
    if (-not $languages -or $languages.Count -eq 0) { $languages = @(& $prop $trans 'targetLanguages' @()) }
    if (-not $languages -or $languages.Count -eq 0) {
        $languages = @(Get-ChildItem -LiteralPath $tf -Filter '*.xlf' -File -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -notlike '*.g.xlf' -and $_.BaseName -match '\.([a-z]{2}-[A-Z]{2})$' } |
                ForEach-Object { $matches[1] } | Select-Object -Unique)
    }
    if (-not $languages -or $languages.Count -eq 0) {
        Write-ALbuildLog "No target languages configured or found for '$appName' - skipping translation sync."
        return [PSCustomObject]@{ skipped = 'no-target-languages'; translationsFolder = $tf; baseFile = $baseXlf; languages = @() }
    }

    $memEnabled = -not $NoMemory -and [bool](& $prop (& $prop $trans 'memory' $null) 'enabled' $true)
    $contextAware = [bool](& $prop (& $prop $trans 'memory' $null) 'contextAware' $true)
    $results = [System.Collections.Generic.List[object]]::new()

    foreach ($lang in $languages) {
        $targetFile = Join-Path $tf "$appName.$lang.xlf"
        if ($Check) {
            # Drift = the committed XLF's unit set (id -> source) differs from the .g.xlf's, i.e. strings were
            # added, removed, or their source changed. Compared semantically (not by raw text) so XML
            # re-serialisation / target / formatting differences never count as drift.
            [xml] $baseDoc = Get-Content -LiteralPath $baseXlf -Raw -Encoding UTF8
            $baseMap = @{}
            foreach ($bu in (Get-BcXliffUnit -Document $baseDoc)) { if ($bu.Id) { $baseMap[$bu.Id] = "$($bu.Source)" } }
            $tgtMap = @{}
            if (Test-Path -LiteralPath $targetFile) {
                [xml] $tgtDoc = Get-Content -LiteralPath $targetFile -Raw -Encoding UTF8
                foreach ($tu in (Get-BcXliffUnit -Document $tgtDoc)) { if ($tu.Id) { $tgtMap[$tu.Id] = "$($tu.Source)" } }
            }
            $drift = $false
            if ($baseMap.Count -ne $tgtMap.Count) { $drift = $true }
            else { foreach ($k in $baseMap.Keys) { if (-not $tgtMap.ContainsKey($k) -or $tgtMap[$k] -ne $baseMap[$k]) { $drift = $true; break } } }
            $results.Add([PSCustomObject]@{ language = $lang; targetFile = $targetFile; drift = $drift })
            continue
        }

        Sync-BcTranslation -BaseFile $baseXlf -TargetFile $targetFile -DetectSourceChanges $DetectSourceChanges | Out-Null
        $tm = $null
        if ($memEnabled) {
            $refs = @(Resolve-BcTranslationMemorySource -WorkspaceRoot $WorkspaceRoot -Language $lang -TargetFile $targetFile -Config $trans)
            if ($refs.Count -gt 0) {
                $idx = Import-BcTranslationMemory -Reference $refs -ContextAware $contextAware
                $tm = Merge-BcTranslationMemory -Path $targetFile -Index $idx
            }
        }
        $results.Add([PSCustomObject]@{ language = $lang; targetFile = $targetFile; tm = $tm })
    }

    return [PSCustomObject]@{ translationsFolder = $tf; baseFile = $baseXlf; app = $appName; languages = @($results) }
}