Private/Metadata.ps1

<#
    Application de la métadonnée après transfert.
 
    Pourquoi cette brique existe alors que les travaux de copie sont censés tout
    préserver : parce que « censé » n'est pas une mesure. Les journaux d'une
    migration réelle rapportent Created et Modified divergents sur 5 fichiers
    échantillonnés sur 5, pendant que le bilan de la même copie annonçait
    « préservée » pour 48 fichiers. Un moteur ne doit pas rapporter ce qu'il
    suppose, mais ce qu'il a écrit.
 
    Trois principes, tous appris sur le terrain :
 
      * ÉCRITURE IDEMPOTENTE. -UpdateType SystemUpdate n'incrémente pas la
        version et ne touche pas Modified/Editor de son propre fait. Réappliquer
        une métadonnée déjà correcte ne coûte donc qu'un aller-retour.
 
      * REPLI CHAMP PAR CHAMP. Set-PnPListItem échoue en bloc : un seul principal
        irrésolvable — « Application SharePoint » — faisait échouer les quatre
        champs d'authorship, dates comprises, alors que les dates seraient
        passées. On réessaie donc champ par champ, et on nomme ceux qui résistent.
 
      * DOSSIER APRÈS CONTENU. Déposer un fichier dans un dossier met à jour le
        Modified et le « Modifié par » de ce dossier. Écrire la métadonnée d'un
        dossier avant d'y copier ses enfants revient à l'écrire pour rien. Cette
        règle appartient au PARCOURS, pas à ce fichier — voir Invoke-SPMigration.
#>


# Champs que l'on ne recopie jamais : identité de l'élément, chemin, plomberie
# de version, drapeaux de conformité. Les écrire casse la cible.
$script:SPMNeverCopyField = @(
    'ID', 'GUID', 'UniqueId', 'FileRef', 'FileDirRef', 'FileLeafRef', 'FSObjType',
    'ContentType', 'ContentTypeId', 'Attachments', 'Order', 'DocIcon', 'LinkFilename',
    'LinkFilenameNoMenu', 'LinkFilename2', 'ServerUrl', 'EncodedAbsUrl', 'BaseName',
    'FileSizeDisplay', 'File_x0020_Size', 'File_x0020_Type', 'HTML_x0020_File_x0020_Type',
    'ItemChildCount', 'FolderChildCount', 'CheckoutUser', 'CheckedOutUserId',
    'IsCheckedoutToLocal', 'CheckedOutTitle', 'SyncClientId', 'ProgId', 'ScopeId',
    'MetaInfo', 'owshiddenversion', 'WorkflowVersion', 'WorkflowInstanceID',
    '_UIVersion', '_UIVersionString', '_Level', '_IsCurrentVersion', '_HasCopyDestinations',
    '_CopySource', '_ModerationStatus', '_ModerationComments', '_EditMenuTableStart',
    '_EditMenuTableStart2', '_EditMenuTableEnd', '_CommentFlags', '_CommentCount',
    '_ComplianceFlags', '_ComplianceTag', '_ComplianceTagWrittenTime',
    '_ComplianceTagUserId', '_IsRecord', '_VirusStatus', '_VirusVendorID', '_VirusInfo',
    'AppAuthor', 'AppEditor', 'SharedWithUsers', 'SharedWithDetails', 'InstanceID',
    'TemplateUrl', 'xd_ProgID', 'xd_Signature', 'SortBehavior', 'PrincipalCount',
    'NoExecute', 'OriginatorId', 'AccessPolicy', 'A2ODMountCount', 'BSN',
    'RestrictContentTypeId', 'TaxCatchAll', 'TaxCatchAllLabel',
    'MediaServiceFastMetadata', 'MediaServiceAutoKeyPoints', 'MediaServiceKeyPoints',
    'MediaServiceOCR', 'MediaServiceGenerationTime', 'MediaServiceEventHashCode',
    'MediaServiceDateTaken', 'MediaServiceLocation', 'MediaServiceAutoTags',
    'MediaServiceSearchProperties', 'MediaServiceObjectDetectorVersions',
    'MediaServiceMetadata', 'MediaLengthInSeconds'
)

$script:SPMAuthorshipField = @('Created', 'Modified', 'Author', 'Editor')

