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
            }
        }
    }
}