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
}