Public/Copy-SPSiteStructure.ps1

function Copy-SPSiteStructure {
    <#
    .SYNOPSIS
        Copie la structure d'un site (colonnes, types de contenu, listes, vues, navigation).
 
    .DESCRIPTION
        Les travaux de copie de contenu supposent que la destination possède déjà
        le bon schéma : sans les colonnes de site et les types de contenu, les
        métadonnées n'ont nulle part où atterrir et `AllowSchemaMismatch` ne fait
        que masquer le problème.
 
        Cette commande enveloppe `Get-PnPSiteTemplate` / `Invoke-PnPSiteTemplate`
        du moteur de provisionnement PnP, avec la résilience et la journalisation
        du reste du module.
 
        À exécuter **avant** `Invoke-SPMigration`.
 
    .PARAMETER SourceSiteUrl
        Site dont la structure est extraite.
 
    .PARAMETER TargetSiteUrl
        Site où la structure est appliquée. Omettre pour n'extraire que le modèle.
 
    .PARAMETER TemplatePath
        Fichier .xml ou .pnp du modèle. Généré s'il n'existe pas.
 
    .PARAMETER Handlers
        Sous-ensemble d'éléments à traiter (Fields, ContentTypes, Lists,
        Navigation, PageContents…). Par défaut : schéma uniquement, sans contenu.
 
    .EXAMPLE
        # Extraire puis appliquer
        Copy-SPSiteStructure -SourceSiteUrl https://contoso.sharepoint.com/sites/A `
                             -TargetSiteUrl https://contoso.sharepoint.com/sites/B `
                             -TemplatePath ./modele-A.xml -WhatIf
 
    .EXAMPLE
        # Extraire seulement, pour revue avant application
        Copy-SPSiteStructure -SourceSiteUrl https://contoso.sharepoint.com/sites/A `
                             -TemplatePath ./modele-A.xml
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$SourceSiteUrl,
        [string]$TargetSiteUrl,
        [Parameter(Mandatory)][string]$TemplatePath,

        [ValidateSet('Fields', 'ContentTypes', 'Lists', 'Navigation', 'SiteSecurity',
            'TermGroups', 'CustomActions', 'Features', 'RegionalSettings',
            'SupportedUILanguages', 'PropertyBagEntries', 'Pages', 'PageContents')]
        [string[]]$Handlers = @('Fields', 'ContentTypes', 'Lists', 'Navigation'),

        [switch]$ReuseExistingTemplate,

        # Auto : tente le modèle, et bascule sur la création bibliothèque par
        # bibliothèque si le service refuse. Invoke-PnPSiteTemplate tente des
        # opérations de niveau LOCATAIRE et échoue en « Access denied » sur un
        # compte pourtant administrateur des deux sites — sans repli, tout le
        # reste de la migration devient sans objet.
        [ValidateSet('Auto', 'Template', 'PerLibrary')]
        [string]$Method = 'Auto',

        [string]$ClientId = $env:SPM_CLIENT_ID,
        [string]$Tenant = $env:SPM_TENANT,
        [string]$Thumbprint = $env:SPM_CERT_THUMBPRINT
    )

    $connectSplat = @{ ClientId = $ClientId }
    if ($Thumbprint) {
        if (-not $Tenant) { throw 'Tenant est obligatoire avec un certificat.' }
        $connectSplat.Thumbprint = $Thumbprint
        $connectSplat.Tenant = $Tenant
    }
    else { $connectSplat.Interactive = $true }

    $extracted = $false

    # ------------------------------------------------------------- extraction
    if ($ReuseExistingTemplate -and (Test-Path -LiteralPath $TemplatePath)) {
        Write-SPMLog -Level INFO -Operation 'Structure' -Message "Modèle existant réutilisé : $TemplatePath"
    }
    else {
        $srcConn = Connect-SPMSite @connectSplat -Url $SourceSiteUrl

        $dir = Split-Path -Parent $TemplatePath
        if ($dir -and -not (Test-Path -LiteralPath $dir)) {
            New-Item -ItemType Directory -Path $dir -Force | Out-Null
        }

        Write-SPMLog -Level INFO -Operation 'Structure' -Message (
            "Extraction du modèle de $SourceSiteUrl (handlers : $($Handlers -join ', '))"
        )

        Invoke-SPMWithRetry -Operation 'Get-PnPSiteTemplate' -ScriptBlock {
            Get-PnPSiteTemplate -Out $TemplatePath -Handlers $Handlers -Force `
                -Connection $srcConn -ErrorAction Stop
        } | Out-Null

        $extracted = $true
        Write-SPMLog -Level SUCCESS -Operation 'Structure' -Message "Modèle extrait : $TemplatePath"
    }

    if (-not $TargetSiteUrl) {
        return [pscustomobject]@{
            Extracted    = $extracted
            Applied      = $false
            TemplatePath = (Resolve-Path $TemplatePath).Path
            Handlers     = $Handlers
        }
    }

    # ------------------------------------------------------------ application
    if (-not $PSCmdlet.ShouldProcess($TargetSiteUrl, "Appliquer le modèle $([System.IO.Path]::GetFileName($TemplatePath))")) {
        return [pscustomobject]@{
            Extracted    = $extracted
            Applied      = $false
            Simulated    = $true
            TemplatePath = (Resolve-Path $TemplatePath).Path
            Handlers     = $Handlers
        }
    }

    $dstConn = Connect-SPMSite @connectSplat -Url $TargetSiteUrl

    $schemaResult = $null
    $methodUsed = $Method

    if ($Method -ne 'PerLibrary') {
        Write-SPMLog -Level INFO -Operation 'Structure' -Message "Application du modèle sur $TargetSiteUrl"

        try {
            Invoke-SPMWithRetry -Operation 'Invoke-PnPSiteTemplate' -ScriptBlock {
                Invoke-PnPSiteTemplate -Path $TemplatePath -Handlers $Handlers `
                    -Connection $dstConn -ErrorAction Stop
            } | Out-Null

            $methodUsed = 'Template'
            Write-SPMLog -Level SUCCESS -Operation 'Structure' -Message "Structure appliquée sur $TargetSiteUrl"
        }
        catch {
            $message = ($_.Exception.Message -split [char]10)[0]

            if ($Method -eq 'Template') { throw }

            Write-SPMLog -Level WARNING -Operation 'Structure' -Message (
                "Provisionnement refusé, bascule sur la création bibliothèque par bibliothèque : $message"
            )
            $methodUsed = 'PerLibrary'
        }
    }

    if ($methodUsed -eq 'PerLibrary') {
        # Le repli ne couvre PAS ce que couvre un modèle : navigation, sécurité,
        # pages, entrées de sac de propriétés restent à la charge de l'opérateur.
        # Il couvre ce sans quoi aucun contenu ne peut atterrir : les
        # bibliothèques et leurs colonnes.
        Write-SPMLog -Level WARNING -Operation 'Structure' -Message (
            'Repli PerLibrary : bibliothèques et colonnes seulement. ' +
            'Navigation, sécurité et pages ne sont PAS reprises.'
        )

        $schemaSplat = @{
            SourceSiteUrl = $SourceSiteUrl
            TargetSiteUrl = $TargetSiteUrl
            ClientId = $ClientId
        }
        if ($Thumbprint) { $schemaSplat.Thumbprint = $Thumbprint; $schemaSplat.Tenant = $Tenant }

        $schemaResult = Copy-SPSiteSchema @schemaSplat
    }

    [pscustomobject]@{
        Extracted    = $extracted
        Applied      = $true
        Simulated    = $false
        Method       = $methodUsed
        Schema       = $schemaResult
        TemplatePath = (Resolve-Path $TemplatePath).Path
        Handlers     = $Handlers
        Target       = $TargetSiteUrl
    }
}