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