# Colonnes transférables par bibliothèque de destination. Une migration touche
# des milliers d'éléments dans quelques bibliothèques : l'interrogation du
# schéma n'a pas à être refaite à chaque élément.
$script:SPMFieldCache = @{}

function Clear-SPMFieldCache {
    [CmdletBinding()]
    param()
    $script:SPMFieldCache.Clear()
}

function Get-SPMCopyableField {
    <#
    .SYNOPSIS
        Colonnes réellement transférables d'une bibliothèque de destination.
 
    .DESCRIPTION
        Les colonnes en lecture seule, masquées, ou de plomberie sont écartées.
        Le filtre s'applique à la DESTINATION : une colonne absente de la cible
        n'a nulle part où atterrir, et tenter de l'écrire ne produirait qu'une
        erreur par élément.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$Connection
    )

    $key = "$($Connection.Url)|$LibraryTitle"
    if ($script:SPMFieldCache.ContainsKey($key)) { return $script:SPMFieldCache[$key] }

    $fields = @(Invoke-SPMWithRetry -Operation "fields/$LibraryTitle" -PassThruErrors -ScriptBlock {
            Get-PnPField -List $LibraryTitle -Connection $Connection -ErrorAction Stop
        })

    $keep = @($fields | Where-Object {
            -not $_.ReadOnlyField -and -not $_.Hidden -and
            $_.InternalName -notin $script:SPMNeverCopyField -and
            "$($_.TypeAsString)" -notmatch '^(Attachments|Computed|Counter|Lookup)$'
        } | ForEach-Object { $_.InternalName })

    $script:SPMFieldCache[$key] = $keep
    return $keep
}

function Get-SPMFieldValue {
    <#
    .SYNOPSIS
        Lit un champ d'un élément de liste, ou $null s'il est absent.
 
    .DESCRIPTION
        Le mode strict fait échouer la lecture d'une clé absente. Un champ
        absent est pourtant un cas NORMAL de comparaison — il ne doit pas
        interrompre la vérification de tous les autres.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Item, [Parameter(Mandatory)][string]$Name)

    if (-not $Item.PSObject.Properties['FieldValues']) { return $null }
    $valeurs = $Item.FieldValues
    if ($null -eq $valeurs) { return $null }
    if ($valeurs -is [System.Collections.IDictionary] -and -not $valeurs.Contains($Name)) { return $null }
    try { return $valeurs[$Name] } catch { return $null }
}

function Format-SPMFieldForCompare {
    <#
    .SYNOPSIS
        Rend un champ comparable de part et d'autre d'une migration.
 
    .DESCRIPTION
        Deux pièges, mesurés l'un et l'autre : une date lue de deux tenants
        diffère par son fuseau et par ses secondes, sans qu'aucune information
        n'ait été perdue ; un principal se présente tantôt comme un objet,
        tantôt comme une chaîne. Comparer les formes brutes produisait des
        écarts qui n'en étaient pas — et un rapport qui crie au loup finit par
        n'être plus lu.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return '' }
    if ($Value -is [datetime]) { return ([datetime]$Value).ToUniversalTime().ToString('yyyy-MM-dd HH:mm') }

    $props = $Value.PSObject.Properties
    if ($props -and $props['Email'] -and $Value.Email) { return "$($Value.Email)" }
    if ($props -and $props['LookupValue'] -and $Value.LookupValue) { return "$($Value.LookupValue)" }

    return "$Value"
}

function Get-SPMItemForPath {
    <#
    .SYNOPSIS
        Élément de liste correspondant à un chemin, fichier ou dossier.
 
    .DESCRIPTION
        Deux cmdlets distinctes selon la nature : `Get-PnPFile -AsListItem` pour
        un fichier, `Get-PnPFolder -AsListItem` pour un dossier. Retourne $null
        plutôt que de lever, l'appelant comptant l'échec.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ServerRelativeUrl,
        [Parameter(Mandatory)][ValidateSet('File', 'Folder')][string]$Kind,
        [Parameter(Mandatory)]$Connection
    )

    return Invoke-SPMWithRetry -Operation "item/$Kind" -PassThruErrors -ScriptBlock {
        if ($Kind -eq 'File') {
            Get-PnPFile -Url $ServerRelativeUrl -AsListItem -Connection $Connection -ErrorAction Stop
        }
        else {
            Get-PnPFolder -Url $ServerRelativeUrl -AsListItem -Connection $Connection -ErrorAction Stop
        }
    }
}

