Private/Schema.ps1

<#
    Briques de recréation de schéma, partagées par Copy-SPSiteSchema et
    Copy-SPSiteStructure.
 
    Elles existent parce que le moteur de provisionnement de PnP
    (Invoke-PnPSiteTemplate) échoue en « Access denied » sur des tenants où la
    création directe fonctionne : il tente des opérations de niveau locataire,
    alors qu'une équipe de migration n'a que des droits de site.
#>


# Bibliothèques que SharePoint crée lui-même : les recopier n'a pas de sens, et
# certaines refusent la création. Les deux langues, parce qu'un même tenant
# mélange souvent des sites créés dans l'une et dans l'autre.
$script:SPMSystemLibrary = @(
    'Pièces jointes', 'Attachments'
    'Modèles de formulaire', 'Form Templates'
    'Bibliothèque de styles', 'Style Library'
    'Ressources du site', 'Site Assets'
    'Pages du site', 'Site Pages'
    'Documents partagés du site'
    'Images de la collection de sites', 'Site Collection Images'
    'Galerie de composants WebPart', 'Web Part Gallery'
    'Modèles de liste', 'List Template Gallery'
    'Solutions', 'Solution Gallery'
    'Thèmes', 'Theme Gallery'
    'Composants de contenu', 'Content and Structure Reports'
)

