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