function ConvertTo-SPMSettableValue {
    <#
    .SYNOPSIS
        Traduit une valeur de champ source vers la forme que Set-PnPListItem accepte.
 
    .DESCRIPTION
        Retourne $null quand la valeur n'est pas transférable — l'appelant
        l'ignore alors, plutôt que d'écrire une chaîne vide qui effacerait la
        valeur de destination.
    #>

    [CmdletBinding()]
    param($Value)

    if ($null -eq $Value) { return $null }

    # Collections : multi-utilisateur, multi-taxonomie, multi-choix.
    if ($Value -is [array] -or ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string])) {
        $items = @(foreach ($v in $Value) { ConvertTo-SPMSettableValue -Value $v })
        $items = @($items | Where-Object { $null -ne $_ })
        return $(if ($items.Count) { , $items } else { $null })
    }

    # Date : ISO 8601 en UTC, culture invariante.
    #
    # Passer un objet [datetime] laisse PnP le convertir en chaîne avec la
    # culture courante. Sur un poste français cela donne « 13/08/2026 09:07 »,
    # que le champ SharePoint interprète selon la locale du WEB — pas celle du
    # poste. Résultat : la valeur est silencieusement mal comprise, ou ignorée,
    # et l'écriture rapporte pourtant un succès. C'est ce qui faisait tenir
    # Author et Editor pendant que Created et Modified restaient à la date de
    # migration.
    if ($Value -is [datetime]) {
        return ([datetime]$Value).ToUniversalTime().ToString(
            "yyyy-MM-ddTHH:mm:ssZ", [System.Globalization.CultureInfo]::InvariantCulture)
    }

    $p = $Value.PSObject.Properties

    # Taxonomie : « Étiquette|GUID » est la forme attendue.
    if ($p['TermGuid'] -and $Value.TermGuid) {
        $label = if ($p['Label']) { $Value.Label } else { '' }
        return "$label|$($Value.TermGuid)"
    }
    # Utilisateur : le courriel est le plus portable d'un site à l'autre.
    if ($p['Email'] -and $Value.Email) { return $Value.Email }
    # Groupe, ou compte sans courriel : le nom de connexion reste résolvable.
    if ($p['LoginName'] -and $Value.LoginName) { return $Value.LoginName }
    # Lien hypertexte ou image.
    if ($p['Url'] -and $Value.Url) {
        $desc = if ($p['Description'] -and $Value.Description) { $Value.Description } else { $Value.Url }
        return "$($Value.Url), $desc"
    }
    # Recherche, ou principal sans identité résolvable : on garde le libellé.
    if ($p['LookupValue'] -and $Value.LookupValue) { return $Value.LookupValue }

    return $Value
}

function Set-SPMValueSet {
    <#
    .SYNOPSIS
        Écrit un jeu de valeurs, avec repli champ par champ.
 
    .DESCRIPTION
        Set-PnPListItem écrit tout ou rien. Un champ fautif faisait donc perdre
        les autres — y compris des dates qui seraient passées sans lui. En cas
        d'échec groupé on réessaie champ par champ, et on retourne la liste de
        ceux qui résistent, pour que le bilan les nomme.
 
    .OUTPUTS
        Tableau des noms internes de champs non appliqués. Vide = tout est passé.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$ItemId,
        [Parameter(Mandatory)][hashtable]$Values,
        [Parameter(Mandatory)]$Connection,
        [string]$Operation = 'metadata',

        # SystemUpdate pour les colonnes métier : il écrit sans créer de version
        # NI toucher Modified/Editor — exactement ce qu'on veut d'une colonne.
        #
        # UpdateOverwriteVersion pour l'authorship, et c'est indispensable :
        # SystemUpdate a pour rôle de PROTÉGER Modified et Editor. Lui demander
        # de les écrire, c'est lui demander l'inverse de sa garantie. Mesuré sur
        # 63 éléments : Modified refusé 63 fois, Created 63 fois, en silence, et
        # l'écriture rapportait un succès.
        [ValidateSet('SystemUpdate', 'UpdateOverwriteVersion', 'Update')]
        [string]$UpdateType = 'SystemUpdate'
    )

    if (-not $Values.Count) { return @() }

    try {
        Invoke-SPMWithRetry -Operation "Set-PnPListItem/$Operation" -MaxRetries 1 -ScriptBlock {
            Set-PnPListItem -List $LibraryTitle -Identity $ItemId -Values $Values `
                -UpdateType $UpdateType -Connection $Connection -ErrorAction Stop
        } | Out-Null
        return @()
    }
    catch {
        Write-SPMLog -Level DEBUG -Operation $Operation -Message (
            "Écriture groupée refusée, passage champ par champ : $($_.Exception.Message)"
        )
    }

    $failed = @()
    foreach ($key in $Values.Keys) {
        $single = @{ $key = $Values[$key] }
        try {
            Invoke-SPMWithRetry -Operation "Set-PnPListItem/$Operation/$key" -MaxRetries 1 -ScriptBlock {
                Set-PnPListItem -List $LibraryTitle -Identity $ItemId -Values $single `
                    -UpdateType $UpdateType -Connection $Connection -ErrorAction Stop
            } | Out-Null
        }
        catch {
            $failed += $key
            Write-SPMLog -Level WARNING -Operation $Operation -Message (
                "Champ « $key » non appliqué : $(($_.Exception.Message -split [char]10)[0])"
            )
        }
    }
    return $failed
}

