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() } } |