Private/Plan.ps1
|
<#
Ponts entre le plan déclaratif et le socle technique. #> function Get-SPMPlanConnection { <# .SYNOPSIS Ouvre (ou réutilise) la connexion décrite par la section auth du plan. .DESCRIPTION Le pool de Connect-SPMSite garantit qu'un même site n'est authentifié qu'une fois par exécution, quel que soit le nombre de tâches. #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)][pscustomobject]$Plan, [Parameter(Mandatory)][ValidateSet('Source', 'Destination')][string]$Which, # Éprouve la connexion en pool avant de la rendre, et la rouvre si elle # est morte. C'est la voie de réparation d'un jeton expiré en cours de # migration : le plan porte les paramètres d'authentification, la # connexion elle-même ne les porte pas. [switch]$Validate ) $url = if ($Which -eq 'Source') { $Plan.Source.Url } else { $Plan.Destination.Url } if (-not $url) { throw "Aucune URL $Which dans le plan (source de type '$($Plan.Source.Type)' ?)." } # Une URL de dossier suffit à identifier le site : on remonte au /sites/<nom> $siteUrl = Get-SPMSiteUrlFromResourceUrl -Url $url $splat = @{ Url = $siteUrl } switch ($Plan.Auth.Mode) { 'interactive' { $splat.Interactive = $true $splat.ClientId = $Plan.Auth.ClientId } 'devicelogin' { $splat.DeviceLogin = $true $splat.ClientId = $Plan.Auth.ClientId } 'certificate' { $splat.ClientId = $Plan.Auth.ClientId $splat.Tenant = $Plan.Auth.Tenant if ($Plan.Auth.Thumbprint) { $splat.Thumbprint = $Plan.Auth.Thumbprint } else { $splat.CertificatePath = $Plan.Auth.CertificatePath } } 'managedidentity' { $splat.ManagedIdentity = $true } default { throw "Mode d'authentification non pris en charge : $($Plan.Auth.Mode)" } } if ($Validate) { $splat.Validate = $true } Connect-SPMSite @splat } function Get-SPMSiteUrlFromResourceUrl { <# .SYNOPSIS Extrait l'URL du site à partir de l'URL d'une bibliothèque ou d'un dossier. .DESCRIPTION Gère les chemins gérés /sites/ et /teams/ ainsi que les sites racine. L'ancien code supposait systématiquement "/sites/<code>", ce qui cassait sur un site racine et sur /teams/. .EXAMPLE Get-SPMSiteUrlFromResourceUrl 'https://contoso.sharepoint.com/sites/RH/Documents/2026' # -> https://contoso.sharepoint.com/sites/RH #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$Url) $uri = [System.Uri]$Url $segments = @($uri.AbsolutePath.Split('/') | Where-Object { $_ }) foreach ($managedPath in 'sites', 'teams', 'personal') { $index = [array]::IndexOf($segments, $managedPath) if ($index -ge 0 -and $segments.Count -gt ($index + 1)) { return "$($uri.Scheme)://$($uri.Authority)/$managedPath/$($segments[$index + 1])" } } # Site racine du tenant return "$($uri.Scheme)://$($uri.Authority)" } function Get-SPMServerRelativePath { <# .SYNOPSIS Retourne le chemin serveur-relatif DÉCODÉ d'une URL absolue ou relative. .DESCRIPTION Le décodage n'est pas un détail cosmétique. `[System.Uri]::AbsolutePath` renvoie un chemin percent-encodé : https://contoso.sharepoint.com/sites/RH/Documents partages -> /sites/RH/Documents%20partages alors que PnP.PowerShell travaille en clair : `Get-PnPWeb().ServerRelativeUrl` et `item.ServerRelativeUrl` sont décodés. Passer la forme encodée à `Get-PnPFolderItem` ne lève AUCUNE erreur — la cmdlet retourne simplement zéro élément. Un pré-vol conclut alors « 0 erreur » sur une énumération vide, et la migration ne trouve rien à copier. Sur un tenant francophone — « Documents partages », « Frais de déplacement » — cela concerne la quasi-totalité des chemins. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$Url) if ($Url.StartsWith('/')) { return $Url.TrimEnd('/') } return [System.Uri]::UnescapeDataString(([System.Uri]$Url).AbsolutePath).TrimEnd('/') } function Get-SPMListTitleForPath { <# .SYNOPSIS Titre de la bibliothèque qui contient un chemin serveur-relatif. .DESCRIPTION L'écriture de métadonnée passe par `Set-PnPListItem -List <titre>`, alors que les unités de travail ne portent que des chemins. La correspondance se fait par le dossier racine de chaque liste, le plus long préfixe gagnant — sans quoi une bibliothèque « Documents » capterait les chemins de « Documents partages ». #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string]$ServerRelativeUrl, [Parameter(Mandatory)]$Connection ) # ------------------------------------------------------------------------ # Voie directe : ListPipeBind accepte le titre, l'identifiant OU l'URL # relative au site. Pour « /sites/B/Admin », le premier segment — « Admin » # — suffit à résoudre la liste, sans énumérer tout le site ni dépendre de # ce que -Includes daigne charger. # ------------------------------------------------------------------------ $siteRelative = Get-SPMSiteRelativePath -ServerRelativeUrl $ServerRelativeUrl -Connection $Connection if ($siteRelative) { $segments = @($siteRelative.Split('/') | Where-Object { $_ }) # Une liste vit sous « Lists/<nom> » ; une bibliothèque à la racine du site. $candidats = @( if ($segments.Count -ge 2 -and $segments[0] -eq 'Lists') { "Lists/$($segments[1])" } if ($segments.Count -ge 1) { $segments[0] } ) foreach ($candidat in $candidats) { $direct = Invoke-SPMWithRetry -Operation 'list/byUrl' -PassThruErrors -ScriptBlock { Get-PnPList -Identity $candidat -Connection $Connection -ErrorAction Stop } if ($direct) { return $direct.Title } } } # Voie de repli : énumérer et comparer les racines. $lists = @(Invoke-SPMWithRetry -Operation 'lists/resolve' -PassThruErrors -ScriptBlock { Get-PnPList -Includes RootFolder -Connection $Connection -ErrorAction Stop }) $best = $null $bestLength = -1 foreach ($list in $lists) { $root = Get-SPMListRootUrl -List $list if (-not $root) { continue } if ($ServerRelativeUrl.StartsWith("$root/", [StringComparison]::OrdinalIgnoreCase) -or $ServerRelativeUrl.Equals($root, [StringComparison]::OrdinalIgnoreCase)) { if ($root.Length -gt $bestLength) { $bestLength = $root.Length; $best = $list.Title } } } return $best } function Get-SPMListRootUrl { <# .SYNOPSIS URL serveur-relative du dossier racine d'une liste, par deux voies. .DESCRIPTION `Get-PnPList` sans `-Identity` ne garantit PAS que `RootFolder` soit chargé, même demandé par `-Includes` : l'objet revient alors avec un RootFolder nul. Un code qui s'y fie conclut « bibliothèque introuvable » sur une bibliothèque parfaitement présente — et, en aval, la métadonnée n'est jamais appliquée, sans le moindre message. `DefaultViewUrl` est une propriété scalaire, toujours renseignée. Elle vaut « /sites/B/Admin/Forms/AllItems.aspx » : la racine s'en déduit en retirant le segment « /Forms/... ». C'est la voie de repli. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$List) # Voie 1 : le dossier racine, s'il a bien été chargé. $rootProp = $List.PSObject.Properties['RootFolder'] if ($rootProp -and $List.RootFolder) { $urlProp = $List.RootFolder.PSObject.Properties['ServerRelativeUrl'] if ($urlProp -and $List.RootFolder.ServerRelativeUrl) { return "$($List.RootFolder.ServerRelativeUrl)".TrimEnd('/') } } # Voie 2 : déduction depuis l'URL de la vue par défaut. $viewProp = $List.PSObject.Properties['DefaultViewUrl'] if ($viewProp -and $List.DefaultViewUrl) { $view = "$($List.DefaultViewUrl)" $index = $view.LastIndexOf('/Forms/', [StringComparison]::OrdinalIgnoreCase) if ($index -gt 0) { return $view.Substring(0, $index) } # Liste sans dossier Forms : la vue vit directement sous la racine. $index = $view.LastIndexOf('/') if ($index -gt 0) { return $view.Substring(0, $index) } } return $null } function Expand-SPMWorkUnit { <# .SYNOPSIS Éclate une unité récursive en une unité par élément. .DESCRIPTION Sans cet éclatement, tout un site tient dans UNE unité de travail : une seule clé d'état, un seul Completed ou Failed. `-Resume` ne peut alors rien reprendre partiellement, le mode incrémental ne compare rien, et l'avancement est invisible jusqu'à la fin. Le prix est réel — un travail de copie par élément au lieu d'un seul — et c'est pourquoi le choix reste explicite (`options.granularity`). ORDRE : les dossiers précèdent leur contenu, tel que l'énumération les rend. La métadonnée des dossiers, elle, doit être appliquée APRÈS leur contenu ; l'appelant parcourt donc les unités à l'envers pour cette phase. .OUTPUTS Unités { Kind, Source, SourceUrl, LocalPath, Target, Signature, Relative }. Pour un FICHIER, Target est le DOSSIER de destination (règle 1 de Transfer.ps1). #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][pscustomobject]$Unit, [Parameter(Mandatory)]$Connection, [int]$MaxDepth = 0 ) if (-not $Unit.SourceUrl) { return , @($Unit) } $srcBase = "$($Unit.SourceUrl)".TrimEnd('/') $dstBase = "$($Unit.Target)".TrimEnd('/') $items = @(Get-SPMFolderInventory -FolderUrl $srcBase -Connection $Connection -MaxDepth $MaxDepth) $units = foreach ($item in $items) { $absolu = "$($item.ServerRelativeUrl)".TrimEnd('/') if (-not $absolu.StartsWith($srcBase, [StringComparison]::OrdinalIgnoreCase)) { continue } $relatif = $absolu.Substring($srcBase.Length).Trim('/') if (-not $relatif) { continue } if ($item.IsFolder) { [pscustomobject]@{ Kind = 'Folder' Source = $absolu SourceUrl = $absolu LocalPath = $null Target = "$dstBase/$relatif" Signature = $null Relative = $relatif } } else { # Le parent, jamais le fichier : -TargetUrl désigne un dossier. $parent = if ($relatif.Contains('/')) { $relatif.Substring(0, $relatif.LastIndexOf('/')) } else { '' } [pscustomobject]@{ Kind = 'File' Source = $absolu SourceUrl = $absolu LocalPath = $null Target = if ($parent) { "$dstBase/$parent" } else { $dstBase } Signature = Get-SPMTransferSignature -SizeBytes $item.SizeBytes -LastModified $item.LastModified Relative = $relatif } } } return @($units) } function Build-SPMWorkUnit { <# .SYNOPSIS Transforme un plan en unités de travail concrètes. .DESCRIPTION Sans section 'tasks', le plan décrit une migration racine-à-racine et produit une seule unité récursive — c'est le cas nominal, et le plus efficace : un seul travail de copie côté service pour toute l'arborescence. #> [CmdletBinding()] [OutputType([pscustomobject])] param([Parameter(Mandatory)][pscustomobject]$Plan) $destPath = Get-SPMServerRelativePath -Url $Plan.Destination.Url if ($Plan.Tasks.Count -eq 0) { if ($Plan.Source.Type -eq 'fileshare') { $srcPath = (Resolve-Path -LiteralPath $Plan.Source.Path -ErrorAction SilentlyContinue)?.Path if (-not $srcPath) { $srcPath = $Plan.Source.Path } $size = 0; $count = 0; $latest = [datetime]::MinValue if (Test-Path -LiteralPath $srcPath) { $measured = Get-ChildItem -LiteralPath $srcPath -Recurse -File -Force -ErrorAction SilentlyContinue $count = @($measured).Count $size = ($measured | Measure-Object Length -Sum).Sum if ($measured) { $latest = ($measured | Measure-Object LastWriteTime -Maximum).Maximum } } return , [pscustomobject]@{ Kind = 'Folder' Source = $srcPath SourceUrl = $null LocalPath = $srcPath Target = $destPath Signature = Get-SPMTransferSignature -SizeBytes ([long]$size) -LastModified $latest -ItemCount $count } } $srcPath = Get-SPMServerRelativePath -Url $Plan.Source.Url return , [pscustomobject]@{ Kind = 'Folder' Source = $srcPath SourceUrl = $srcPath LocalPath = $null Target = $destPath Signature = $null # signature calculée par tâche uniquement } } foreach ($task in $Plan.Tasks) { $kind = if ($task.kind) { "$($task.kind)" } else { 'Folder' } $target = if ($task.target) { Get-SPMServerRelativePath -Url $task.target } else { $destPath } if ($Plan.Source.Type -eq 'fileshare') { $local = if ($task.path) { $task.path } else { $Plan.Source.Path } [pscustomobject]@{ Kind = $kind Source = $local SourceUrl = $null LocalPath = $local Target = $target Signature = $null } } else { $source = Get-SPMServerRelativePath -Url ($task.source ?? $Plan.Source.Url) [pscustomobject]@{ Kind = $kind Source = $source SourceUrl = $source LocalPath = $null Target = $target Signature = $null } } } } |