function Test-SPMAuthorshipApplied {
    <#
    .SYNOPSIS
        Relit un élément et retourne les champs d'authorship qui n'ont PAS pris.
 
    .DESCRIPTION
        `Set-PnPListItem` retourne un succès dès lors que la requête est
        acceptée. Une date mal comprise par le champ, ou refusée en silence,
        passe donc pour appliquée. Mesuré : Author et Editor prenaient, Created
        et Modified non, et le bilan annonçait quatre champs restaurés.
 
        Les dates sont comparées à la minute et en UTC : la seconde et le
        fuseau d'affichage ne sont pas des écarts de fidélité.
 
    .OUTPUTS
        Noms des champs dont la valeur relue diffère de la valeur écrite.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$ItemId,
        [Parameter(Mandatory)][hashtable]$Expected,
        [Parameter(Mandatory)]$Connection
    )

    $relu = Invoke-SPMWithRetry -Operation 'relecture/authorship' -PassThruErrors -ScriptBlock {
        Get-PnPListItem -List $LibraryTitle -Id $ItemId -Connection $Connection -ErrorAction Stop
    }
    if (-not $relu) { return @($Expected.Keys) }

    $normalise = {
        param($v)
        if ($null -eq $v) { return '' }
        if ($v -is [datetime]) {
            return ([datetime]$v).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
        }
        $texte = "$v"
        # Une date écrite en ISO revient en [datetime] : on ramène les deux
        # formes au même cadran avant de conclure à un écart.
        $parsed = [datetime]::MinValue
        $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor
        [System.Globalization.DateTimeStyles]::AssumeUniversal
        if ([datetime]::TryParse($texte, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$parsed)) {
            return $parsed.ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
        }
        if ($v.PSObject.Properties['Email'] -and $v.Email) { return "$($v.Email)" }
        if ($v.PSObject.Properties['LookupValue'] -and $v.LookupValue) { return "$($v.LookupValue)" }
        return $texte
    }

    $refuses = @()
    foreach ($champ in $Expected.Keys) {
        if (-not $relu.FieldValues.ContainsKey($champ)) { $refuses += $champ; continue }

        $attendu = & $normalise $Expected[$champ]
        $obtenu = & $normalise $relu.FieldValues[$champ]

        if ($attendu -ne $obtenu) {
            $refuses += $champ
            Write-SPMLog -Level WARNING -Operation 'Authorship' -Message (
                "Champ « $champ » accepté mais NON applique : ecrit « $attendu », relu « $obtenu »"
            )
        }
    }
    return $refuses
}

