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