Private/Transfer.ps1
|
<#
Moteur de transfert. Remplace le cycle « download vers disque temporaire puis upload » par les travaux de copie serveur-à-serveur de SharePoint Online, exposés par Copy-PnPFile / Copy-PnPFolder. Ce que ces cmdlets apportent, et que l'ancien moteur ne pouvait pas offrir : * la copie s'exécute côté service : le contenu ne transite pas par le poste * l'historique des versions est conservé (d'où le switch -IgnoreVersionHistory qui sert à l'ignorer volontairement, preuve qu'il est préservé par défaut) * les dates et auteurs d'origine sont conservés par le service * -NoWait rend le travail asynchrone : on lance puis on suit Précision d'API : il s'agit de l'API de travaux de copie/déplacement serveur-side (CreateCopyJobs), celle qui sous-tend « Déplacer vers » / « Copier vers » dans l'interface. Elle est distincte de l'API de migration à conteneurs Azure utilisée par SPMT, qui n'est pas exposée par les cmdlets PnP. Pour un usage PowerShell, CreateCopyJobs est le meilleur mécanisme disponible et il est très largement supérieur au download/upload. ------------------------------------------------------------------------ LES QUATRE RÈGLES D'APPEL, ÉCRITES ICI ET NULLE PART AILLEURS ------------------------------------------------------------------------ 1. -TargetUrl désigne le DOSSIER de destination, jamais le fichier. L'aide de la cmdlet est explicite : « Site or server relative URL where to copy the file or folder to. Must not include the file name. » Lui passer le chemin du fichier cible produit une SPMigrationQosException — « impossible de vérifier l'existence de l'emplacement de destination » — qu'il est facile de prendre pour un refus d'API. Ce n'en est pas un. 2. Le dossier de destination doit EXISTER avant l'appel. Copy-PnPFolder échoue sinon, avec la même exception. 3. -Force ne veut pas dire « écraser ». L'écrasement, c'est -Overwrite. Copy-PnPFile et Copy-PnPFolder ne déclarent pas SupportsShouldProcess : leur demande de confirmation passe par ShouldContinue, que -Confirm:$false ne neutralise pas. Sans -Force, une migration de N fichiers exige N validations au clavier, et échoue au premier élément en hôte non interactif. 4. Le travail se soumet depuis la connexion SOURCE. CreateCopyJobs s'invoque sur le site qui détient le contenu à exporter. 5. Copy-PnPFolder a DEUX jeux de paramètres, et ils ne se mélangent pas. -Recurse, -RemoveAfterCopy n'existent que pour -LocalPath ; -IgnoreVersionHistory, -AllowSchemaMismatch, -NoWait n'existent que pour -SourceUrl. Mélanger les deux donne « Parameter set cannot be resolved ». Voir le détail au point de construction du splat. #> function Get-SPMTransferSignature { <# .SYNOPSIS Calcule la signature d'une source pour le mode incrémental. .DESCRIPTION « Le fichier existe à destination » ne prouve pas qu'il est à jour. La signature combine taille et date de modification : si l'une change, l'unité est re-migrée. #> [CmdletBinding()] [OutputType([string])] param( [long]$SizeBytes = -1, [datetime]$LastModified = [datetime]::MinValue, [int]$ItemCount = -1 ) $parts = @( if ($SizeBytes -ge 0) { "s=$SizeBytes" } else { 's=?' } if ($LastModified -gt [datetime]::MinValue) { "m=$($LastModified.ToUniversalTime().ToString('yyyyMMddHHmmss'))" } else { 'm=?' } if ($ItemCount -ge 0) { "n=$ItemCount" } else { 'n=?' } ) $parts -join '|' } $script:SPMWebRoots = [System.Collections.Generic.Dictionary[string, string]]::new() # Dossiers dont l'énumération a échoué pendant le dernier inventaire. Un # sous-arbre illisible ne doit jamais passer pour un sous-arbre vide. $script:SPMEnumerationFailures = [System.Collections.Generic.List[object]]::new() # --------------------------------------------------------------------------- # Dossiers que SharePoint gère lui-même. Ils ne contiennent pas de contenu # utilisateur et NE DOIVENT PAS être migrés : # # Forms pages de formulaire de la bibliothèque (AllItems.aspx, # DispForm.aspx…). Elles sont recréées avec la bibliothèque à # destination, et le service REFUSE de les copier — « Accès # refusé », qu'on prend alors pour un bridage de l'API. # _vti_history versions archivées, accessibles par Get-PnPFileVersion # _catalogs, # _private, # _vti_pvt plomberie de site # # Sans ce filtre, une bibliothèque type apporte huit fichiers parasites, fausse # tous les compteurs, et fait échouer la première copie sur une page système. # --------------------------------------------------------------------------- $script:SPMSystemFolder = @('Forms', '_catalogs', '_private', '_vti_pvt', '_vti_history', '_vti_cnf') function Clear-SPMEnumerationFailure { [CmdletBinding()] param() $script:SPMEnumerationFailures.Clear() } function Get-SPMEnumerationFailure { <# .SYNOPSIS Retourne les dossiers illisibles rencontrés depuis le dernier Clear. #> [CmdletBinding()] [OutputType([object[]])] param() return @($script:SPMEnumerationFailures) } function Get-SPMSiteRelativePath { <# .SYNOPSIS Convertit un chemin serveur-relatif en chemin SITE-relatif. .DESCRIPTION `Resolve-PnPFolder`, `Get-PnPFolderItem` et `Add-PnPFile` attendent tous du site-relatif (« Documents/2026 »), alors que les unités de travail portent du serveur-relatif (« /sites/X/Documents/2026 »). Aucune de ces cmdlets ne lève d'erreur sur la mauvaise forme : elles retournent simplement zéro élément, ou créent au mauvais endroit. La racine du web est mise en cache : une migration touche des milliers d'éléments dans le même site. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string]$ServerRelativeUrl, [Parameter(Mandatory)]$Connection ) # StrictMode fait échouer l'accès à une propriété absente : on ne suppose # pas que l'objet de connexion expose .Url. $key = if ($Connection.PSObject.Properties['Url']) { "$($Connection.Url)" } else { "$Connection" } if (-not $script:SPMWebRoots.ContainsKey($key)) { $web = Invoke-SPMWithRetry -Operation 'web/root' -ScriptBlock { Get-PnPWeb -Connection $Connection -ErrorAction Stop } $script:SPMWebRoots[$key] = $web.ServerRelativeUrl.TrimEnd('/') } $webRoot = $script:SPMWebRoots[$key] $siteRelative = $ServerRelativeUrl if ($webRoot -and $siteRelative.StartsWith($webRoot, [StringComparison]::OrdinalIgnoreCase)) { $siteRelative = $siteRelative.Substring($webRoot.Length) } return $siteRelative.Trim('/') } function Resolve-SPMTargetFolder { <# .SYNOPSIS Crée le dossier de destination s'il n'existe pas (règle 2). .DESCRIPTION Idempotent : `Resolve-PnPFolder` sur un dossier existant le retourne sans rien modifier. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$ServerRelativeUrl, [Parameter(Mandatory)]$Connection ) $siteRelative = Get-SPMSiteRelativePath -ServerRelativeUrl $ServerRelativeUrl -Connection $Connection # Chemin vide : la cible est la racine du site, rien à créer. if (-not $siteRelative) { return } Invoke-SPMWithRetry -Operation 'Resolve-PnPFolder' -ScriptBlock { Resolve-PnPFolder -SiteRelativePath $siteRelative -Connection $Connection -ErrorAction Stop } | Out-Null } function Invoke-SPMCopyJob { <# .SYNOPSIS Lance un travail de copie serveur-à-serveur pour un dossier ou un fichier. .PARAMETER SourceUrl URL serveur-relative de la source SharePoint (ignorée si -LocalPath est fourni). .PARAMETER LocalPath Chemin local (partage de fichiers) à téléverser. Uniquement pour les dossiers. .PARAMETER TargetUrl URL serveur-relative du DOSSIER de destination — jamais du fichier. Voir la règle 1 en tête de fichier. .PARAMETER Connection Connexion qui SOUMET le travail. Pour une source SharePoint, c'est la connexion source (règle 4). .PARAMETER DestinationConnection Connexion utilisée pour créer le dossier de destination avant la copie (règle 2). Sans elle, la préparation est ignorée et l'appelant doit garantir que la cible existe. .PARAMETER PreserveVersions Conserve l'historique des versions (défaut : $true). .PARAMETER NoWait Retourne dès la soumission du travail au lieu d'attendre sa fin. .OUTPUTS Objet décrivant l'issue : Success, JobId, Duration, Error. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [string]$SourceUrl, [string]$LocalPath, [Parameter(Mandatory)][string]$TargetUrl, [ValidateSet('Folder', 'File')][string]$Kind = 'Folder', [bool]$PreserveVersions = $true, [switch]$Overwrite, [switch]$Recurse, [switch]$AllowSchemaMismatch, [switch]$NoWait, $Connection, $DestinationConnection ) if (-not $SourceUrl -and -not $LocalPath) { throw 'Invoke-SPMCopyJob : fournissez -SourceUrl ou -LocalPath.' } if ($LocalPath -and $Kind -eq 'File') { throw 'Invoke-SPMCopyJob : -LocalPath ne s''applique qu''aux dossiers (Copy-PnPFolder).' } $describe = if ($LocalPath) { "$LocalPath -> $TargetUrl" } else { "$SourceUrl -> $TargetUrl" } if (-not $PSCmdlet.ShouldProcess($describe, "Copier ($Kind)")) { return [pscustomobject]@{ Success = $true Simulated = $true JobId = $null Duration = [timespan]::Zero Source = $SourceUrl ? $SourceUrl : $LocalPath Target = $TargetUrl Error = $null } } # Règle 2 : la cible doit exister avant l'appel. if ($DestinationConnection) { try { Resolve-SPMTargetFolder -ServerRelativeUrl $TargetUrl -Connection $DestinationConnection } catch { Write-SPMLog -Level WARNING -Operation 'Transfer' -Message ( "Préparation du dossier de destination impossible : $TargetUrl — $($_.Exception.Message)" ) } } $splat = @{ TargetUrl = $TargetUrl # Règle 3 : -Force supprime la confirmation par élément, il n'écrase rien. Force = $true ErrorAction = 'Stop' } if ($Overwrite) { $splat.Overwrite = $true } if ($Connection) { $splat.Connection = $Connection } # ------------------------------------------------------------------------ # RÈGLE 5 : les paramètres ne sont PAS interchangeables entre les deux jeux # de Copy-PnPFolder. Vérifié sur PnP.PowerShell 3.1.0 : # # [SourceUrl] SourceUrl, TargetUrl, Overwrite, Force, # IgnoreVersionHistory, AllowSchemaMismatch, NoWait, Connection # [LocalPath] LocalPath, TargetUrl, Overwrite, Force, # Recurse, RemoveAfterCopy, Connection # # -Recurse n'existe QUE pour un téléversement depuis le disque. Le poser sur # une copie SharePoint vers SharePoint rend le jeu de paramètres insoluble : # « Parameter set cannot be resolved ». Comme options.recurse vaut vrai par # défaut, cela faisait échouer TOUTE migration de dossier entre deux sites. # # Ce n'est pas une perte : une copie serveur-à-serveur emporte de toute façon # l'arborescence complète — la récursion est le fait du service, pas une # option du client. # ------------------------------------------------------------------------ if ($LocalPath) { $splat.LocalPath = $LocalPath if ($Recurse) { $splat.Recurse = $true } } else { $splat.SourceUrl = $SourceUrl if ($AllowSchemaMismatch) { $splat.AllowSchemaMismatch = $true } if ($NoWait) { $splat.NoWait = $true } # Le switch est inversé : on le pose seulement pour NE PAS garder les versions. if (-not $PreserveVersions) { $splat.IgnoreVersionHistory = $true } } $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $result = Invoke-SPMWithRetry -Operation "Copy-Pnp$Kind" -ScriptBlock { if ($Kind -eq 'Folder') { Copy-PnPFolder @splat } else { Copy-PnPFile @splat } } $sw.Stop() $jobId = $null if ($result) { $idProp = $result.PSObject.Properties['JobId'] if ($idProp) { $jobId = [string]$idProp.Value } elseif ($result -is [string]) { $jobId = $result } } Write-SPMLog -Level SUCCESS -Operation 'Transfer' -Message "Copie soumise : $describe" -Data @{ kind = $Kind durationMs = $sw.ElapsedMilliseconds jobId = $jobId preserveVersions = $PreserveVersions async = [bool]$NoWait } return [pscustomobject]@{ Success = $true Simulated = $false JobId = $jobId Duration = $sw.Elapsed Source = $SourceUrl ? $SourceUrl : $LocalPath Target = $TargetUrl Error = $null } } catch { $sw.Stop() Write-SPMLog -Level ERROR -Operation 'Transfer' -Message "Échec de la copie : $describe - $($_.Exception.Message)" -Data @{ kind = $Kind durationMs = $sw.ElapsedMilliseconds } return [pscustomobject]@{ Success = $false Simulated = $false JobId = $null Duration = $sw.Elapsed Source = $SourceUrl ? $SourceUrl : $LocalPath Target = $TargetUrl Error = $_.Exception.Message } } } function Invoke-SPMLocalTransfer { <# .SYNOPSIS Repli : télécharge le fichier puis le téléverse. NIVEAU 2. .DESCRIPTION À n'employer que si le travail de copie serveur est indisponible. Ce que ce repli coûte, dit sans détour : * l'HISTORIQUE DES VERSIONS n'est pas transféré — seule la version courante arrive. Ce n'est pas un réglage, c'est une propriété du mécanisme : un téléversement crée une version 1.0, point ; * le contenu transite par le poste d'exécution, ce qui plafonne le débit et consomme de la bande passante ; * dates et auteurs doivent être réappliqués explicitement après coup (voir Set-SPMItemMetadata). Il est donc réservé aux tenants où l'API de travaux de copie est refusée, et le mécanisme employé est rapporté PAR UNITÉ — jamais supposé. .OUTPUTS Objet { Success, Error, Mechanism }. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$SourceUrl, [Parameter(Mandatory)][string]$TargetFolderServerRelative, [Parameter(Mandatory)]$SourceConnection, [Parameter(Mandatory)]$DestinationConnection ) $name = Split-Path -Leaf $SourceUrl if (-not $PSCmdlet.ShouldProcess("$SourceUrl -> $TargetFolderServerRelative", 'Copier (repli local)')) { return [pscustomobject]@{ Success = $true; Error = $null; Mechanism = 'local (simulé)' } } $temp = Join-Path ([System.IO.Path]::GetTempPath()) "spm-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $temp -Force | Out-Null try { Invoke-SPMWithRetry -Operation 'Get-PnPFile/fallback' -ScriptBlock { Get-PnPFile -Url $SourceUrl -Path $temp -Filename $name -AsFile -Force ` -Connection $SourceConnection -ErrorAction Stop } | Out-Null # Add-PnPFile attend un dossier SITE-relatif, comme Resolve-PnPFolder. $siteRelative = Get-SPMSiteRelativePath -ServerRelativeUrl $TargetFolderServerRelative ` -Connection $DestinationConnection $localPath = Join-Path $temp $name Invoke-SPMWithRetry -Operation 'Add-PnPFile/fallback' -ScriptBlock { Add-PnPFile -Path $localPath -Folder $siteRelative ` -Connection $DestinationConnection -ErrorAction Stop } | Out-Null return [pscustomobject]@{ Success = $true; Error = $null; Mechanism = 'local' } } catch { return [pscustomobject]@{ Success = $false Error = ($_.Exception.Message -split [char]10)[0] Mechanism = 'local' } } finally { Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue } } function Get-SPMFolderInventory { <# .SYNOPSIS Énumère récursivement un dossier SharePoint pour l'inventaire et le pré-vol. .DESCRIPTION Utilise Get-PnPFolderItem, qui pagine côté serveur, plutôt que Get-PnPListItem suivi d'un filtrage client. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string]$FolderUrl, $Connection, [int]$MaxDepth = 0, [int]$CurrentDepth = 0 ) # -FolderSiteRelativeUrl attend un chemin relatif AU SITE (« Documents/2026 »), # pas au serveur (« /sites/X/Documents/2026 »). Passer la seconde forme ne # leve aucune erreur : la cmdlet retourne simplement zero element, ce qui # se lit comme « dossier vide » et fait echouer la migration en silence. $siteRelative = $FolderUrl if ($siteRelative.StartsWith('/')) { $webSplat = @{ ErrorAction = 'Stop' } if ($Connection) { $webSplat.Connection = $Connection } $webRoot = (Invoke-SPMWithRetry -Operation 'Get-PnPWeb/inventory' -ScriptBlock { Get-PnPWeb @webSplat }).ServerRelativeUrl.TrimEnd('/') if ($webRoot -and $siteRelative.StartsWith($webRoot, [StringComparison]::OrdinalIgnoreCase)) { $siteRelative = $siteRelative.Substring($webRoot.Length) } $siteRelative = $siteRelative.TrimStart('/') } $splat = @{ FolderSiteRelativeUrl = $siteRelative; ErrorAction = 'Stop' } if ($Connection) { $splat.Connection = $Connection } # Un dossier VIDE et un dossier ILLISIBLE se ressemblent : les deux donnent # une énumération sans élément. Les confondre faisait disparaître des # sous-arbres entiers du pré-vol, qui concluait « 0 erreur » sur un # inventaire tronqué. On les distingue donc explicitement. $items = $null try { $items = Invoke-SPMWithRetry -Operation 'Get-PnPFolderItem' -ScriptBlock { Get-PnPFolderItem @splat } } catch { $message = ($_.Exception.Message -split [char]10)[0].Trim() $script:SPMEnumerationFailures.Add([pscustomobject]@{ FolderUrl = $FolderUrl Depth = $CurrentDepth Error = $message }) Write-SPMLog -Level ERROR -Operation 'Inventory' -Message ( "Dossier illisible, sous-arbre entier ignoré : $FolderUrl — $message" ) return } if (-not $items) { return } foreach ($item in $items) { $isFolder = $item.PSObject.Properties['Folders'] -or ($item.PSObject.Properties['TypedObject'] -and "$($item.TypedObject)" -match 'Folder') $name = $item.Name # Dossier système : ni rendu, ni parcouru. Voir $script:SPMSystemFolder. if ($isFolder -and $name -in $script:SPMSystemFolder) { Write-SPMLog -Level DEBUG -Operation 'Inventory' -Message "Dossier système ignoré : $($item.ServerRelativeUrl)" continue } $size = 0 $modified = [datetime]::MinValue $lengthProp = $item.PSObject.Properties['Length'] if ($lengthProp -and $null -ne $lengthProp.Value) { $size = [long]$lengthProp.Value } $modProp = $item.PSObject.Properties['TimeLastModified'] if ($modProp -and $null -ne $modProp.Value) { $modified = [datetime]$modProp.Value } [pscustomobject]@{ Name = $name ServerRelativeUrl = $item.ServerRelativeUrl IsFolder = [bool]$isFolder SizeBytes = $size LastModified = $modified Depth = $CurrentDepth # Un fichier extrait ne transfère que sa dernière version archivée : # le travail en cours reste chez la personne qui l'a extrait. Le # pré-vol doit pouvoir le dire avant, pas le découvrir après. IsCheckedOut = [bool]($item.PSObject.Properties['CheckOutType'] -and "$($item.CheckOutType)" -ne 'None') } if ($isFolder -and ($MaxDepth -le 0 -or $CurrentDepth -lt $MaxDepth)) { Get-SPMFolderInventory -FolderUrl $item.ServerRelativeUrl -Connection $Connection ` -MaxDepth $MaxDepth -CurrentDepth ($CurrentDepth + 1) } } } |