function Get-SPMSourceLibrary {
    <#
    .SYNOPSIS
        Bibliothèques de documents d'un site, hors bibliothèques système.
 
    .DESCRIPTION
        BaseTemplate 101 = bibliothèque de documents. Les listes masquées et les
        bibliothèques système sont écartées ; -Include et -Exclude affinent.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]$Connection,
        [string[]]$Include,
        [string[]]$Exclude
    )

    # RootFolder est chargé d'emblée : c'est lui qui porte l'URL réelle de la
    # bibliothèque, distincte de son titre, et le recharger liste par liste
    # coûterait un aller-retour par bibliothèque.
    $lists = @(Invoke-SPMWithRetry -Operation 'lists/src' -ScriptBlock {
            Get-PnPList -Includes RootFolder, DefaultViewUrl, EnableVersioning, EnableMinorVersions,
            MajorVersionLimit, MajorWithMinorVersionsLimit, DraftVersionVisibility,
            ForceCheckout, EnableModeration `
                -Connection $Connection -ErrorAction Stop
        })

    return @($lists | Where-Object {
            $_.BaseTemplate -eq 101 -and
            -not $_.Hidden -and
            $_.Title -notin $script:SPMSystemLibrary -and
            (-not $Include -or $_.Title -in $Include) -and
            (-not $Exclude -or $_.Title -notin $Exclude)
        })
}

function Get-SPMLibraryUrlSegment {
    <#
    .SYNOPSIS
        Segment d'URL d'une bibliothèque, relatif à son site.
 
    .DESCRIPTION
        Le TITRE d'une bibliothèque et son URL sont deux choses distinctes :
        « Admin » peut vivre à /Documents partages. `New-PnPList -Title` dérive
        l'URL du titre, ce qui produit une copie dont les adresses ne
        correspondent pas à la source.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]$Library,
        [Parameter(Mandatory)][string]$WebRoot
    )

    # Même piège que Get-SPMListTitleForPath : RootFolder n'est pas garanti
    # chargé sur un Get-PnPList groupé. On passe par la même résolution à deux
    # voies plutôt que de conclure « pas d'URL » sur une bibliothèque valide.
    $url = Get-SPMListRootUrl -List $Library
    if (-not $url) { return $null }

    if ($url.StartsWith($WebRoot, [StringComparison]::OrdinalIgnoreCase)) {
        $url = $url.Substring($WebRoot.Length)
    }
    return $url.Trim('/')
}

function Get-SPMListSetting {
    <#
    .SYNOPSIS
        Lit les réglages d'une bibliothèque, sans supposer qu'ils soient chargés.
 
    .DESCRIPTION
        Une propriété CSOM non demandée ne vaut pas « faux » : y accéder LÈVE
        une exception (« The property or field has not been initialized »). Les
        confondre ferait désactiver à destination un réglage actif à la source,
        en silence. Chaque propriété est donc lue isolément ; celles qui ne sont
        pas chargées reviennent à $null — c'est-à-dire « inconnue », un état
        distinct de « fausse ».
 
    .OUTPUTS
        Hashtable des sept réglages, valeur $null quand la propriété est absente.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([Parameter(Mandatory)]$List)

    $lecture = {
        param($nom)
        $prop = $List.PSObject.Properties[$nom]
        if (-not $prop) { return $null }
        try { return $prop.Value }
        catch { return $null }   # propriété non initialisée par le serveur
    }

    @{
        EnableVersioning = & $lecture 'EnableVersioning'
        EnableMinorVersions = & $lecture 'EnableMinorVersions'
        MajorVersions = & $lecture 'MajorVersionLimit'
        MinorVersions = & $lecture 'MajorWithMinorVersionsLimit'
        DraftVersionVisibility = & $lecture 'DraftVersionVisibility'
        ForceCheckout = & $lecture 'ForceCheckout'
        EnableModeration = & $lecture 'EnableModeration'
    }
}

function Copy-SPMLibrarySetting {
    <#
    .SYNOPSIS
        Réplique les réglages d'une bibliothèque source, puis relit pour vérifier.
 
    .DESCRIPTION
        Ne reprendre que le versionnage — et seulement quand la source l'avait
        actif — laissait le service décider de tout le reste : une bibliothèque
        volontairement NON versionnée à la source se retrouvait versionnée à
        destination, l'extraction obligatoire disparaissait, la modération aussi.
        Les sept réglages sont donc posés EXPLICITEMENT, y compris quand la
        valeur est fausse, puis relus : un réglage refusé par le service ne doit
        pas passer pour appliqué.
 
        Les réglages inconnus à la source (propriété non chargée) ne sont pas
        posés : on ne remplace pas une valeur mesurée par une supposition.
 
    .OUTPUTS
        Objet { Applied, Requested, Divergences, Unknown }.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$SourceList,
        [Parameter(Mandatory)]$DestinationConnection,
        $SourceConnection,
        # La bibliothèque existait déjà à destination : on ne réplique alors
        # aucun réglage qui puisse détruire ce qui s'y trouve.
        [switch]$Preexisting
    )

    $souhaite = Get-SPMListSetting -List $SourceList
    $inconnus = @($souhaite.Keys | Where-Object { $null -eq $souhaite[$_] } | Sort-Object)

    # Un Get-PnPList groupé ne garantit pas le chargement de ces propriétés —
    # le même piège que RootFolder. Plutôt que de conclure « réglage absent »
    # sur une bibliothèque qui en a un, on relit la liste par son identité.
    if ($inconnus.Count -and $SourceConnection) {
        $precis = Invoke-SPMWithRetry -Operation "list/src/settings/$LibraryTitle" -PassThruErrors -ScriptBlock {
            Get-PnPList -Identity $LibraryTitle -Connection $SourceConnection `
                -Includes EnableVersioning, EnableMinorVersions, MajorVersionLimit,
            MajorWithMinorVersionsLimit, DraftVersionVisibility, ForceCheckout, EnableModeration `
                -ErrorAction Stop
        }
        if ($precis) {
            $complement = Get-SPMListSetting -List $precis
            foreach ($k in $inconnus) { $souhaite[$k] = $complement[$k] }
            $inconnus = @($souhaite.Keys | Where-Object { $null -eq $souhaite[$_] } | Sort-Object)
        }
    }
    $aPoser = @{}
    foreach ($k in $souhaite.Keys) {
        if ($null -ne $souhaite[$k]) { $aPoser[$k] = $souhaite[$k] }
    }

    # MajorVersions à 0 signifie « sans limite » côté SharePoint, mais
    # Set-PnPList refuse la valeur : on n'envoie la limite que si elle est utile.
    foreach ($limite in 'MajorVersions', 'MinorVersions') {
        if ($aPoser.ContainsKey($limite) -and [int]$aPoser[$limite] -le 0) { $aPoser.Remove($limite) }
    }
    if ($aPoser.ContainsKey('MinorVersions') -and -not $souhaite.EnableMinorVersions) {
        $aPoser.Remove('MinorVersions')   # sans versions mineures, la limite n'a pas de sens
    }

    $divergences = [System.Collections.Generic.List[string]]::new()

    # Désactiver le versionnage d'une bibliothèque qui existait déjà, c'est
    # exposer son historique à la purge — sur une destination que le module
    # s'interdit de vider. Fidélité contre destruction : on garde l'écart, on
    # l'inscrit, on ne l'applique pas.
    if ($Preexisting -and $aPoser.ContainsKey('EnableVersioning') -and -not $aPoser.EnableVersioning) {
        $aPoser.Remove('EnableVersioning')
        $aPoser.Remove('MajorVersions')
        $divergences.Add(
            'versionnage laissé tel quel à destination : la source ne le versionne pas, mais la ' +
            'bibliothèque existait déjà et le désactiver exposerait son historique à la purge'
        )
    }

    if (-not $aPoser.Count) {
        return [pscustomobject]@{
            Applied = $false; Requested = @{}; Divergences = $divergences.ToArray(); Unknown = $inconnus
        }
    }

    if (-not $PSCmdlet.ShouldProcess($LibraryTitle, 'Répliquer les réglages de bibliothèque')) {
        return [pscustomobject]@{
            Applied = $false; Requested = $aPoser; Divergences = $divergences.ToArray(); Unknown = $inconnus
        }
    }

    $splat = @{ Identity = $LibraryTitle; Connection = $DestinationConnection; ErrorAction = 'Stop' }
    foreach ($k in $aPoser.Keys) { $splat[$k] = $aPoser[$k] }

    try {
        Invoke-SPMWithRetry -Operation "Set-PnPList/settings/$LibraryTitle" -ScriptBlock {
            Set-PnPList @splat
        } | Out-Null
    }
    catch {
        $message = ($_.Exception.Message -split [char]10)[0]
        $divergences.Add("réglages non appliqués — $message")
        Write-SPMLog -Level WARNING -Operation 'Schema' -Message "$LibraryTitle : réglages non appliqués — $message"
        return [pscustomobject]@{
            Applied = $false; Requested = $aPoser; Divergences = $divergences.ToArray(); Unknown = $inconnus
        }
    }

    # Relecture : « Set-PnPList n'a pas levé d'erreur » ne prouve rien. Certains
    # réglages sont silencieusement refusés selon le modèle de la bibliothèque.
    $relu = Invoke-SPMWithRetry -Operation "list/verify/$LibraryTitle" -PassThruErrors -ScriptBlock {
        Get-PnPList -Identity $LibraryTitle -Connection $DestinationConnection `
            -Includes EnableVersioning, EnableMinorVersions, MajorVersionLimit,
        MajorWithMinorVersionsLimit, DraftVersionVisibility, ForceCheckout, EnableModeration `
            -ErrorAction Stop
    }

    if (-not $relu) {
        $divergences.Add('relecture impossible : réglages posés mais NON vérifiés')
    }
    else {
        $obtenu = Get-SPMListSetting -List $relu
        foreach ($k in ($aPoser.Keys | Sort-Object)) {
            $attendu = $aPoser[$k]
            $valeur = $obtenu[$k]
            if ($null -eq $valeur) { continue }   # non relisible : pas une divergence prouvée
            if ("$valeur" -ne "$attendu") {
                $divergences.Add("$k = $valeur à destination, $attendu à la source")
            }
        }
    }

    foreach ($d in $divergences) {
        Write-SPMLog -Level WARNING -Operation 'Schema' -Message "$LibraryTitle : $d"
    }

    return [pscustomobject]@{
        Applied = $true
        Requested = $aPoser
        Divergences = $divergences.ToArray()
        Unknown = $inconnus
    }
}

function Copy-SPMLibraryView {
    <#
    .SYNOPSIS
        Recopie les affichages personnalisés d'une bibliothèque.
 
    .DESCRIPTION
        `New-PnPList` ne crée que l'affichage par défaut. Les vues d'une
        bibliothèque — leurs colonnes, leur tri, leur filtre, leur seuil — sont
        pourtant ce par quoi les utilisateurs voient leur contenu : une
        migration fidèle au fichier près qui rend une bibliothèque
        méconnaissable à l'écran a raté quelque chose.
 
        Une vue refusée n'interrompt pas les suivantes, et l'affichage par
        défaut n'est jamais recréé — il existe déjà.
 
    .OUTPUTS
        Objet { Added, Skipped, Problems }.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$SourceConnection,
        [Parameter(Mandatory)]$DestinationConnection
    )

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

    $srcViews = @(Invoke-SPMWithRetry -Operation "views/src/$LibraryTitle" -PassThruErrors -ScriptBlock {
            Get-PnPView -List $LibraryTitle -Connection $SourceConnection -ErrorAction Stop
        })
    $dstViews = @(Invoke-SPMWithRetry -Operation "views/dst/$LibraryTitle" -PassThruErrors -ScriptBlock {
            Get-PnPView -List $LibraryTitle -Connection $DestinationConnection -ErrorAction Stop
        })

    $existants = @($dstViews | ForEach-Object { "$($_.Title)" })
    $lire = {
        param($vue, $nom)
        $p = $vue.PSObject.Properties[$nom]
        if (-not $p) { return $null }
        try { return $p.Value } catch { return $null }
    }

    $added = 0
    $candidates = @($srcViews | Where-Object {
            $titre = "$($_.Title)"
            $titre -and $titre -notin $existants -and -not (& $lire $_ 'Hidden')
        })

    foreach ($vue in $candidates) {
        $titre = "$($vue.Title)"
        if (-not $PSCmdlet.ShouldProcess("$LibraryTitle :: $titre", 'Créer l''affichage')) { continue }

        try {
            $champs = @(& $lire $vue 'ViewFields')
            $splat = @{
                List = $LibraryTitle
                Title = $titre
                Connection = $DestinationConnection
                ErrorAction = 'Stop'
            }
            if ($champs.Count) { $splat.Fields = $champs }

            $requete = & $lire $vue 'ViewQuery'
            if ($requete) { $splat.Query = "$requete" }

            $limite = & $lire $vue 'RowLimit'
            if ($limite) { $splat.RowLimit = [uint32]$limite }

            if (& $lire $vue 'DefaultView') { $splat.SetAsDefault = $true }

            Invoke-SPMWithRetry -Operation "Add-PnPView/$LibraryTitle/$titre" -MaxRetries 1 -ScriptBlock {
                Add-PnPView @splat
            } | Out-Null
            $added++
        }
        catch {
            $message = ($_.Exception.Message -split [char]10)[0]
            $problems.Add("affichage « $titre » non recréé — $message")
            Write-SPMLog -Level WARNING -Operation 'Schema' -Message (
                "$LibraryTitle : affichage « $titre » non recréé — $message"
            )
        }
    }

    return [pscustomobject]@{
        Added = $added
        Skipped = $candidates.Count - $added
        Problems = $problems.ToArray()
    }
}

function Get-SPMOutOfScopeList {
    <#
    .SYNOPSIS
        Listes du site que le moteur ne migre PAS.
 
    .DESCRIPTION
        Quatre étages du code filtrent `BaseTemplate -eq 101`. Listes
        personnalisées, calendriers, listes de tâches et les pièces jointes de
        leurs éléments ne sont donc ni migrés, ni inventoriés, **ni signalés**.
        Le silence est le vrai défaut : un exploitant qui lit « migration
        terminée » n'a aucune raison de soupçonner qu'un calendrier d'équipe est
        resté derrière.
 
    .OUTPUTS
        Objets { Title, Template, ItemCount }.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param([Parameter(Mandatory)]$Connection)

    $lists = @(Invoke-SPMWithRetry -Operation 'lists/scope' -PassThruErrors -ScriptBlock {
            Get-PnPList -Connection $Connection -ErrorAction Stop
        })

    @($lists |
            Where-Object { $_.BaseTemplate -ne 101 -and -not $_.Hidden -and $_.Title -notin $script:SPMSystemLibrary } |
            ForEach-Object {
                [pscustomobject]@{
                    Title = "$($_.Title)"
                    Template = "$($_.BaseTemplate)"
                    ItemCount = $_.ItemCount
                }
            })
}

function Copy-SPMLibraryField {
    <#
    .SYNOPSIS
        Recopie les colonnes personnalisées d'une bibliothèque, une par une.
 
    .DESCRIPTION
        Le schéma XML est repris tel quel : c'est le seul moyen de conserver le
        type exact, les choix, les valeurs par défaut et le rattachement d'une
        colonne de taxonomie.
 
        Une colonne refusée n'interrompt pas les suivantes. Perdre une
        bibliothèque entière parce qu'un champ de taxonomie exige un magasin de
        termes absent serait un mauvais échange.
 
    .OUTPUTS
        Objet { Added, Skipped, Problems }.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$SourceConnection,
        [Parameter(Mandatory)]$DestinationConnection
    )

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

    $srcFields = @(Invoke-SPMWithRetry -Operation "fields/src/$LibraryTitle" -PassThruErrors -ScriptBlock {
            Get-PnPField -List $LibraryTitle -Connection $SourceConnection -ErrorAction Stop
        })
    $dstFields = @(Invoke-SPMWithRetry -Operation "fields/dst/$LibraryTitle" -PassThruErrors -ScriptBlock {
            Get-PnPField -List $LibraryTitle -Connection $DestinationConnection -ErrorAction Stop
        })

    $existing = @($dstFields | ForEach-Object { $_.InternalName })

    # FromBaseType : la colonne vient du type de contenu de base, elle existe
    # déjà à destination. Seules les colonnes propres à la bibliothèque comptent.
    $custom = @($srcFields | Where-Object {
            -not $_.FromBaseType -and -not $_.Hidden -and -not $_.ReadOnlyField -and
            $_.InternalName -notin $existing
        })

    $added = 0
    foreach ($field in $custom) {
        $nom = $field.InternalName
        if (-not $PSCmdlet.ShouldProcess("$LibraryTitle :: $nom", 'Ajouter la colonne')) { continue }

        try {
            $xml = $field.SchemaXml
            Invoke-SPMWithRetry -Operation "Add-PnPFieldFromXml/$LibraryTitle/$nom" -MaxRetries 1 -ScriptBlock {
                Add-PnPFieldFromXml -List $LibraryTitle -FieldXml $xml `
                    -Connection $DestinationConnection -ErrorAction Stop
            } | Out-Null
            $added++
        }
        catch {
            $message = ($_.Exception.Message -split [char]10)[0]
            $problems.Add("colonne « $nom » non copiée — $message")
            Write-SPMLog -Level WARNING -Operation 'Schema' -Message (
                "$LibraryTitle : colonne « $nom » non copiée — $message"
            )
        }
    }

    return [pscustomobject]@{
        Added = $added
        Skipped = $custom.Count - $added
        Problems = $problems.ToArray()
    }
}