Public/New-SPSiteInventory.ps1
|
function New-SPSiteInventory { <# .SYNOPSIS Produit un inventaire d'évaluation avant migration. .DESCRIPTION Répond aux questions qu'il faut se poser avant de lancer quoi que ce soit : combien d'éléments, quel volume, quelle profondeur, quel est le chemin le plus long, le plus gros fichier, combien de bibliothèques, y a-t-il des fichiers extraits. C'est l'équivalent du rapport de pré-migration de ShareGate — l'outil précédent n'avait aucun équivalent. L'inventaire est aussi la base d'une estimation de durée réaliste et du découpage en vagues. Il porte exactement sur le périmètre que la migration transfère : mêmes bibliothèques que `Copy-SPSiteSchema` (systèmes et masquées écartées), mêmes dossiers système ignorés. Une bibliothèque dont la racine ne se résout pas est déclarée NON inventoriée plutôt que comptée de travers. .PARAMETER SiteUrl URL du site à inventorier. .PARAMETER LibraryName Restreint l'inventaire à une bibliothèque. Sinon, toutes les bibliothèques de documents visibles sont parcourues. .PARAMETER ClientId ClientId de l'application Entra ID (obligatoire depuis PnP 2.x). .PARAMETER OutputPath Chemin d'export JSON de l'inventaire complet. .EXAMPLE New-SPSiteInventory -SiteUrl https://contoso.sharepoint.com/sites/RH -ClientId $id -OutputPath .\inventaire.json .OUTPUTS Objet de synthèse avec le détail par bibliothèque. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory, Position = 0)][string]$SiteUrl, [string]$LibraryName, [string]$ClientId = $env:SPM_CLIENT_ID, [string]$Tenant = $env:SPM_TENANT, [string]$Thumbprint = $env:SPM_CERT_THUMBPRINT, [string]$OutputPath, [int]$MaxDepth = 0 ) $started = Get-Date $connectSplat = @{ Url = $SiteUrl; ClientId = $ClientId } if ($Thumbprint) { if (-not $Tenant) { throw 'Tenant est obligatoire avec un certificat.' } $connectSplat.Thumbprint = $Thumbprint $connectSplat.Tenant = $Tenant } else { $connectSplat.Interactive = $true } $connection = Connect-SPMSite @connectSplat $web = Invoke-SPMWithRetry -Operation 'Get-PnPWeb' -ScriptBlock { Get-PnPWeb -Connection $connection -ErrorAction Stop } $webRoot = $web.ServerRelativeUrl.TrimEnd('/') # Même sélection que le moteur de schéma : RootFolder demandé d'emblée, # bibliothèques système et listes masquées écartées. L'inventaire les # comptait auparavant, et mesurait donc un volume que la migration ne # transfère pas. $libraries = @(Get-SPMSourceLibrary -Connection $connection ` -Include $(if ($LibraryName) { @($LibraryName) } else { $null })) Write-SPMLog -Level INFO -Operation 'Inventory' -Message "$($libraries.Count) bibliothèque(s) à inventorier sur $SiteUrl" $details = [System.Collections.Generic.List[object]]::new() $ignorees = [System.Collections.Generic.List[string]]::new() $totalFiles = 0 $totalFolders = 0 $totalBytes = 0L $totalCheckedOut = 0 $maxDepthSeen = 0 $maxUrlLength = 0 $longestPath = '' $largestFile = '' $largestBytes = 0L foreach ($lib in $libraries) { Write-SPMLog -Level INFO -Operation 'Inventory' -Message "Analyse de '$($lib.Title)' ($($lib.ItemCount) élément(s) déclaré(s))" # L'URL d'une bibliothèque n'est ni son titre ni le nom de son dossier # racine lu sur un objet incomplet : « $webRoot/$($lib.RootFolder.Name) » # sur un Get-PnPList groupé donnait « /sites/X/ », c'est-à-dire la RACINE # DU SITE. L'inventaire énumérait alors les dossiers système du site # entier au lieu du contenu de la bibliothèque — et volumes, profondeur # et URL maximale, donc l'estimation de durée et l'alerte des # 400 caractères, portaient sur un périmètre qui n'était pas le bon. $folderUrl = Get-SPMListRootUrl -List $lib if (-not $folderUrl -or $folderUrl.TrimEnd('/') -ieq $webRoot) { $raison = if ($folderUrl) { 'sa racine se confond avec celle du site' } else { 'sa racine est irrésoluble' } $ignorees.Add("$($lib.Title) — $raison") Write-SPMLog -Level ERROR -Operation 'Inventory' -Message ( "'$($lib.Title)' non inventoriée : $raison. Le compte ne porte donc PAS sur cette bibliothèque." ) continue } $items = @(Get-SPMFolderInventory -FolderUrl $folderUrl -Connection $connection -MaxDepth $MaxDepth) $files = @($items | Where-Object { -not $_.IsFolder }) $folders = @($items | Where-Object { $_.IsFolder }) $bytes = ($files | Measure-Object SizeBytes -Sum).Sum if (-not $bytes) { $bytes = 0 } $libMaxDepth = 0 if ($items.Count -gt 0) { $libMaxDepth = ($items | Measure-Object Depth -Maximum).Maximum } foreach ($i in $items) { $len = [System.Uri]::UnescapeDataString($i.ServerRelativeUrl).Length if ($len -gt $maxUrlLength) { $maxUrlLength = $len; $longestPath = $i.ServerRelativeUrl } } foreach ($f in $files) { if ($f.SizeBytes -gt $largestBytes) { $largestBytes = $f.SizeBytes; $largestFile = $f.ServerRelativeUrl } } # L'énumération relève déjà l'état d'extraction (Get-SPMFolderInventory). # Ne pas le remonter obligeait l'opérateur à découvrir en pleine # migration qu'un fichier extrait ne transfère que sa dernière version # archivée — le travail en cours restant chez celui qui l'a extrait. $extraits = @($files | Where-Object { $_.PSObject.Properties['IsCheckedOut'] -and $_.IsCheckedOut }) $totalFiles += $files.Count $totalFolders += $folders.Count $totalBytes += [long]$bytes $totalCheckedOut += $extraits.Count if ($libMaxDepth -gt $maxDepthSeen) { $maxDepthSeen = $libMaxDepth } $details.Add([pscustomobject]@{ Library = $lib.Title RootUrl = $folderUrl DeclaredItems = $lib.ItemCount CheckedOut = $extraits.Count Files = $files.Count Folders = $folders.Count SizeBytes = [long]$bytes SizeGB = [math]::Round($bytes / 1GB, 3) MaxDepth = $libMaxDepth EnableVersion = $lib.EnableVersioning MajorVersions = $lib.MajorVersionLimit ContentTypesOn = $lib.ContentTypesEnabled AboveThreshold = ($files.Count -gt $script:SPMLimits.ListViewThreshold) }) } $duration = (Get-Date) - $started $inventory = [pscustomobject]@{ SiteUrl = $SiteUrl SiteTitle = $web.Title GeneratedAt = (Get-Date).ToString('o') DurationSec = [math]::Round($duration.TotalSeconds, 1) Libraries = $details.Count LibrariesIgnored = $ignorees.ToArray() TotalFiles = $totalFiles TotalFolders = $totalFolders TotalCheckedOut = $totalCheckedOut TotalSizeBytes = $totalBytes TotalSizeGB = [math]::Round($totalBytes / 1GB, 3) MaxDepth = $maxDepthSeen MaxUrlLength = $maxUrlLength LongestPath = $longestPath LargestFile = $largestFile LargestFileGB = [math]::Round($largestBytes / 1GB, 3) UrlRisk = ($maxUrlLength -gt 360) Detail = $details.ToArray() } Write-SPMLog -Level $(if ($ignorees.Count) { 'WARNING' } else { 'SUCCESS' }) -Operation 'Inventory' -Message ( "Inventaire terminé sur $($details.Count) bibliothèque(s) : $totalFiles fichier(s), $totalFolders dossier(s), " + "$([math]::Round($totalBytes / 1GB, 2)) Go, profondeur max $maxDepthSeen, URL max $maxUrlLength caractères" + $(if ($totalCheckedOut) { ", $totalCheckedOut fichier(s) EXTRAIT(S)" }) + $(if ($ignorees.Count) { " — $($ignorees.Count) bibliothèque(s) NON inventoriée(s)" }) ) -Data @{ libraries = $details.Count ignored = $ignorees.Count files = $totalFiles folders = $totalFolders bytes = $totalBytes checkedOut = $totalCheckedOut maxDepth = $maxDepthSeen maxUrlLen = $maxUrlLength } if ($totalCheckedOut) { Write-SPMLog -Level WARNING -Operation 'Inventory' -Message ( "$totalCheckedOut fichier(s) extrait(s) : seule leur dernière version archivée sera transférée. " + 'Faites-les archiver avant la migration, ou acceptez la perte du travail en cours.' ) } if ($maxUrlLength -gt 360) { Write-SPMLog -Level WARNING -Operation 'Inventory' -Message ( "Chemin le plus long : $maxUrlLength caractères. Tout préfixe de destination plus long " + "que $((400 - $maxUrlLength)) caractères provoquera des échecs." ) } if ($OutputPath) { $dir = Split-Path -Parent $OutputPath if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $inventory | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $OutputPath -Encoding utf8 Write-SPMLog -Level SUCCESS -Operation 'Inventory' -Message "Inventaire exporté : $OutputPath" } return $inventory } |