function Set-SPMItemMetadata {
    <#
    .SYNOPSIS
        Applique à un élément de destination la métadonnée de son homologue source.
 
    .DESCRIPTION
        Trois écritures séparées — type de contenu, colonnes métier, authorship —
        et non une seule. Un échec sur l'authorship n'a aucune raison d'emporter
        les colonnes métier, ni l'inverse.
 
    .OUTPUTS
        Objet décrivant ce qui a été ÉCRIT, jamais ce qui est supposé préservé.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]$SourceItem,
        [Parameter(Mandatory)]$TargetItem,
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$Connection,
        [switch]$SkipAuthorship
    )

    $etat = [ordered]@{
        ContentType = 'non tenté'
        Fields = 'non tenté'
        Authorship = 'non tenté'
        FailedFields = @()
        Substitutions = @()
    }

    $srcValues = $SourceItem.FieldValues

    # ------------------------------------------------------- type de contenu
    if ($srcValues.ContainsKey('ContentType') -and $srcValues['ContentType']) {
        $ctName = "$($srcValues['ContentType'])"
        try {
            Invoke-SPMWithRetry -Operation 'Set-PnPListItem/contentType' -MaxRetries 1 -ScriptBlock {
                Set-PnPListItem -List $LibraryTitle -Identity $TargetItem.Id -ContentType $ctName `
                    -UpdateType SystemUpdate -Connection $Connection -ErrorAction Stop
            } | Out-Null
            $etat.ContentType = 'appliqué'
        }
        catch {
            $etat.ContentType = 'échec'
            Write-SPMLog -Level WARNING -Operation 'ContentType' -Message (
                "Type de contenu « $ctName » non appliqué : $(($_.Exception.Message -split [char]10)[0])"
            )
        }
    }

    # ---------------------------------------------------- colonnes métier
    $copyable = Get-SPMCopyableField -LibraryTitle $LibraryTitle -Connection $Connection
    $values = @{}
    foreach ($name in $copyable) {
        if (-not $srcValues.ContainsKey($name)) { continue }
        $converted = ConvertTo-SPMSettableValue -Value $srcValues[$name]
        if ($null -eq $converted) { continue }
        if ($converted -is [string] -and $converted -eq '') { continue }
        $values[$name] = $converted
    }

    if ($values.Count) {
        $failed = @(Set-SPMValueSet -LibraryTitle $LibraryTitle -ItemId $TargetItem.Id `
                -Values $values -Connection $Connection -Operation 'fields')
        $etat.FailedFields += $failed
        $etat.Fields = if (-not $failed.Count) { "$($values.Count)/$($values.Count)" }
        else { "$($values.Count - $failed.Count)/$($values.Count)" }
    }
    else {
        $etat.Fields = 'aucune colonne à copier'
    }

    # --------------------------------------------------- dates et auteurs
    if (-not $SkipAuthorship) {
        $auth = @{}
        foreach ($f in $script:SPMAuthorshipField) {
            if (-not $srcValues.ContainsKey($f)) { continue }

            # Un principal système ne se restaure pas : le déclarer ici évite
            # une écriture vouée à l'échec, et le dit dans le bilan.
            if ($f -in 'Author', 'Editor') {
                $classe = Get-SPMPrincipalClass -Principal $srcValues[$f]
                if ($classe -eq 'System') {
                    $etat.Substitutions += "$f=principal système, non restaurable"
                    continue
                }
            }

            $converted = ConvertTo-SPMSettableValue -Value $srcValues[$f]
            if ($null -ne $converted) { $auth[$f] = $converted }
        }

        if ($auth.Count) {
            $failed = @(Set-SPMValueSet -LibraryTitle $LibraryTitle -ItemId $TargetItem.Id `
                    -Values $auth -Connection $Connection -Operation 'authorship' `
                    -UpdateType UpdateOverwriteVersion)

            # RELECTURE. Une écriture acceptée n'est pas une écriture prise en
            # compte : SharePoint retourne un succès sur une date qu'il n'a pas
            # comprise. Sans ce contrôle, le moteur rapportait « restaurée »
            # pour quatre champs dont deux n'avaient pas bougé.
            $refuses = @(Test-SPMAuthorshipApplied -LibraryTitle $LibraryTitle -ItemId $TargetItem.Id `
                    -Expected $auth -Connection $Connection)

            $enEchec = @(@($failed) + @($refuses) | Sort-Object -Unique)
            $etat.FailedFields += $enEchec

            $etat.Authorship = if (-not $enEchec.Count) { 'restaurée (verifiée)' }
            elseif ($enEchec.Count -lt $auth.Count) { "partielle ($($auth.Count - $enEchec.Count)/$($auth.Count))" }
            else { 'non restaurée' }
        }
        elseif ($etat.Substitutions.Count) {
            $etat.Authorship = 'principal système : dates seules'
        }
    }

    return [pscustomobject]$etat
}