Public/New-SPMigrationPlan.ps1

function New-SPMigrationPlan {
    <#
    .SYNOPSIS
        Charge et valide un plan de migration déclaratif.
 
    .DESCRIPTION
        Remplace la configuration par une série de Read-Host, qui rendait toute
        exécution non surveillée impossible. Le plan est un fichier JSON (ou YAML
        si le module powershell-yaml est présent) validé à la lecture.
 
        Les valeurs de la forme ${NOM_VARIABLE} sont résolues depuis les variables
        d'environnement : aucun secret n'a à figurer dans le fichier.
 
    .PARAMETER Path
        Chemin du fichier de plan (.json, .yaml ou .yml).
 
    .PARAMETER InputObject
        Plan déjà matérialisé sous forme de hashtable ou d'objet — utile pour les tests.
 
    .EXAMPLE
        $plan = New-SPMigrationPlan -Path .\examples\plan-sharepoint-to-sharepoint.json
        Test-SPMigrationPlan -Plan $plan
 
    .OUTPUTS
        Objet plan normalisé, consommable par Test-SPMigrationPlan et Invoke-SPMigration.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)]
        [string]$Path,

        [Parameter(Mandatory, ParameterSetName = 'Object')]
        [object]$InputObject
    )

    # ---------------------------------------------------------------- lecture
    if ($PSCmdlet.ParameterSetName -eq 'Path') {
        if (-not (Test-Path -LiteralPath $Path)) {
            throw "Plan de migration introuvable : $Path"
        }

        $raw = Get-Content -LiteralPath $Path -Raw -Encoding utf8
        $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()

        switch ($extension) {
            '.json' { $parsed = $raw | ConvertFrom-Json -AsHashtable }
            { $_ -in '.yaml', '.yml' } {
                if (-not (Get-Module powershell-yaml -ListAvailable)) {
                    throw "Le format YAML requiert le module powershell-yaml. " +
                    "Installez-le (Install-Module powershell-yaml -Scope CurrentUser) " +
                    "ou fournissez le plan au format JSON."
                }
                Import-Module powershell-yaml -ErrorAction Stop
                $parsed = ConvertFrom-Yaml $raw
            }
            default { throw "Extension de plan non prise en charge : '$extension' (attendu .json, .yaml ou .yml)" }
        }
    }
    else {
        $parsed = if ($InputObject -is [hashtable]) { $InputObject }
        else { $InputObject | ConvertTo-Json -Depth 12 | ConvertFrom-Json -AsHashtable }
    }

    if ($null -eq $parsed) { throw 'Plan de migration vide.' }

    # ------------------------------------------- résolution des ${VARIABLES}
    $parsed = Resolve-SPMPlanVariable -Value $parsed

    # ------------------------------------------------------------ validation
    # Note : Set-StrictMode -Version Latest fait échouer l'accès à une clé
    # absente d'une hashtable. Toute lecture passe donc par Get-SPMPlanValue.
    $problems = [System.Collections.Generic.List[string]]::new()

    $source = Get-SPMPlanValue $parsed 'source'
    $destination = Get-SPMPlanValue $parsed 'destination'
    $auth = Get-SPMPlanValue $parsed 'auth'

    if ($null -eq $destination) { $problems.Add("Section 'destination' manquante") }
    elseif (-not (Get-SPMPlanValue $destination 'url')) { $problems.Add("'destination.url' manquant") }

    $sourceType = 'sharepoint'
    if ($null -eq $source) {
        $problems.Add("Section 'source' manquante")
    }
    else {
        $declaredType = Get-SPMPlanValue $source 'type'
        if ($declaredType) { $sourceType = "$declaredType".ToLowerInvariant() }

        if ($sourceType -notin @('sharepoint', 'fileshare')) {
            $problems.Add("'source.type' doit valoir 'sharepoint' ou 'fileshare' (reçu : $sourceType)")
        }
        if ($sourceType -eq 'sharepoint' -and -not (Get-SPMPlanValue $source 'url')) {
            $problems.Add("'source.url' est obligatoire pour source.type = sharepoint")
        }
        if ($sourceType -eq 'fileshare' -and -not (Get-SPMPlanValue $source 'path')) {
            $problems.Add("'source.path' est obligatoire pour source.type = fileshare")
        }
    }

    $authMode = 'interactive'
    if ($null -ne $auth) {
        $declaredMode = Get-SPMPlanValue $auth 'mode'
        if ($declaredMode) { $authMode = "$declaredMode".ToLowerInvariant() }

        if ($authMode -notin @('interactive', 'devicelogin', 'certificate', 'managedidentity')) {
            $problems.Add("'auth.mode' invalide : $authMode")
        }
        if ($authMode -in @('interactive', 'devicelogin', 'certificate') -and -not (Get-SPMPlanValue $auth 'clientId')) {
            $problems.Add("'auth.clientId' est obligatoire pour le mode $authMode " +
                "(l'application PnP multi-tenant par défaut a été retirée en 2024)")
        }
        if ($authMode -eq 'certificate') {
            if (-not (Get-SPMPlanValue $auth 'tenant')) {
                $problems.Add("'auth.tenant' est obligatoire pour le mode certificate")
            }
            if (-not (Get-SPMPlanValue $auth 'thumbprint') -and -not (Get-SPMPlanValue $auth 'certificatePath')) {
                $problems.Add("'auth.thumbprint' ou 'auth.certificatePath' est obligatoire pour le mode certificate")
            }
        }
    }

    if ($problems.Count -gt 0) {
        throw "Plan de migration invalide :`n - " + ($problems -join "`n - ")
    }

    # ----------------------------------------------------------- normalisation
    $options = Merge-SPMPlanSection -Defaults @{
        preserveVersions = $true
        preservePermissions = $false
        incremental = $false
        overwrite = $false
        recurse = $true
        allowSchemaMismatch = $false
        # async = $false tant que le suivi de travail (Receive-PnPCopyMoveJobStatus)
        # n'est pas branché : une soumission asynchrone ne peut être confirmée, et
        # un défaut à $true faisait rapporter comme migré ce qui n'était que soumis.
        async = $false

        # 'item' : une unité par fichier et par dossier. Reprise partielle,
        # avancement visible, repli par élément. Un travail de copie
        # par élément — plus lent, mais reprenable.
        # 'root' : une seule unité pour tout le sous-arbre. Le plus rapide,
        # mais un échec en fin de course fait tout recommencer.
        granularity = 'item'

        # Réapplique la métadonnée source après transfert au lieu de la supposer
        # préservée. Écriture idempotente (SystemUpdate).
        applyMetadata = $true

        # Compare la destination à la source en fin de migration. Le rapport dit
        # ce que le moteur croit avoir fait ; cette passe dit ce qui s'y trouve.
        # Coûteuse — elle relit tout — donc explicite, jamais implicite.
        verify = $false

        # Repli téléchargement/téléversement si le travail de copie est refusé.
        # PERD L'HISTORIQUE DES VERSIONS : désactivé par défaut, pour que la
        # perte soit un choix et non une surprise.
        allowLocalFallback = $false
    } -Provided (Get-SPMPlanValue $parsed 'options')

    if (Get-SPMPlanValue (Get-SPMPlanValue $parsed 'options') 'throttle' | ForEach-Object { Get-SPMPlanValue $_ 'maxParallel' }) {
        Write-SPMLog -Level WARNING -Operation 'Plan' -Message (
            "'throttle.maxParallel' est présent dans le plan mais IGNORÉ : le moteur ne parallélise " +
            'pas côté client. Retirez-le pour éviter de croire à un réglage de débit inexistant.'
        )
    }

    $throttle = Merge-SPMPlanSection -Defaults @{
        # maxParallel a été RETIRÉ. Il figurait ici, était documenté, et n'était
        # lu par aucune ligne du moteur : la parallélisation repose entièrement
        # sur l'asynchronisme du service. Une option qui ne fait rien laisse
        # croire à un réglage de débit qui n'existe pas. Un plan qui la porte
        # encore reste accepté — elle est simplement ignorée, et signalée.
        maxRetries = 6
        baseDelayMs = 1000
        maxDelayMs = 120000
    } -Provided (Get-SPMPlanValue $options 'throttle')

    $validation = Merge-SPMPlanSection -Defaults @{
        maxUrlLength = 400
        blockedExtensions = @()
        failOnCheckedOut = $true
        allowEmptyFiles = $false

        # Une étiquette de rétention perdue ne se voit dans aucun rapport, et se
        # découvre en audit. Le pré-vol BLOQUE donc sur des éléments étiquetés ou
        # déclarés enregistrements, jusqu'à ce que la perte possible soit
        # explicitement acceptée. Le défaut protège ; l'exception se déclare.
        acceptLabelLoss = $false

        # Que faire d'un élément que le pré-vol refuse.
        # 'stop' : la migration n'a pas lieu. Défaut.
        # 'exclude' : l'élément est mis en QUARANTAINE — écarté, listé dans un
        # fichier dédié, compté dans le bilan — et le reste part.
        # Sur des dizaines de milliers de fichiers il y aura toujours un
        # « ~$brouillon.docx » ou un fichier vide. La seule échappatoire était
        # -SkipPreflight, qui désactive TOUS les contrôles, y compris la
        # longueur d'URL : une porte de sortie qui coûte plus cher que le
        # problème qu'elle contourne.
        onError = 'stop'
    } -Provided (Get-SPMPlanValue $parsed 'validation')

    if ("$($validation.onError)" -notin 'stop', 'exclude') {
        throw "validation.onError doit valoir 'stop' ou 'exclude', pas '$($validation.onError)'."
    }

    $planName = Get-SPMPlanValue $parsed 'name'
    if (-not $planName) {
        $planName = if ($PSCmdlet.ParameterSetName -eq 'Path') {
            [System.IO.Path]::GetFileNameWithoutExtension($Path)
        }
        else { 'plan' }
    }

    $plan = [pscustomobject]@{
        Version = (Get-SPMPlanValue $parsed 'version') ?? 1
        Name = $planName
        Source = [pscustomobject]@{
            Type = $sourceType
            Url = Get-SPMPlanValue $source 'url'
            Path = Get-SPMPlanValue $source 'path'
        }
        Destination = [pscustomobject]@{
            Url = Get-SPMPlanValue $destination 'url'
        }
        Auth = [pscustomobject]@{
            Mode = $authMode
            ClientId = Get-SPMPlanValue $auth 'clientId'
            Tenant = Get-SPMPlanValue $auth 'tenant'
            Thumbprint = Get-SPMPlanValue $auth 'thumbprint'
            CertificatePath = Get-SPMPlanValue $auth 'certificatePath'
        }
        Options = [pscustomobject]$options
        Throttle = [pscustomobject]$throttle
        Validation = [pscustomobject]$validation
        Tasks = @((Get-SPMPlanValue $parsed 'tasks') ?? @())
        SourceFile = if ($PSCmdlet.ParameterSetName -eq 'Path') { (Resolve-Path $Path).Path } else { $null }
    }

    Write-SPMLog -Level SUCCESS -Operation 'Plan' -Message (
        "Plan '$($plan.Name)' chargé : $($plan.Source.Type) -> $($plan.Destination.Url), " +
        "$($plan.Tasks.Count) tâche(s), versions=$($plan.Options.preserveVersions), incrémental=$($plan.Options.incremental)"
    )

    return $plan
}