Public/Copy-SPSiteSchema.ps1

function Copy-SPSiteSchema {
    <#
    .SYNOPSIS
        Recrée les bibliothèques de documents et leurs colonnes, une par une.
 
    .DESCRIPTION
        Voie de secours — et sur bien des tenants, voie principale — face à
        `Copy-SPSiteStructure`, qui repose sur `Invoke-PnPSiteTemplate`.
 
        Le moteur de provisionnement tente des opérations de niveau LOCATAIRE.
        Il échoue donc en « Access denied » sur un compte pourtant administrateur
        des deux sites, ce qui est exactement le cas observé en production. La
        création directe d'une bibliothèque et la recopie de son schéma de
        colonnes n'exigent, elles, que des droits de site — ceux dont dispose une
        équipe de migration.
 
        Trois différences de comportement, toutes délibérées :
 
          * NON DESTRUCTIF. Une bibliothèque déjà présente n'est jamais recréée,
            ni vidée. Seules les colonnes manquantes sont ajoutées.
          * PAS D'ARRÊT SUR REFUS. Une colonne refusée est journalisée et le
            traitement continue : perdre une bibliothèque entière parce qu'une
            colonne de taxonomie résiste n'a aucun sens.
          * RÉGLAGES ALIGNÉS D'ABORD. Sans versionnage à destination,
            l'historique copié n'a nulle part où s'écrire — et on ne s'en aperçoit
            qu'après le transfert. Les sept réglages de la bibliothèque
            (versionnage majeur et mineur, limites, visibilité des brouillons,
            extraction obligatoire, modération) sont posés explicitement, même
            quand la valeur source est fausse, puis relus.
 
    .PARAMETER MatchUrl
        Crée la bibliothèque à l'URL de la source plutôt qu'à celle dérivée de
        son titre. `New-PnPList -Title 'Admin'` produit /Admin, alors que la
        source peut vivre à /Documents partages : les liens directs ne suivent
        pas. Écarté par défaut car l'URL visée peut déjà être occupée à
        destination — un conflit est alors signalé, pas contourné.
 
    .EXAMPLE
        Copy-SPSiteSchema -SourceSiteUrl https://contoso.sharepoint.com/sites/A `
                          -TargetSiteUrl https://contoso.sharepoint.com/sites/B
 
    .OUTPUTS
        Objet { Ok, Libraries, Created, Skipped, FieldsAdded, Problems }.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$SourceSiteUrl,
        [Parameter(Mandatory)][string]$TargetSiteUrl,
        [string[]]$IncludeLibrary,
        [string[]]$ExcludeLibrary,
        [switch]$MatchUrl,
        [string]$ClientId = $env:SPM_CLIENT_ID,
        [string]$Tenant = $env:SPM_TENANT,
        [string]$Thumbprint = $env:SPM_CERT_THUMBPRINT
    )

    if (-not $ClientId) {
        throw "ClientId est obligatoire. Définissez SPM_CLIENT_ID ou passez -ClientId."
    }

    $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 }

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

    $srcRoot = (Invoke-SPMWithRetry -Operation 'web/src' -ScriptBlock {
            Get-PnPWeb -Connection $srcConn -ErrorAction Stop
        }).ServerRelativeUrl.TrimEnd('/')

    $libs = @(Get-SPMSourceLibrary -Connection $srcConn -Include $IncludeLibrary -Exclude $ExcludeLibrary)

    Write-SPMLog -Level INFO -Operation 'Schema' -Message (
        "$($libs.Count) bibliothèque(s) à traiter de $SourceSiteUrl vers $TargetSiteUrl"
    )

    $rows = [System.Collections.Generic.List[object]]::new()
    $problems = [System.Collections.Generic.List[string]]::new()

    # Les titres déjà présents à destination, en UN appel. Interroger chaque
    # bibliothèque une par une pour savoir si elle existe faisait journaliser
    # une ERREUR par bibliothèque absente — « La liste X n'existe pas » — alors
    # que c'est précisément le cas nominal d'une création. Un opérateur voyait
    # sept erreurs rouges défiler pendant une exécution parfaitement réussie.
    $dstTitles = @(Invoke-SPMWithRetry -Operation 'lists/dst' -PassThruErrors -ScriptBlock {
            Get-PnPList -Connection $dstConn -ErrorAction Stop
        } | ForEach-Object { $_.Title })

    foreach ($lib in $libs) {
        $title = $lib.Title
        $row = [ordered]@{
            Library = $title; Action = 'inchangée'; FieldsAdded = 0; ViewsAdded = 0
            Versioning = '-'; Settings = '-'; Url = '-'; Problems = @()
        }

        $existing = $null
        if ($title -in $dstTitles) {
            $existing = Invoke-SPMWithRetry -Operation "list/dst/$title" -PassThruErrors -ScriptBlock {
                Get-PnPList -Identity $title -Connection $dstConn -ErrorAction Stop
            }
        }

        # --------------------------------------------------------- création
        if (-not $existing) {
            if (-not $PSCmdlet.ShouldProcess("$TargetSiteUrl :: $title", 'Créer la bibliothèque')) {
                $row.Action = 'simulée'
                $rows.Add([pscustomobject]$row)
                continue
            }

            $newSplat = @{
                Title = $title; Template = 'DocumentLibrary'; OnQuickLaunch = $true
                Connection = $dstConn; ErrorAction = 'Stop'
            }

            if ($MatchUrl) {
                # L'URL d'une bibliothèque n'est pas son titre. « Admin » peut
                # vivre à /Documents partages : sans -Url, les liens directs
                # vers la source ne pointeront jamais vers la copie.
                $srcLibUrl = Get-SPMLibraryUrlSegment -Library $lib -WebRoot $srcRoot
                if ($srcLibUrl) { $newSplat.Url = $srcLibUrl }
            }

            try {
                Invoke-SPMWithRetry -Operation "New-PnPList/$title" -ScriptBlock {
                    New-PnPList @newSplat
                } | Out-Null
                $row.Action = 'créée'
                $row.Url = if ($newSplat.ContainsKey('Url')) { $newSplat.Url } else { '(dérivée du titre)' }
            }
            catch {
                $message = ($_.Exception.Message -split [char]10)[0]
                $row.Action = "ÉCHEC : $message"
                $problems.Add("$title : $message")
                $rows.Add([pscustomobject]$row)
                continue
            }

            $existing = Invoke-SPMWithRetry -Operation "list/dst2/$title" -PassThruErrors -ScriptBlock {
                Get-PnPList -Identity $title -Connection $dstConn -ErrorAction Stop
            }
        }

        # -------------------------------------------------------- réglages
        # Les sept réglages, pas le seul versionnage, et posés même à faux :
        # une bibliothèque volontairement non versionnée à la source ne doit
        # pas hériter du défaut du service à destination.
        if ($existing) {
            $reglages = Copy-SPMLibrarySetting -LibraryTitle $title -SourceList $lib `
                -SourceConnection $srcConn -DestinationConnection $dstConn `
                -Preexisting:($row.Action -ne 'créée') -WhatIf:$WhatIfPreference

            $row.Settings = if ($reglages.Applied) {
                ($reglages.Requested.Keys | Sort-Object | ForEach-Object { "$_=$($reglages.Requested[$_])" }) -join ', '
            }
            elseif ($WhatIfPreference) { 'simulés' }
            else { 'non appliqués' }

            $row.Versioning = if ($reglages.Requested.ContainsKey('EnableVersioning')) {
                if ($reglages.Requested.EnableVersioning) {
                    "activé$(if ($reglages.Requested.ContainsKey('MajorVersions')) { " ($($reglages.Requested.MajorVersions))" })"
                }
                else { 'désactivé (comme la source)' }
            }
            else { 'inconnu à la source' }

            foreach ($d in $reglages.Divergences) {
                $problems.Add("$title : $d")
                $row.Problems += $d
            }
            foreach ($u in $reglages.Unknown) {
                $ecart = "réglage « $u » illisible à la source — laissé au défaut du service"
                $problems.Add("$title : $ecart")
                $row.Problems += $ecart
            }
        }

        # --------------------------------------------------------- colonnes
        if ($existing) {
            $ajout = Copy-SPMLibraryField -LibraryTitle $title -SourceConnection $srcConn `
                -DestinationConnection $dstConn -WhatIf:$WhatIfPreference
            $row.FieldsAdded = $ajout.Added
            $row.Problems += $ajout.Problems
            foreach ($p in $ajout.Problems) { $problems.Add("$title : $p") }

            # Les affichages : une bibliothèque fidèle au fichier près mais
            # méconnaissable à l'écran a raté quelque chose.
            $vues = Copy-SPMLibraryView -LibraryTitle $title -SourceConnection $srcConn `
                -DestinationConnection $dstConn -WhatIf:$WhatIfPreference
            $row.ViewsAdded = $vues.Added
            $row.Problems += $vues.Problems
            foreach ($p in $vues.Problems) { $problems.Add("$title : $p") }
        }

        $rows.Add([pscustomobject]$row)
        Write-SPMLog -Level INFO -Operation 'Schema' -Message (
            "$title — $($row.Action), $($row.FieldsAdded) colonne(s) ajoutée(s)"
        )
    }

    $created = @($rows | Where-Object Action -eq 'créée').Count
    $simulated = @($rows | Where-Object Action -eq 'simulée').Count
    $failed = @($rows | Where-Object { "$($_.Action)" -like 'ÉCHEC*' }).Count

    # Ce que ce chemin ne migre PAS. Quatre étages du code filtrent
    # BaseTemplate 101 : listes personnalisées, calendriers et listes de tâches
    # restent derrière. Ne pas les nommer, c'est laisser croire à une copie
    # exhaustive du site.
    $horsPerimetre = @(Get-SPMOutOfScopeList -Connection $srcConn)
    foreach ($l in $horsPerimetre) {
        Write-SPMLog -Level WARNING -Operation 'Schema' -Message (
            "HORS PÉRIMÈTRE — non migrée : « $($l.Title) » (modèle $($l.Template), $($l.ItemCount) élément(s))"
        )
    }

    $result = [pscustomobject]@{
        Ok = ($failed -eq 0)
        Libraries = $rows.ToArray()
        Created = $created
        Simulated = $simulated
        Skipped = @($rows | Where-Object Action -eq 'inchangée').Count
        FieldsAdded = ($rows | Measure-Object FieldsAdded -Sum).Sum
        ViewsAdded = ($rows | Measure-Object ViewsAdded -Sum).Sum
        NotMigrated = $horsPerimetre
        Problems = $problems.ToArray()
    }

    # Une simulation qui rapporte « 0 créée, 0 échec » se lit comme une
    # exécution sans effet. Elle doit dire ce qu'elle AURAIT fait, sinon elle ne
    # sert à rien — c'est le même travers qu'un bilan tout vert sur un transfert
    # qui n'a rien transféré.
    $resume = if ($simulated) {
        "Schéma SIMULÉ : $simulated bibliothèque(s) seraient créées. Aucune écriture."
    }
    else {
        "Schéma : $created créée(s), $($result.Skipped) déjà présente(s), " +
        "$($result.FieldsAdded) colonne(s) et $($result.ViewsAdded) affichage(s) ajouté(s), $failed échec(s)" +
        $(if ($horsPerimetre.Count) { ", $($horsPerimetre.Count) liste(s) HORS PÉRIMÈTRE" })
    }

    Write-SPMLog -Level $(if ($result.Ok) { 'SUCCESS' } else { 'WARNING' }) -Operation 'Schema' -Message $resume

    return $result
}