Private/Core.ps1
|
<#
.SYNOPSIS Socle technique commun pour les scripts de migration SharePoint. .DESCRIPTION Regroupe les briques transverses qui manquaient aux scripts historiques : * Invoke-SPMWithRetry - résilience au throttling SharePoint Online (HTTP 429 / 503 / 504, en-tête Retry-After, backoff exponentiel + jitter) * Connect-SPMSite - pool de connexions PnP réutilisables (-ReturnConnection / -Connection) : plus aucune déconnexion/reconnexion en cours de traitement * Get-SPMListItemByPath - recherche d'élément par requête CAML ciblée au lieu d'un téléchargement complet de la liste * Write-SPMLog - journalisation structurée NDJSON + console Ce module est le socle de transition : les scripts existants l'importent, et il sert de base au futur module SPMigrator. .NOTES Requiert PnP.PowerShell 3.x (lui-même dépendant de PowerShell 7.4.6+). Le User-Agent est déjà décoré par PnP.PowerShell (NONISV|SharePointPnP|PnPPS/x.y.z), aucune action n'est nécessaire de ce côté. #> Set-StrictMode -Version Latest # ============================================================================ # Configuration du module # ============================================================================ $script:SPMConfig = @{ MaxRetries = 6 BaseDelayMs = 1000 MaxDelayMs = 120000 # Plafond du délai DICTÉ PAR LE SERVEUR, distinct de MaxDelayMs qui borne le # backoff que nous calculons. Rejouer avant l'heure autorisée par un # Retry-After aggrave le throttling au lieu de l'absorber : le service # rallonge alors ses propres délais. Cette borne-ci n'est qu'un garde-fou # contre un en-tête aberrant, pas un arbitrage. MaxRetryAfterMs = 900000 JitterRatio = 0.25 LogPath = $null JsonLogPath = $null ConsoleOutput = $true MinLevel = 'INFO' } $script:SPMLevels = @{ DEBUG = 0; INFO = 1; SUCCESS = 1; WARNING = 2; ERROR = 3 } # Pool de connexions : clé = URL normalisée, valeur = objet PnPConnection $script:SPMConnections = [System.Collections.Generic.Dictionary[string, object]]::new() # Compteurs d'observabilité (exposés par Get-SPMStatistics) $script:SPMStats = [ordered]@{ Calls = 0 Retries = 0 ThrottleEvents = 0 ThrottleWaitMs = 0 Failures = 0 } # ============================================================================ # Configuration # ============================================================================ function Set-SPMConfiguration { <# .SYNOPSIS Configure le comportement global du socle (retries, journalisation). #> [CmdletBinding()] param( [ValidateRange(0, 20)] [int]$MaxRetries, [ValidateRange(100, 60000)][int]$BaseDelayMs, [ValidateRange(1000, 600000)][int]$MaxDelayMs, [ValidateRange(1000, 3600000)][int]$MaxRetryAfterMs, [ValidateRange(0.0, 1.0)] [double]$JitterRatio, [string]$LogPath, [string]$JsonLogPath, [ValidateSet('DEBUG', 'INFO', 'WARNING', 'ERROR')][string]$MinLevel, [bool]$ConsoleOutput ) foreach ($key in $PSBoundParameters.Keys) { if ($script:SPMConfig.Contains($key)) { $script:SPMConfig[$key] = $PSBoundParameters[$key] } } foreach ($p in 'LogPath', 'JsonLogPath') { $value = $script:SPMConfig[$p] if ($value) { $dir = Split-Path -Parent $value if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } } } function Get-SPMConfiguration { [CmdletBinding()] param() [pscustomobject]$script:SPMConfig } function Get-SPMStatistics { <# .SYNOPSIS Retourne les compteurs d'appels, de retries et de throttling observés. #> [CmdletBinding()] param([switch]$Reset) $snapshot = [pscustomobject]$script:SPMStats if ($Reset) { foreach ($k in @($script:SPMStats.Keys)) { $script:SPMStats[$k] = 0 } } $snapshot } # ============================================================================ # Journalisation structurée # ============================================================================ function Write-SPMLog { <# .SYNOPSIS Journalise un événement en texte lisible et, si configuré, en NDJSON. .DESCRIPTION Le NDJSON est la source exploitable par les outils (une ligne = un objet JSON). Il remplace l'analyse de logs par correspondance de chaînes. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Message, [ValidateSet('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')][string]$Level = 'INFO', [string]$Operation, [hashtable]$Data ) if ($script:SPMLevels[$Level] -lt $script:SPMLevels[$script:SPMConfig.MinLevel]) { return } $now = Get-Date $stamp = $now.ToString('yyyy-MM-dd HH:mm:ss') $line = if ($Operation) { "[$stamp] [$Level] [$Operation] $Message" } else { "[$stamp] [$Level] $Message" } if ($script:SPMConfig.ConsoleOutput) { $color = switch ($Level) { 'SUCCESS' { 'Green' } 'WARNING' { 'Yellow' } 'ERROR' { 'Red' } 'DEBUG' { 'DarkGray' } default { 'Gray' } } Write-Host $line -ForegroundColor $color } if ($script:SPMConfig.LogPath) { Add-Content -LiteralPath $script:SPMConfig.LogPath -Value $line -Encoding utf8 } if ($script:SPMConfig.JsonLogPath) { $record = [ordered]@{ ts = $now.ToString('o') level = $Level operation = $Operation message = $Message } if ($Data) { foreach ($k in $Data.Keys) { $record[$k] = $Data[$k] } } Add-Content -LiteralPath $script:SPMConfig.JsonLogPath ` -Value ($record | ConvertTo-Json -Compress -Depth 6) -Encoding utf8 } } # ============================================================================ # Résilience : détection du throttling et des erreurs transitoires # ============================================================================ function Test-SPMAuthExpired { <# .SYNOPSIS Reconnaît un échec dû à un jeton expiré plutôt qu'à un refus de droit. .DESCRIPTION Une migration dure des heures ; un jeton interactif, non. Les deux se présentent en 401, mais l'un se répare en rouvrant la connexion et l'autre non. Ne pas les distinguer faisait échouer définitivement toutes les unités restantes d'une exécution de nuit — pour une raison qui se corrigeait en une seconde. La reconnaissance porte sur le message, parce que c'est tout ce dont dispose l'appelant en aval : un travail de copie rend une chaîne, pas une ErrorRecord. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][string]$Message, [AllowNull()]$StatusCode) if ("$StatusCode" -eq '401') { return $true } if ([string]::IsNullOrWhiteSpace($Message)) { return $false } $lower = $Message.ToLowerInvariant() # « access denied » seul ne suffit pas : c'est aussi le refus de droit # légitime, que rouvrir la connexion ne changerait pas. $indices = @( 'token has expired', 'token is expired', 'expired token', 'lifetime validation failed', 'the token expiration', 'aadsts50173', 'aadsts700082', 'aadsts50076', 'aadsts50079', 'reauthenticate', 're-authenticate', 'authentication expired', 'session has expired', '(401)', 'unauthorized' ) foreach ($i in $indices) { if ($lower.Contains($i)) { return $true } } return $false } function Get-SPMRetryDecision { <# .SYNOPSIS Analyse une ErrorRecord et décide si l'opération doit être retentée. .DESCRIPTION Parcourt la chaîne d'exceptions internes à la recherche : - d'un code de statut HTTP (429, 502, 503, 504, 500 transitoire) - d'un en-tête Retry-After (délai ou date) - d'une erreur réseau / d'un dépassement de délai Retourne un objet { ShouldRetry, RetryAfterSeconds, StatusCode, Reason }. .NOTES Exposée publiquement car c'est la fonction la plus critique du socle : elle doit être testable unitairement sans appeler SharePoint. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) $statusCode = $null $retryAfter = $null $reason = $null $ex = $ErrorRecord.Exception $depth = 0 while ($null -ne $ex -and $depth -lt 12) { $props = $ex.PSObject.Properties # --- 1. Statut HTTP porté par une propriété Response (HttpResponseMessage # pour HttpClient, HttpWebResponse pour WebException) --- if ($props['Response'] -and $null -ne $props['Response'].Value) { $response = $props['Response'].Value $rprops = $response.PSObject.Properties if ($rprops['StatusCode'] -and $null -ne $rprops['StatusCode'].Value) { try { $statusCode = [int]$rprops['StatusCode'].Value } catch { Write-Debug 'StatusCode non convertible en entier' } } if ($rprops['Headers'] -and $null -ne $rprops['Headers'].Value) { $retryAfter = Get-SPMRetryAfterSeconds -Headers $rprops['Headers'].Value } } # --- 2. Statut HTTP porté directement par l'exception --- if ($null -eq $statusCode -and $props['StatusCode'] -and $null -ne $props['StatusCode'].Value) { try { $statusCode = [int]$props['StatusCode'].Value } catch { Write-Debug 'StatusCode non convertible en entier' } } # --- 3. Erreurs réseau / délais dépassés (pas de statut HTTP) --- $typeName = $ex.GetType().FullName if ($typeName -in @( 'System.Net.Sockets.SocketException', 'System.IO.IOException', 'System.Threading.Tasks.TaskCanceledException', 'System.OperationCanceledException', 'System.Net.Http.HttpRequestException')) { if (-not $reason) { $reason = "Erreur transitoire : $typeName" } } $ex = $ex.InnerException $depth++ } # --- 4. Repli sur l'analyse du message quand rien n'a été trouvé --- $message = $ErrorRecord.Exception.Message if ($null -eq $statusCode -and $message) { $m = [regex]::Match($message, '\((?<code>4\d{2}|5\d{2})\)') if ($m.Success) { $statusCode = [int]$m.Groups['code'].Value } } $lower = if ($message) { $message.ToLowerInvariant() } else { '' } $throttleHints = @( 'too many requests', 'throttl', '429', 'temporarily unavailable', 'service unavailable', 'server is busy', 'please retry' ) $transientHints = @( 'the operation has timed out', 'a task was canceled', 'unable to read data from the transport connection', 'the underlying connection was closed', 'an existing connection was forcibly closed', 'the remote name could not be resolved', 'connection reset', 'timed out' ) $isThrottle = $statusCode -eq 429 -or ($throttleHints | Where-Object { $lower.Contains($_) }) $isTransient = $statusCode -in @(502, 503, 504) -or ($transientHints | Where-Object { $lower.Contains($_) }) # 500 est ambigu chez SharePoint (souvent une vraie erreur métier) : # on ne retente que si le message évoque explicitement un problème transitoire. $is500Transient = $statusCode -eq 500 -and ($isThrottle -or $isTransient) $shouldRetry = [bool]($isThrottle -or $isTransient -or $is500Transient -or $reason) # Les erreurs définitives ne sont jamais retentées, quoi qu'en dise le message. if ($statusCode -in @(400, 401, 403, 404, 409, 413)) { # 403 accompagné d'un indice de throttling reste une limitation de débit. if (-not ($statusCode -eq 403 -and $isThrottle)) { $shouldRetry = $false } } if (-not $reason) { $reason = if ($isThrottle) { 'Throttling SharePoint Online' } elseif ($isTransient) { 'Erreur serveur transitoire' } elseif ($shouldRetry) { 'Erreur potentiellement transitoire' } else { 'Erreur définitive' } } [pscustomobject]@{ ShouldRetry = $shouldRetry IsThrottle = [bool]$isThrottle # Un jeton expiré n'est pas retentable EN L'ÉTAT — rejouer le même appel # avec la même connexion morte échouerait pareil. Il est réparable : # c'est à l'appelant qui détient la connexion de la rouvrir puis de # rejouer. Le drapeau existe pour qu'il puisse le décider. IsAuthExpired = (Test-SPMAuthExpired -Message $message -StatusCode $statusCode) RetryAfterSeconds = $retryAfter StatusCode = $statusCode Reason = $reason Message = $message } } function Get-SPMRetryAfterSeconds { <# .SYNOPSIS Extrait la valeur Retry-After d'une collection d'en-têtes HTTP. #> [CmdletBinding()] [OutputType([int])] param([Parameter(Mandatory)]$Headers) try { $hprops = $Headers.PSObject.Properties # HttpResponseHeaders : propriété fortement typée RetryConditionHeaderValue if ($hprops['RetryAfter'] -and $null -ne $hprops['RetryAfter'].Value) { $ra = $hprops['RetryAfter'].Value $raProps = $ra.PSObject.Properties if ($raProps['Delta'] -and $null -ne $raProps['Delta'].Value) { return [int][math]::Ceiling(([timespan]$raProps['Delta'].Value).TotalSeconds) } if ($raProps['Date'] -and $null -ne $raProps['Date'].Value) { $delta = ([datetimeoffset]$raProps['Date'].Value) - [datetimeoffset]::UtcNow if ($delta.TotalSeconds -gt 0) { return [int][math]::Ceiling($delta.TotalSeconds) } } } # WebHeaderCollection / dictionnaire : accès par nom foreach ($name in 'Retry-After', 'retry-after') { $raw = $null try { $raw = $Headers[$name] } catch { Write-Debug "En-tete $name inaccessible" } if ($raw) { $value = if ($raw -is [array]) { $raw[0] } else { $raw } $parsed = 0 if ([int]::TryParse([string]$value, [ref]$parsed) -and $parsed -gt 0) { return $parsed } } } } catch { # En-têtes illisibles ou d'un type inattendu : on retombe sur le backoff. Write-Debug "Retry-After illisible : $($_.Exception.Message)" } return 0 } function Invoke-SPMWithRetry { <# .SYNOPSIS Exécute un bloc de script avec gestion du throttling SharePoint Online. .DESCRIPTION Retente automatiquement les erreurs 429 / 502 / 503 / 504 et les erreurs réseau transitoires. Respecte strictement l'en-tête Retry-After quand il est présent ; sinon applique un backoff exponentiel avec jitter afin d'éviter que plusieurs travailleurs ne retentent en même temps. .PARAMETER ScriptBlock Le bloc à exécuter. Sa sortie est retournée telle quelle. .PARAMETER Operation Libellé utilisé dans les journaux. .EXAMPLE Invoke-SPMWithRetry -Operation 'Get-PnPList' -ScriptBlock { Get-PnPList -Identity 'Documents' -Connection $conn } #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][scriptblock]$ScriptBlock, [string]$Operation = 'Appel PnP', [int]$MaxRetries = -1, [int]$BaseDelayMs = -1, [int]$MaxDelayMs = -1, [switch]$PassThruErrors ) if ($MaxRetries -lt 0) { $MaxRetries = $script:SPMConfig.MaxRetries } if ($BaseDelayMs -lt 0) { $BaseDelayMs = $script:SPMConfig.BaseDelayMs } if ($MaxDelayMs -lt 0) { $MaxDelayMs = $script:SPMConfig.MaxDelayMs } $attempt = 0 $script:SPMStats.Calls++ while ($true) { $attempt++ try { return & $ScriptBlock } catch { $decision = Get-SPMRetryDecision -ErrorRecord $_ if (-not $decision.ShouldRetry -or $attempt -gt $MaxRetries) { $script:SPMStats.Failures++ Write-SPMLog -Level ERROR -Operation $Operation -Message ( "Échec définitif après $attempt tentative(s) : $($decision.Reason) - $($decision.Message)" ) -Data @{ attempts = $attempt statusCode = $decision.StatusCode retryable = $decision.ShouldRetry } if ($PassThruErrors) { return $null } throw } # Délai : Retry-After prioritaire, sinon backoff exponentiel + jitter if ($decision.RetryAfterSeconds -and $decision.RetryAfterSeconds -gt 0) { # Le plafond MaxDelayMs borne CE QUE NOUS CALCULONS. L'appliquer # aussi à l'en-tête du serveur faisait rejouer avant l'heure # autorisée — c'est-à-dire aggraver un throttling sévère, où # SharePoint demande couramment plus de deux minutes. $dicte = [long]$decision.RetryAfterSeconds * 1000 $delayMs = [int][math]::Min($dicte, $script:SPMConfig.MaxRetryAfterMs) $source = "Retry-After=$($decision.RetryAfterSeconds)s" if ($dicte -gt $script:SPMConfig.MaxRetryAfterMs) { Write-SPMLog -Level WARNING -Operation $Operation -Message ( "Retry-After aberrant ($($decision.RetryAfterSeconds)s) : ramené à " + "$([int]($script:SPMConfig.MaxRetryAfterMs / 1000))s." ) } elseif ($delayMs -gt $MaxDelayMs) { Write-SPMLog -Level WARNING -Operation $Operation -Message ( "Le serveur impose $($decision.RetryAfterSeconds)s d'attente, au-delà du plafond " + "de backoff ($([int]($MaxDelayMs / 1000))s). Le délai du serveur est respecté : " + 'rejouer plus tôt allongerait ses propres délais.' ) } } else { $exponential = $BaseDelayMs * [math]::Pow(2, $attempt - 1) $capped = [math]::Min($exponential, $MaxDelayMs) $jitter = $capped * $script:SPMConfig.JitterRatio $delayMs = [int]($capped - $jitter + (Get-Random -Minimum 0.0 -Maximum (2 * $jitter))) $delayMs = [math]::Max(100, [math]::Min($delayMs, $MaxDelayMs)) $source = 'backoff exponentiel + jitter' } $script:SPMStats.Retries++ if ($decision.IsThrottle) { $script:SPMStats.ThrottleEvents++ $script:SPMStats.ThrottleWaitMs += $delayMs } Write-SPMLog -Level WARNING -Operation $Operation -Message ( "Tentative $attempt/$MaxRetries échouée ($($decision.Reason)) - " + "nouvelle tentative dans $([math]::Round($delayMs / 1000, 1))s [$source]" ) -Data @{ attempt = $attempt statusCode = $decision.StatusCode delayMs = $delayMs throttled = $decision.IsThrottle } Start-Sleep -Milliseconds $delayMs } } } # ============================================================================ # Pool de connexions # ============================================================================ function ConvertTo-SPMConnectionKey { [CmdletBinding()] param([Parameter(Mandatory)][string]$Url) $Url.TrimEnd('/').ToLowerInvariant() } function Connect-SPMSite { <# .SYNOPSIS Ouvre (ou réutilise) une connexion PnP vers un site et la retourne. .DESCRIPTION Remplace le cycle Disconnect/Remove-Module/Import-Module/Connect qui provoquait une réauthentification MFA à chaque changement de site. La connexion retournée doit être passée aux cmdlets PnP via -Connection. Modes pris en charge : -Interactive login navigateur (MFA) -DeviceLogin login par code (poste sans navigateur) -Thumbprint application seule, certificat du magasin Windows -CertificatePath application seule, certificat sur disque (.pfx) -ManagedIdentity identité managée Azure (Automation, Functions) .EXAMPLE $src = Connect-SPMSite -Url $srcUrl -ClientId $id -Interactive $dst = Connect-SPMSite -Url $dstUrl -ClientId $id -Interactive Get-PnPList -Connection $src #> [CmdletBinding(DefaultParameterSetName = 'Interactive')] [OutputType([object])] param( [Parameter(Mandatory, Position = 0)][string]$Url, [Parameter(ParameterSetName = 'Interactive')] [Parameter(ParameterSetName = 'DeviceLogin')] [Parameter(ParameterSetName = 'Certificate', Mandatory)] [Parameter(ParameterSetName = 'CertificateFile', Mandatory)] [string]$ClientId, [Parameter(ParameterSetName = 'Certificate', Mandatory)] [Parameter(ParameterSetName = 'CertificateFile', Mandatory)] [string]$Tenant, [Parameter(ParameterSetName = 'Interactive')][switch]$Interactive, [Parameter(ParameterSetName = 'DeviceLogin', Mandatory)][switch]$DeviceLogin, [Parameter(ParameterSetName = 'Certificate', Mandatory)][string]$Thumbprint, [Parameter(ParameterSetName = 'CertificateFile', Mandatory)][string]$CertificatePath, [Parameter(ParameterSetName = 'CertificateFile')][securestring]$CertificatePassword, [Parameter(ParameterSetName = 'ManagedIdentity', Mandatory)][switch]$ManagedIdentity, [switch]$PersistLogin, [switch]$Force, [switch]$Validate ) $key = ConvertTo-SPMConnectionKey -Url $Url if (-not $Force -and $script:SPMConnections.ContainsKey($key)) { $existing = $script:SPMConnections[$key] if (-not $Validate) { Write-SPMLog -Level DEBUG -Operation 'Connect' -Message "Connexion réutilisée : $Url" return $existing } try { Invoke-SPMWithRetry -Operation 'Connect/Validate' -MaxRetries 1 -ScriptBlock { Get-PnPWeb -Connection $existing -ErrorAction Stop } | Out-Null Write-SPMLog -Level DEBUG -Operation 'Connect' -Message "Connexion réutilisée et validée : $Url" return $existing } catch { Write-SPMLog -Level WARNING -Operation 'Connect' -Message "Connexion expirée pour $Url, réouverture" $script:SPMConnections.Remove($key) | Out-Null } } $params = @{ Url = $Url; ReturnConnection = $true; ErrorAction = 'Stop' } switch ($PSCmdlet.ParameterSetName) { 'Interactive' { if (-not $ClientId) { throw "ClientId est obligatoire pour l'authentification interactive depuis PnP.PowerShell 2.x " + "(l'application multi-tenant par défaut a été retirée). " + "Enregistrez une application Entra ID et fournissez son ClientId." } $params.Interactive = $true $params.ClientId = $ClientId if ($Tenant) { $params.Tenant = $Tenant } if ($PersistLogin) { $params.PersistLogin = $true } } 'DeviceLogin' { $params.DeviceLogin = $true if ($ClientId) { $params.ClientId = $ClientId } if ($Tenant) { $params.Tenant = $Tenant } if ($PersistLogin) { $params.PersistLogin = $true } } 'Certificate' { $params.ClientId = $ClientId $params.Tenant = $Tenant $params.Thumbprint = $Thumbprint } 'CertificateFile' { $params.ClientId = $ClientId $params.Tenant = $Tenant $params.CertificatePath = $CertificatePath if ($CertificatePassword) { $params.CertificatePassword = $CertificatePassword } } 'ManagedIdentity' { $params.ManagedIdentity = $true } } Write-SPMLog -Level INFO -Operation 'Connect' -Message ( "Ouverture d'une connexion ($($PSCmdlet.ParameterSetName)) vers $Url" ) $connection = Invoke-SPMWithRetry -Operation 'Connect-PnPOnline' -ScriptBlock { Connect-PnPOnline @params } $script:SPMConnections[$key] = $connection Write-SPMLog -Level SUCCESS -Operation 'Connect' -Message "Connexion établie : $Url" return $connection } function Get-SPMSiteConnection { <# .SYNOPSIS Retourne la connexion en pool pour une URL, ou $null si absente. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Url) $key = ConvertTo-SPMConnectionKey -Url $Url if ($script:SPMConnections.ContainsKey($key)) { return $script:SPMConnections[$key] } return $null } function Disconnect-SPMAllSites { <# .SYNOPSIS Ferme et vide le pool de connexions. À appeler une seule fois, en fin de traitement. #> [CmdletBinding()] param([switch]$ClearPersistedLogin) $count = $script:SPMConnections.Count $script:SPMConnections.Clear() try { if ($ClearPersistedLogin) { Disconnect-PnPOnline -ClearPersistedLogin -ErrorAction SilentlyContinue } else { Disconnect-PnPOnline -ErrorAction SilentlyContinue } } catch { # Deconnexion best-effort : une session deja fermee n'est pas une erreur. Write-Debug "Disconnect-PnPOnline : $($_.Exception.Message)" } Write-SPMLog -Level INFO -Operation 'Disconnect' -Message "$count connexion(s) fermée(s)" } # ============================================================================ # Accès aux éléments : requêtes ciblées au lieu d'énumérations complètes # ============================================================================ function Get-SPMListItemByPath { <# .SYNOPSIS Retrouve un élément de liste par son nom de fichier/dossier via CAML. .DESCRIPTION Remplace le motif catastrophique en O(n) : Get-PnPListItem -List $lib | Where-Object { $_.FieldValues['Title'] -eq $name } qui télécharge l'intégralité de la bibliothèque côté client. Ici, le filtrage est fait par le serveur sur les champs indexés FileLeafRef et FileDirRef : une seule ligne transite. .PARAMETER LeafName Nom du fichier ou du dossier (FileLeafRef), sans le chemin. .PARAMETER FolderServerRelativeUrl Chemin serveur-relatif du dossier parent (FileDirRef), par ex. /sites/MonSite/Documents/General. Optionnel mais fortement recommandé. .EXAMPLE Get-SPMListItemByPath -List 'Documents' -LeafName 'Innovation' ` -FolderServerRelativeUrl '/sites/DPT/Documents/General' -Connection $conn #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)][string]$List, [Parameter(Mandatory)][string]$LeafName, [string]$FolderServerRelativeUrl, [string[]]$Fields, $Connection, [int]$RowLimit = 2, # Rend le PREMIER homonyme au lieu de refuser. C'était le comportement # par défaut, et il est dangereux là où le résultat sert à écrire : # sur dix « Rapport.docx », neuf fois sur dix ce n'était pas le bon. [switch]$AllowAmbiguous ) $safeLeaf = [System.Security.SecurityElement]::Escape($LeafName) $where = "<Eq><FieldRef Name='FileLeafRef'/><Value Type='Text'>$safeLeaf</Value></Eq>" if ($FolderServerRelativeUrl) { $safeDir = [System.Security.SecurityElement]::Escape($FolderServerRelativeUrl.TrimEnd('/')) $where = "<And>$where<Eq><FieldRef Name='FileDirRef'/><Value Type='Text'>$safeDir</Value></Eq></And>" } $viewFields = '' if ($Fields) { $refs = ($Fields | ForEach-Object { "<FieldRef Name='$([System.Security.SecurityElement]::Escape($_))'/>" }) -join '' $viewFields = "<ViewFields>$refs</ViewFields>" } $caml = "<View Scope='RecursiveAll'>$viewFields<Query><Where>$where</Where></Query><RowLimit>$RowLimit</RowLimit></View>" $splat = @{ List = $List; Query = $caml; ErrorAction = 'Stop' } if ($Connection) { $splat.Connection = $Connection } $items = Invoke-SPMWithRetry -Operation "Get-SPMListItemByPath/$List" -ScriptBlock { Get-PnPListItem @splat } if (-not $items) { return $null } if ($items -is [array]) { if ($items.Count -gt 1) { # La requête est en Scope='RecursiveAll' : sans dossier parent, le # même nom de feuille se retrouve à tous les étages de la # bibliothèque. Retourner « le premier » revient à désigner un # élément au hasard — acceptable pour une lecture, jamais pour une # écriture de permissions. if (-not $AllowAmbiguous) { Write-SPMLog -Level ERROR -Operation 'Get-SPMListItemByPath' -Message ( "$($items.Count) éléments portent le nom '$LeafName' dans '$List' : refus de choisir. " + 'Précisez -FolderServerRelativeUrl.' ) return $null } Write-SPMLog -Level WARNING -Operation 'Get-SPMListItemByPath' -Message ( "$($items.Count) éléments portent le nom '$LeafName' dans '$List' — le premier est retourné (-AllowAmbiguous)" ) } return $items[0] } return $items } function Test-SPMPathExists { <# .SYNOPSIS Teste l'existence d'un dossier serveur-relatif sans lever d'exception. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][string]$ServerRelativeUrl, $Connection ) $splat = @{ Url = $ServerRelativeUrl; ErrorAction = 'SilentlyContinue' } if ($Connection) { $splat.Connection = $Connection } try { $folder = Invoke-SPMWithRetry -Operation 'Test-SPMPathExists' -MaxRetries 2 -ScriptBlock { Get-PnPFolder @splat } return [bool]$folder } catch { return $false } } # ============================================================================ # Vérification d'environnement # ============================================================================ function Test-SPMPrerequisite { <# .SYNOPSIS Vérifie que l'environnement satisfait les prérequis PnP.PowerShell 3.x. .DESCRIPTION PnP.PowerShell 3.x exige PowerShell 7.4.6+ (édition Core). Un lancement sous Windows PowerShell 5.1 échoue avec un message peu explicite : ce contrôle transforme cet échec en diagnostic clair. #> [CmdletBinding()] [OutputType([pscustomobject])] param([switch]$ThrowOnFailure) $problems = [System.Collections.Generic.List[string]]::new() $warnings = [System.Collections.Generic.List[string]]::new() if ($PSVersionTable.PSEdition -ne 'Core') { $problems.Add("Édition PowerShell '$($PSVersionTable.PSEdition)' : PnP.PowerShell 3.x exige l'édition Core (PowerShell 7).") } if ($PSVersionTable.PSVersion -lt [version]'7.4.6') { $problems.Add("PowerShell $($PSVersionTable.PSVersion) détecté : PnP.PowerShell 3.x exige 7.4.6 ou supérieur.") } $pnp = Get-Module PnP.PowerShell | Select-Object -First 1 if (-not $pnp) { $pnp = Get-Module PnP.PowerShell -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1 } if (-not $pnp) { $problems.Add("Module PnP.PowerShell introuvable. Installez-le : Install-Module PnP.PowerShell -Scope CurrentUser") } elseif ($pnp.Version.Major -lt 2) { $problems.Add("PnP.PowerShell $($pnp.Version) est trop ancien. Version 3.x recommandée.") } $installed = @(Get-Module PnP.PowerShell -ListAvailable | Select-Object -ExpandProperty Version -Unique) if ($installed.Count -gt 1) { $warnings.Add("Plusieurs versions de PnP.PowerShell installées ($($installed -join ', ')). " + "Épinglez la version voulue : Import-Module PnP.PowerShell -RequiredVersion $(($installed | Sort-Object -Descending)[0])") } # Les scripts historiques positionnent des variables de télémétrie obsolètes. # PnP 3.x lit PNP_DISABLETELEMETRY ; PNPPOWERSHELL_TELEMETRY_DISABLED est ignorée. if ($env:PNPPOWERSHELL_TELEMETRY_DISABLED -and -not $env:PNP_DISABLETELEMETRY) { $warnings.Add("PNPPOWERSHELL_TELEMETRY_DISABLED n'est plus lue par PnP 3.x. Utilisez PNP_DISABLETELEMETRY.") } $result = [pscustomobject]@{ Ok = ($problems.Count -eq 0) PSVersion = $PSVersionTable.PSVersion PSEdition = $PSVersionTable.PSEdition PnPVersion = if ($pnp) { $pnp.Version } else { $null } PnPAvailable = @($installed) Problems = @($problems) Warnings = @($warnings) } if ($ThrowOnFailure -and -not $result.Ok) { throw "Prérequis non satisfaits :`n - " + ($problems -join "`n - ") } $result } |