Remarque
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de vous connecter ou de modifier des répertoires.
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de modifier des répertoires.
Cet article contient la source des scripts d’onde de page wiki et de composant WebPart pilotés par l’évaluation. Suivez Transformer les pages classiques sélectionnées avec PnP PowerShell pour la procédure ordonnée.
Configuration requise
- PowerShell 7.4.0 ou version ultérieure.
- PowerShell PnP actuel.
- révision
representative-page-groups.csvgénérée par la procédure principale. - Une application interactive appartenant au locataire ou une application de certificat d’application uniquement configurée séparément.
Enregistrez les trois fichiers incorporés dans le même dossier avec les noms de fichiers exacts affichés.
Fichiers de script
| Fichier | Objectif |
|---|---|
PageTransformation.Common.ps1 |
Authentification partagée, validation de la source, contrôle préliminaire, transformation et persistance des résultats. |
Convert-RepresentativePages.ps1 |
Préversion ou transformation de chaque page représentative sélectionnée. |
Convert-SelectedPages.ps1 |
Contrôle ou transforme des pages approuvées supplémentaires une fois la validation représentative réussie. |
Paramètres communs
| Paramètre | Objectif |
|---|---|
-ClientId |
ID d’application PowerShell PnP appartenant au locataire. |
-AuthenticationMode |
Interactive, DeviceLogin, CertificateThumbprintou CertificateFile. |
-Tenant |
Requis pour l’authentification de l’appareil ou du certificat. |
-AzureEnvironment |
Sélectionne l’environnement cloud Microsoft 365. |
-WhatIf |
Écrit un plan sans authentification ; l’existence de la cible reste NotChecked. |
-PreflightOnly |
Authentifie et valide le plan source et cible sans effectuer de conversion. |
-Confirm |
Demande une confirmation à fort impact pour chaque page. Utilisez -Confirm:$false uniquement pour une exécution sans assistance approuvée. |
-Force |
Remplace un fichier CSV de préversion, de préversion ou de résultat local existant. Il n’active jamais le remplacement de la cible SharePoint. |
-ResultPath, -LogFolder |
Sélectionnez des emplacements de preuve locaux. |
Convert-RepresentativePages.ps1 nécessite -ManifestPath.
Convert-SelectedPages.ps1 nécessite -PagesPath, -RepresentativeManifestPathet -RepresentativeResultsPath.
Contrat de résultat
Chaque tentative est écrite immédiatement. Les champs importants sont les suivants :
| Field | Signification |
|---|---|
ManifestRowHash, CandidateRowHash, IncludedManifestHash |
Liez chaque résultat, la page approuvée et l’étendue incluse complète au contenu du manifeste révisé. |
TransformationProfileHash et les hachages de script |
Liez la validation représentative aux scripts exécutés et à la version De PowerShell PnP. |
PlannedAction, PlannedTargetPageUrl, TargetExists |
Conservez la préversion ou le plan préalable authentifié. |
TransformationStatus |
Skipped, PreflightPassed, Createdou Failed. |
TargetPageUrl, LogPath |
Création d’un brouillon et d’un journal PnP exact lorsqu’il est disponible. |
ValidationStatus, ValidationNotes, ValidatedBy, ValidatedAt |
Preuve de validation manuelle requise avant l’expansion. |
Pages prises en charge et exclues
| Condition de page | Itinéraire de script batch |
|---|---|
Page Wiki ou composant WebPart dans la bibliothèque par défaut SitePages |
Pris en charge une fois que toutes les vérifications de manifeste et de contrôle préalable ont réussi. |
| Page d’accueil, page en partie nulle, source modifiée, autorisations uniques ou cible existante | Exclus; revenir à une révision distincte. |
| Page de publication ou de blog | Utilisez une procédure avancée validée séparément. |
| Mappage de composants WebPart personnalisé ou cible intersites | Utilisez une procédure monopage ou avancée examinée séparément. |
| Page source ou racine web SharePoint Server | Utilisez des conseils intersites avancés. |
PageTransformation.Common.ps1
Set-StrictMode -Version Latest
# CSV values arrive as strings. Parse safety-critical values strictly instead of
# treating blanks or misspellings as False.
function ConvertTo-PageWaveBoolean {
param(
[Parameter(Mandatory = $true)]
[object]$Value,
[Parameter(Mandatory = $false)]
[string]$Name = 'Boolean value'
)
if ($Value -is [bool]) {
return $Value
}
$text = [string]$Value
if ([string]::IsNullOrWhiteSpace($text)) {
throw "$Name can't be empty."
}
switch ($text.Trim().ToLowerInvariant()) {
{ $_ -in @('true', 'yes', '1') } { return $true }
{ $_ -in @('false', 'no', '0') } { return $false }
default { throw "$Name must be True or False. Received '$text'." }
}
}
# Use bounded integer parsing for Assessment counts and readiness percentages.
function ConvertTo-PageWaveInteger {
param(
[Parameter(Mandatory = $true)]
[object]$Value,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
[int]$Minimum = 0,
[Parameter(Mandatory = $false)]
[int]$Maximum = [int]::MaxValue
)
$parsed = 0
if (-not [int]::TryParse([string]$Value, [ref]$parsed) -or
$parsed -lt $Minimum -or
$parsed -gt $Maximum) {
throw "$Name must be an integer from $Minimum through $Maximum. Received '$Value'."
}
return $parsed
}
# Reconstruct the exact web URL from the Assessment SiteUrl + WebUrl contract.
function Get-PageWaveSourceUrl {
param(
[Parameter(Mandatory = $true)]
[object]$Row
)
if ([string]::IsNullOrWhiteSpace($Row.SiteUrl) -or
[string]::IsNullOrWhiteSpace($Row.WebUrl)) {
throw "SiteUrl and WebUrl are required for $($Row.PageUrl)."
}
if ($Row.WebUrl -eq '/') {
return $Row.SiteUrl.TrimEnd('/')
}
return "$($Row.SiteUrl.TrimEnd('/'))$($Row.WebUrl)"
}
# Read optional manifest/result properties without breaking StrictMode error handling.
function Get-PageWaveValue {
param(
[Parameter(Mandatory = $true)]
[object]$Row,
[Parameter(Mandatory = $true)]
[string]$Name
)
if ($Row.PSObject.Properties.Name -contains $Name) {
return $Row.$Name
}
return ''
}
# This key uniquely identifies one assessed page across scans, sites, and webs.
function Get-PageWaveKey {
param(
[Parameter(Mandatory = $true)]
[object]$Row
)
return (
'{0}|{1}|{2}|{3}' -f
(Get-PageWaveValue -Row $Row -Name 'ScanId'),
(Get-PageWaveValue -Row $Row -Name 'SiteUrl'),
(Get-PageWaveValue -Row $Row -Name 'WebUrl'),
(Get-PageWaveValue -Row $Row -Name 'PageUrl')
).ToLowerInvariant()
}
# Hash every transformation-relevant manifest value. The expansion script uses this
# hash to prove that the validated representative row hasn't changed.
function Get-PageWaveManifestHash {
param(
[Parameter(Mandatory = $true)]
[object]$Row
)
$fields = @(
'ScanId',
'SiteUrl',
'WebUrl',
'PageUrl',
'PageType',
'ListUrl',
'ListTitle',
'ListId',
'ModifiedAt',
'AssessmentTimeZoneId',
'Layout',
'HomePage',
'WebPartCount',
'MappingPercentage',
'UnmappedWebParts',
'WebPartSignature',
'PatternKey',
'IncludePattern',
'Selected',
'ExpectedVisibleContent',
'ValidationOwner'
)
$payload = ($fields | ForEach-Object { [string](Get-PageWaveValue -Row $Row -Name $_) }) -join [char]0x1f
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
return [Convert]::ToHexString(
$sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload))
).ToLowerInvariant()
}
finally {
$sha256.Dispose()
}
}
function Get-PageWaveCandidateHash {
param(
[Parameter(Mandatory = $true)]
[object]$Row
)
$fields = @(
'ScanId',
'SiteUrl',
'WebUrl',
'PageUrl',
'PageType',
'ListUrl',
'ListTitle',
'ListId',
'ModifiedAt',
'AssessmentTimeZoneId',
'Layout',
'HomePage',
'WebPartCount',
'MappingPercentage',
'UnmappedWebParts',
'WebPartSignature',
'PatternKey',
'IncludePattern'
)
$payload = ($fields | ForEach-Object { [string](Get-PageWaveValue -Row $Row -Name $_) }) -join [char]0x1f
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
return [Convert]::ToHexString(
$sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload))
).ToLowerInvariant()
}
finally {
$sha256.Dispose()
}
}
function Get-PageWaveIncludedManifestHash {
param(
[Parameter(Mandatory = $true)]
[object[]]$Rows
)
$entries = [Collections.Generic.List[string]]::new()
foreach ($row in $Rows) {
$entries.Add(
(Get-PageWaveKey -Row $row) +
'|' +
(Get-PageWaveCandidateHash -Row $row)
)
}
$sortedEntries = $entries.ToArray()
[Array]::Sort($sortedEntries, [StringComparer]::Ordinal)
$payload = $sortedEntries -join [char]0x1e
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
return [Convert]::ToHexString(
$sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload))
).ToLowerInvariant()
}
finally {
$sha256.Dispose()
}
}
# Validation is meaningful only when the same scripts, PnP.PowerShell version, and Web
# Part transformation implementation are used for both waves.
function Get-PageWaveTransformationProfile {
Import-Module PnP.PowerShell -ErrorAction Stop
$module = Get-Module PnP.PowerShell | Sort-Object Version -Descending | Select-Object -First 1
if (-not $module) {
throw "PnP.PowerShell isn't loaded."
}
$scriptNames = @(
'PageTransformation.Common.ps1',
'Convert-RepresentativePages.ps1',
'Convert-SelectedPages.ps1'
)
$scriptHashes = @{}
foreach ($scriptName in $scriptNames) {
$scriptPath = Join-Path $PSScriptRoot $scriptName
if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) {
throw "Required page wave script not found: $scriptPath"
}
$scriptHashes[$scriptName] = (
Get-FileHash -LiteralPath $scriptPath -Algorithm SHA256
).Hash.ToLowerInvariant()
}
$scriptVersion = '1.0.0'
$payload = (
"Script=$scriptVersion",
"PnP=$($module.Version)",
"Common=$($scriptHashes['PageTransformation.Common.ps1'])",
"Representative=$($scriptHashes['Convert-RepresentativePages.ps1'])",
"Selected=$($scriptHashes['Convert-SelectedPages.ps1'])",
'Mapping=embedded-default',
'Draft=True',
'UniquePermissions=Excluded',
'ModifiedSources=Excluded'
) -join '|'
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$profileHash = [Convert]::ToHexString(
$sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload))
).ToLowerInvariant()
}
finally {
$sha256.Dispose()
}
return [pscustomobject]@{
Hash = $profileHash
ScriptVersion = $scriptVersion
PnPPowerShellVersion = $module.Version.ToString()
CommonScriptHash = $scriptHashes['PageTransformation.Common.ps1']
RepresentativeScriptHash = $scriptHashes['Convert-RepresentativePages.ps1']
SelectedScriptHash = $scriptHashes['Convert-SelectedPages.ps1']
WebPartMappingHash = 'embedded-default'
}
}
# Validate unattended inputs before result files are reserved or page processing begins.
function Test-PageWaveAuthentication {
param(
[Parameter(Mandatory = $true)]
[ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')]
[string]$AuthenticationMode,
[Parameter(Mandatory = $false)]
[string]$Tenant,
[Parameter(Mandatory = $false)]
[string]$Thumbprint,
[Parameter(Mandatory = $false)]
[string]$CertificatePath
)
switch ($AuthenticationMode) {
'DeviceLogin' {
if ([string]::IsNullOrWhiteSpace($Tenant)) {
throw "Tenant is required for DeviceLogin authentication."
}
}
'CertificateThumbprint' {
if ([string]::IsNullOrWhiteSpace($Tenant) -or
[string]::IsNullOrWhiteSpace($Thumbprint)) {
throw "Tenant and Thumbprint are required for CertificateThumbprint authentication."
}
}
'CertificateFile' {
if ([string]::IsNullOrWhiteSpace($Tenant) -or
[string]::IsNullOrWhiteSpace($CertificatePath)) {
throw "Tenant and CertificatePath are required for CertificateFile authentication."
}
if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) {
throw "Certificate file not found: $CertificatePath"
}
}
}
}
# Reserve the output path before the first SharePoint write. The returned callback
# appends one durable result row after every page attempt.
function New-PageWaveResultWriter {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $false)]
[switch]$Force
)
$fullPath = if ([IO.Path]::IsPathRooted($Path)) {
[IO.Path]::GetFullPath($Path)
}
else {
[IO.Path]::GetFullPath((Join-Path (Get-Location).Path $Path))
}
$folder = Split-Path -Parent $fullPath
if ([string]::IsNullOrWhiteSpace($folder)) {
$folder = (Get-Location).Path
}
New-Item -ItemType Directory -Path $folder -Force -WhatIf:$false | Out-Null
if ((Test-Path -LiteralPath $fullPath) -and -not $Force) {
throw "Result file already exists. Use -Force to replace it: $fullPath"
}
$fileMode = if ($Force) { [IO.FileMode]::Create } else { [IO.FileMode]::CreateNew }
$stream = [IO.File]::Open($fullPath, $fileMode, [IO.FileAccess]::Write, [IO.FileShare]::None)
$stream.Dispose()
$state = [pscustomobject]@{ First = $true }
$writer = {
param($Row)
if ($state.First) {
$Row | Export-Csv -LiteralPath $fullPath -NoTypeInformation -Force -ErrorAction Stop -WhatIf:$false
$state.First = $false
}
else {
$Row | Export-Csv -LiteralPath $fullPath -NoTypeInformation -Append -ErrorAction Stop -WhatIf:$false
}
}.GetNewClosure()
return [pscustomobject]@{
Path = $fullPath
Write = $writer
}
}
# Build a connection without logging secrets. Connections are cached by web by the
# calling engine.
function New-PageWaveConnection {
param(
[Parameter(Mandatory = $true)]
[string]$Url,
[Parameter(Mandatory = $true)]
[string]$ClientId,
[Parameter(Mandatory = $true)]
[ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')]
[string]$AuthenticationMode,
[Parameter(Mandatory = $false)]
[string]$Tenant,
[Parameter(Mandatory = $false)]
[string]$Thumbprint,
[Parameter(Mandatory = $false)]
[string]$CertificatePath,
[Parameter(Mandatory = $false)]
[securestring]$CertificatePassword,
[Parameter(Mandatory = $false)]
[string]$AzureEnvironment = 'Production'
)
$parameters = @{
Url = $Url
ClientId = $ClientId
ReturnConnection = $true
AzureEnvironment = $AzureEnvironment
}
switch ($AuthenticationMode) {
'Interactive' {
$parameters.Interactive = $true
}
'DeviceLogin' {
if ([string]::IsNullOrWhiteSpace($Tenant)) {
throw "Tenant is required for DeviceLogin authentication."
}
$parameters.DeviceLogin = $true
$parameters.Tenant = $Tenant
}
'CertificateThumbprint' {
if ([string]::IsNullOrWhiteSpace($Tenant) -or
[string]::IsNullOrWhiteSpace($Thumbprint)) {
throw "Tenant and Thumbprint are required for CertificateThumbprint authentication."
}
$parameters.Tenant = $Tenant
$parameters.Thumbprint = $Thumbprint
}
'CertificateFile' {
if ([string]::IsNullOrWhiteSpace($Tenant) -or
[string]::IsNullOrWhiteSpace($CertificatePath)) {
throw "Tenant and CertificatePath are required for CertificateFile authentication."
}
if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) {
throw "Certificate file not found: $CertificatePath"
}
$parameters.Tenant = $Tenant
$parameters.CertificatePath = (Resolve-Path -LiteralPath $CertificatePath).Path
if ($CertificatePassword) {
$parameters.CertificatePassword = $CertificatePassword
}
}
}
Connect-PnPOnline @parameters
}
# Validate the whole manifest before any page is transformed. This prevents a malformed
# later row from stopping a wave after earlier pages have already been created.
function Test-AssessmentPageWaveRows {
param(
[Parameter(Mandatory = $true)]
[object[]]$Rows
)
$requiredProperties = @(
'ScanId',
'SiteUrl',
'WebUrl',
'PageUrl',
'PageType',
'ListUrl',
'ListId',
'ModifiedAt',
'AssessmentTimeZoneId',
'HomePage',
'WebPartCount',
'MappingPercentage',
'UnmappedWebParts'
)
$requiredValues = @(
'ScanId',
'SiteUrl',
'WebUrl',
'PageUrl',
'PageType',
'ListUrl',
'ListId',
'ModifiedAt',
'AssessmentTimeZoneId'
)
$keys = @{}
foreach ($row in $Rows) {
foreach ($requiredProperty in $requiredProperties) {
if ($row.PSObject.Properties.Name -notcontains $requiredProperty) {
throw "$requiredProperty is required for every manifest row."
}
}
foreach ($requiredValue in $requiredValues) {
if ([string]::IsNullOrWhiteSpace($row.$requiredValue)) {
throw "$requiredValue can't be empty."
}
}
$listId = [guid]::Empty
if (-not [guid]::TryParse([string]$row.ListId, [ref]$listId)) {
throw "ListId must be a GUID: $($row.ListId)"
}
$modifiedAt = [datetime]::MinValue
if (-not [datetime]::TryParseExact(
[string]$row.ModifiedAt,
'MM/dd/yyyy HH:mm:ss',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::None,
[ref]$modifiedAt
)) {
throw "ModifiedAt must use MM/dd/yyyy HH:mm:ss: $($row.ModifiedAt)"
}
try {
[TimeZoneInfo]::FindSystemTimeZoneById([string]$row.AssessmentTimeZoneId) | Out-Null
}
catch {
throw "AssessmentTimeZoneId isn't available on this machine: $($row.AssessmentTimeZoneId)"
}
ConvertTo-PageWaveBoolean -Value $row.HomePage -Name "HomePage for $($row.PageUrl)" | Out-Null
ConvertTo-PageWaveInteger -Value $row.WebPartCount -Name "WebPartCount for $($row.PageUrl)" | Out-Null
ConvertTo-PageWaveInteger `
-Value $row.MappingPercentage `
-Name "MappingPercentage for $($row.PageUrl)" `
-Maximum 100 | Out-Null
$key = Get-PageWaveKey -Row $row
if ($keys.ContainsKey($key)) {
throw "Duplicate manifest row: $($row.PageUrl)"
}
$keys[$key] = $true
}
}
# Shared execution engine used by both public entry scripts.
function Invoke-AssessmentPageWave {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[object[]]$Rows,
[Parameter(Mandatory = $true)]
[string]$ClientId,
[Parameter(Mandatory = $true)]
[ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')]
[string]$AuthenticationMode,
[Parameter(Mandatory = $true)]
[scriptblock]$ShouldProcessCallback,
[Parameter(Mandatory = $true)]
[scriptblock]$ResultWriter,
[Parameter(Mandatory = $true)]
[object]$TransformationProfile,
[Parameter(Mandatory = $true)]
[string]$IncludedManifestHash,
[Parameter(Mandatory = $false)]
[string]$Tenant,
[Parameter(Mandatory = $false)]
[string]$Thumbprint,
[Parameter(Mandatory = $false)]
[string]$CertificatePath,
[Parameter(Mandatory = $false)]
[securestring]$CertificatePassword,
[Parameter(Mandatory = $false)]
[string]$AzureEnvironment = 'Production',
[Parameter(Mandatory = $false)]
[string]$LogFolder = (Join-Path (Get-Location) 'page-transformation-logs'),
[Parameter(Mandatory = $false)]
[switch]$PreflightOnly
)
if ($PSVersionTable.PSVersion -lt [version]'7.4.0') {
throw "PnP PowerShell requires PowerShell 7.4.0 or later."
}
# Repeat the entry-point validation because this function can also be invoked directly.
Test-PageWaveAuthentication `
-AuthenticationMode $AuthenticationMode `
-Tenant $Tenant `
-Thumbprint $Thumbprint `
-CertificatePath $CertificatePath
Test-AssessmentPageWaveRows -Rows $Rows
Import-Module PnP.PowerShell -ErrorAction Stop
# WhatIf doesn't create a SharePoint connection or log folder, but still writes a
# preview result CSV through the supplied ResultWriter.
if (Test-Path -LiteralPath $LogFolder -PathType Container) {
$LogFolder = (Resolve-Path -LiteralPath $LogFolder).Path
}
else {
$LogFolder = if ([IO.Path]::IsPathRooted($LogFolder)) {
[IO.Path]::GetFullPath($LogFolder)
}
else {
[IO.Path]::GetFullPath((Join-Path (Get-Location).Path $LogFolder))
}
if (-not $WhatIfPreference) {
New-Item -ItemType Directory -Path $LogFolder -Force | Out-Null
}
}
$connections = @{}
$connectionErrors = @{}
$webs = @{}
$siteTimeZones = @{}
$results = [Collections.Generic.List[object]]::new()
foreach ($row in $Rows) {
$startedAt = Get-Date
$sourceWebUrl = $null
$plannedAction = $null
$plannedTargetPageUrl = $null
$targetExists = 'NotChecked'
$targetPageUrl = $null
$status = 'Failed'
$errorMessage = $null
$sourceItemId = $null
$sourceUniqueId = $null
$observedListId = $null
$observedModifiedAt = $null
$approvedModifiedUtc = $null
$observedModifiedUtc = $null
$siteTimeZoneId = $null
$siteTimeZoneDescription = $null
$logPath = $null
$logFilesBefore = @()
$conversionAttempted = $false
try {
# Reject page categories that require a separately reviewed migration path.
if ($row.PageType -notin @('WikiPage', 'WebPartPage')) {
throw "Page type '$($row.PageType)' isn't supported by these Wiki/Web Part wave scripts."
}
if (ConvertTo-PageWaveBoolean -Value $row.HomePage -Name "HomePage for $($row.PageUrl)") {
throw "Home pages require a separate approved migration path: $($row.PageUrl)"
}
$webPartCount = ConvertTo-PageWaveInteger `
-Value $row.WebPartCount `
-Name "WebPartCount for $($row.PageUrl)"
$mappingPercentage = ConvertTo-PageWaveInteger `
-Value $row.MappingPercentage `
-Name "MappingPercentage for $($row.PageUrl)" `
-Maximum 100
if ($webPartCount -eq 0) {
throw "Zero-part pages are excluded from these batch scripts: $($row.PageUrl)"
}
if ($mappingPercentage -ne 100 -or
-not [string]::IsNullOrWhiteSpace($row.UnmappedWebParts)) {
throw "These batch scripts require Assessment readiness of 100 with no unmapped Web Parts: $($row.PageUrl)"
}
$sourceWebUrl = Get-PageWaveSourceUrl -Row $row
$sourceDirectory = ([IO.Path]::GetDirectoryName($row.PageUrl) -replace '\\', '/').TrimEnd('/')
$sourceFileName = [IO.Path]::GetFileName($row.PageUrl)
$plannedTargetPageUrl = "$sourceDirectory/Migrated_$sourceFileName"
$plannedAction = if ($PreflightOnly) {
"Validate the source and target plan for $($row.PageUrl)"
}
else {
"Create a draft modern page from $($row.PageUrl)"
}
# Ask permission before authentication or any tenant read. -WhatIf exits here.
if (-not (& $ShouldProcessCallback $sourceWebUrl $plannedAction)) {
$status = 'Skipped'
$errorMessage = "The operation wasn't approved or was run with -WhatIf."
continue
}
# Reuse successful connections and avoid repeating a known authentication
# failure for every remaining page in the same web.
if ($connectionErrors.ContainsKey($sourceWebUrl)) {
throw $connectionErrors[$sourceWebUrl]
}
if (-not $connections.ContainsKey($sourceWebUrl)) {
try {
$connections[$sourceWebUrl] = New-PageWaveConnection `
-Url $sourceWebUrl `
-ClientId $ClientId `
-AuthenticationMode $AuthenticationMode `
-Tenant $Tenant `
-Thumbprint $Thumbprint `
-CertificatePath $CertificatePath `
-CertificatePassword $CertificatePassword `
-AzureEnvironment $AzureEnvironment
}
catch {
$connectionErrors[$sourceWebUrl] = $_.Exception.Message
throw
}
}
$connection = $connections[$sourceWebUrl]
# Assessment HomePage can become stale without changing the page itself.
# Re-read the current welcome page immediately before conversion.
if (-not $webs.ContainsKey($sourceWebUrl)) {
$webs[$sourceWebUrl] = Get-PnPWeb `
-Includes WelcomePage, ServerRelativeUrl, RegionalSettings `
-Connection $connection
$siteTimeZones[$sourceWebUrl] = Get-PnPProperty `
-ClientObject $webs[$sourceWebUrl].RegionalSettings `
-Property TimeZone `
-Connection $connection
Get-PnPProperty `
-ClientObject $siteTimeZones[$sourceWebUrl] `
-Property Id, Description `
-Connection $connection | Out-Null
}
$web = $webs[$sourceWebUrl]
$siteTimeZone = $siteTimeZones[$sourceWebUrl]
$siteTimeZoneId = $siteTimeZone.Id
$siteTimeZoneDescription = $siteTimeZone.Description
if (-not [string]::IsNullOrWhiteSpace($web.WelcomePage)) {
$currentHomePageUrl = (
'{0}/{1}' -f
$web.ServerRelativeUrl.TrimEnd('/'),
$web.WelcomePage.TrimStart('/')
).Replace('//', '/')
if ($currentHomePageUrl.Equals($row.PageUrl, [StringComparison]::OrdinalIgnoreCase)) {
throw "The page is currently the web home page and is excluded: $($row.PageUrl)"
}
}
$libraryPath = $row.ListUrl.TrimEnd('/')
if (-not $row.PageUrl.StartsWith("$libraryPath/", [StringComparison]::OrdinalIgnoreCase)) {
throw "PageUrl isn't under ListUrl: $($row.PageUrl)"
}
# Resolve the assessed list by GUID, then verify its URL. This prevents a
# reused item ID in another library from being transformed.
$sourceList = Get-PnPList `
-Identity ([guid]$row.ListId) `
-ThrowExceptionIfListNotFound `
-Connection $connection
$observedListId = $sourceList.Id.ToString()
if ($sourceList.Id -ne [guid]$row.ListId) {
throw "ListId changed for $($row.PageUrl). Rerun Assessment before transforming this page."
}
Get-PnPProperty -ClientObject $sourceList -Property RootFolder -Connection $connection | Out-Null
Get-PnPProperty `
-ClientObject $sourceList.RootFolder `
-Property ServerRelativeUrl `
-Connection $connection | Out-Null
if (-not $sourceList.RootFolder.ServerRelativeUrl.Equals(
$libraryPath,
[StringComparison]::OrdinalIgnoreCase
)) {
throw "ListUrl changed for $($row.PageUrl). Rerun Assessment before transforming this page."
}
# Resolve the exact approved file and prove that it still belongs to the
# assessed list.
$sourceItem = Get-PnPFile `
-Url $row.PageUrl `
-AsListItem `
-ThrowExceptionIfFileNotFound `
-Connection $connection
$sourceItemId = $sourceItem.Id
Get-PnPProperty -ClientObject $sourceItem -Property ParentList -Connection $connection | Out-Null
Get-PnPProperty -ClientObject $sourceItem.ParentList -Property Id -Connection $connection | Out-Null
if ($sourceItem.ParentList.Id -ne $sourceList.Id) {
throw "The approved file no longer belongs to the assessed list: $($row.PageUrl)"
}
# Assessment exports runner-local time without an offset, while PnP returns
# site-local time. Normalize both through their recorded time zones before
# deciding whether the source changed.
$observedFileRef = [string]$sourceItem.FieldValues['FileRef']
if (-not $observedFileRef.Equals($row.PageUrl, [StringComparison]::OrdinalIgnoreCase)) {
throw "The current file URL doesn't match the approved PageUrl: $($row.PageUrl)"
}
$sourceUniqueId = [string]$sourceItem.FieldValues['UniqueId']
$observedModified = [datetime]$sourceItem.FieldValues['Modified']
$observedModifiedAt = $observedModified.ToString(
'MM/dd/yyyy HH:mm:ss',
[Globalization.CultureInfo]::InvariantCulture
)
$assessmentLocal = [datetime]::ParseExact(
[string]$row.ModifiedAt,
'MM/dd/yyyy HH:mm:ss',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::None
)
$assessmentTimeZone = [TimeZoneInfo]::FindSystemTimeZoneById(
[string]$row.AssessmentTimeZoneId
)
$approvedModifiedUtc = [TimeZoneInfo]::ConvertTimeToUtc(
[datetime]::SpecifyKind($assessmentLocal, [DateTimeKind]::Unspecified),
$assessmentTimeZone
)
$siteUtcResult = $siteTimeZone.LocalTimeToUTC($observedModified)
Invoke-PnPQuery -Connection $connection
$observedModifiedUtc = [datetime]$siteUtcResult.Value
if ([Math]::Abs(($approvedModifiedUtc - $observedModifiedUtc).TotalSeconds) -ge 1) {
throw "The source was modified after Assessment. Rerun Assessment before transforming this page: $($row.PageUrl)"
}
# Batch scripts deliberately exclude unique ACLs. Silent permission-copy
# failures could otherwise expose the draft through broader inheritance.
$hasUniquePermissions = Get-PnPProperty `
-ClientObject $sourceItem `
-Property HasUniqueRoleAssignments `
-Connection $connection
if ($hasUniquePermissions) {
throw "The source has unique permissions and is excluded from these batch scripts: $($row.PageUrl)"
}
# Keep source and target in the same default SitePages permission scope.
$webServerRelativeUrl = $web.ServerRelativeUrl.TrimEnd('/')
$libraryRelativeUrl = if ([string]::IsNullOrWhiteSpace($webServerRelativeUrl)) {
$sourceList.RootFolder.ServerRelativeUrl.TrimStart('/')
}
else {
$sourceList.RootFolder.ServerRelativeUrl.Substring($webServerRelativeUrl.Length).TrimStart('/')
}
if (-not $libraryRelativeUrl.Equals('SitePages', [StringComparison]::OrdinalIgnoreCase)) {
throw "These batch scripts only support pages in the default SitePages library: $($row.PageUrl)"
}
$existingTarget = Get-PnPFile `
-Url $plannedTargetPageUrl `
-AsListItem `
-Connection $connection
$targetExists = $null -ne $existingTarget
if ($targetExists) {
throw "The planned target already exists and must be reviewed instead of overwritten: $plannedTargetPageUrl"
}
if ($PreflightOnly) {
$status = 'PreflightPassed'
continue
}
# Always preserve the source, create a draft, skip ACL copying, and log.
# Overwrite and TakeSourcePageName are intentionally unavailable here.
$parameters = @{
Identity = $sourceItem.Id
Connection = $connection
DontPublish = $true
LogType = 'File'
LogFolder = $LogFolder
LogVerbose = $true
SkipItemLevelPermissionCopyToClientSidePage = $true
}
# ConvertTo-PnPPage returns the created server-relative URL. A missing URL is
# treated as a failure even if the cmdlet didn't throw.
$logFilesBefore = @(
Get-ChildItem -LiteralPath $LogFolder -File -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
)
$conversionAttempted = $true
$conversionOutput = @(ConvertTo-PnPPage @parameters)
$targetPageUrl = $conversionOutput |
Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Last 1
if ([string]::IsNullOrWhiteSpace($targetPageUrl)) {
throw "ConvertTo-PnPPage didn't return a target page URL."
}
$status = 'Created'
}
catch {
$errorMessage = $_.Exception.Message
}
finally {
if ($conversionAttempted) {
$logPath = Get-ChildItem -LiteralPath $LogFolder -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notin $logFilesBefore } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1 -ExpandProperty FullName
}
# Persist success, failure, skip, observed source identity, and validation
# profile before moving to the next page.
$resultRow = [pscustomobject][ordered]@{
ManifestRowHash = Get-PageWaveManifestHash -Row $row
CandidateRowHash = Get-PageWaveCandidateHash -Row $row
IncludedManifestHash = $IncludedManifestHash
TransformationProfileHash = $TransformationProfile.Hash
ScriptVersion = $TransformationProfile.ScriptVersion
PnPPowerShellVersion = $TransformationProfile.PnPPowerShellVersion
CommonScriptHash = $TransformationProfile.CommonScriptHash
RepresentativeScriptHash = $TransformationProfile.RepresentativeScriptHash
SelectedScriptHash = $TransformationProfile.SelectedScriptHash
WebPartMappingHash = $TransformationProfile.WebPartMappingHash
ScanId = Get-PageWaveValue -Row $row -Name 'ScanId'
SiteUrl = Get-PageWaveValue -Row $row -Name 'SiteUrl'
WebUrl = Get-PageWaveValue -Row $row -Name 'WebUrl'
PageUrl = Get-PageWaveValue -Row $row -Name 'PageUrl'
PageType = Get-PageWaveValue -Row $row -Name 'PageType'
Layout = Get-PageWaveValue -Row $row -Name 'Layout'
PatternKey = Get-PageWaveValue -Row $row -Name 'PatternKey'
ExpectedVisibleContent = Get-PageWaveValue -Row $row -Name 'ExpectedVisibleContent'
ValidationOwner = Get-PageWaveValue -Row $row -Name 'ValidationOwner'
SourceWebUrl = $sourceWebUrl
SourceItemId = $sourceItemId
SourceUniqueId = $sourceUniqueId
ObservedListId = $observedListId
ApprovedModifiedAt = Get-PageWaveValue -Row $row -Name 'ModifiedAt'
ObservedModifiedAt = $observedModifiedAt
AssessmentTimeZoneId = Get-PageWaveValue -Row $row -Name 'AssessmentTimeZoneId'
ApprovedModifiedUtc = if ($approvedModifiedUtc) { $approvedModifiedUtc.ToString('o') } else { '' }
ObservedModifiedUtc = if ($observedModifiedUtc) { $observedModifiedUtc.ToString('o') } else { '' }
SiteTimeZoneId = $siteTimeZoneId
SiteTimeZoneDescription = $siteTimeZoneDescription
MappingPercentage = Get-PageWaveValue -Row $row -Name 'MappingPercentage'
UnmappedWebParts = Get-PageWaveValue -Row $row -Name 'UnmappedWebParts'
PlannedAction = $plannedAction
PlannedTargetPageUrl = $plannedTargetPageUrl
TargetExists = $targetExists
TargetPageUrl = $targetPageUrl
TransformationStatus = $status
ValidationStatus = if ($status -eq 'Created') { 'Pending' } else { '' }
LogFolder = $LogFolder
LogPath = $logPath
ValidationNotes = ''
ValidatedBy = ''
ValidatedAt = ''
StartedAt = $startedAt.ToString('o')
FinishedAt = (Get-Date).ToString('o')
Error = $errorMessage
}
$results.Add($resultRow)
& $ResultWriter $resultRow
}
}
return $results
}
Convert-RepresentativePages.ps1
<#
.SYNOPSIS
Transforms all approved representative Wiki and Web Part pages from an Assessment manifest.
.DESCRIPTION
Requires one or more rows marked Selected=true for every IncludePattern=true PatternKey.
Generated pages remain drafts and source pages aren't renamed or overwritten.
.EXAMPLE
.\Convert-RepresentativePages.ps1 `
-ManifestPath .\representative-page-groups.csv `
-ClientId "<client-id>" `
-AuthenticationMode Interactive `
-Confirm
.EXAMPLE
.\Convert-RepresentativePages.ps1 `
-ManifestPath .\representative-page-groups.csv `
-ClientId "<client-id>" `
-AuthenticationMode CertificateThumbprint `
-Tenant "contoso.onmicrosoft.com" `
-Thumbprint "<certificate-thumbprint>" `
-Confirm:$false
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory = $true)]
[string]$ManifestPath,
[Parameter(Mandatory = $true)]
[string]$ClientId,
[Parameter(Mandatory = $false)]
[ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')]
[string]$AuthenticationMode = 'Interactive',
[Parameter(Mandatory = $false)]
[string]$Tenant,
[Parameter(Mandatory = $false)]
[string]$Thumbprint,
[Parameter(Mandatory = $false)]
[string]$CertificatePath,
[Parameter(Mandatory = $false)]
[securestring]$CertificatePassword,
[Parameter(Mandatory = $false)]
[string]$AzureEnvironment = 'Production',
[Parameter(Mandatory = $false)]
[string]$ResultPath,
[Parameter(Mandatory = $false)]
[string]$LogFolder,
[Parameter(Mandatory = $false)]
[switch]$PreflightOnly,
[Parameter(Mandatory = $false)]
[switch]$Force
)
Set-StrictMode -Version Latest
# Keep shared authentication, manifest validation, and conversion behavior identical
# between the representative and expanded-wave entry points.
. "$PSScriptRoot\PageTransformation.Common.ps1"
# Resolve the manifest once so result and log defaults stay next to the reviewed input.
if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) {
throw "Manifest not found: $ManifestPath"
}
$ManifestPath = (Resolve-Path -LiteralPath $ManifestPath).Path
$manifest = @(Import-Csv -LiteralPath $ManifestPath)
if ($manifest.Count -eq 0) {
throw "The representative manifest is empty."
}
foreach ($column in @('PatternKey', 'IncludePattern', 'Selected', 'ExpectedVisibleContent', 'ValidationOwner')) {
if ($manifest[0].PSObject.Properties.Name -notcontains $column) {
throw "The representative manifest requires a $column column."
}
}
# IncludePattern defines the patterns in the planned migration. Selected identifies
# the concrete pages that must prove those patterns before the wave can expand.
$includedRows = @(
$manifest |
Where-Object {
ConvertTo-PageWaveBoolean `
-Value $_.IncludePattern `
-Name "IncludePattern for $($_.PageUrl)"
}
)
$selectedRows = @(
$includedRows |
Where-Object {
ConvertTo-PageWaveBoolean `
-Value $_.Selected `
-Name "Selected for $($_.PageUrl)"
}
)
if ($includedRows.Count -eq 0) {
throw "No patterns are marked IncludePattern=true."
}
if ($selectedRows.Count -eq 0) {
throw "No pages are marked Selected=true."
}
# A representative run is incomplete when any included pattern lacks a selected page.
$includedPatterns = @($includedRows.PatternKey | Sort-Object -Unique)
$selectedPatterns = @($selectedRows.PatternKey | Sort-Object -Unique)
$missingPatterns = @($includedPatterns | Where-Object { $_ -notin $selectedPatterns })
if ($missingPatterns.Count -gt 0) {
throw "Select at least one representative page for each included pattern: $($missingPatterns -join ', ')"
}
foreach ($row in $selectedRows) {
if ([string]::IsNullOrWhiteSpace($row.PatternKey)) {
throw "Every selected representative page needs PatternKey: $($row.PageUrl)"
}
if ([string]::IsNullOrWhiteSpace($row.ExpectedVisibleContent) -or
[string]::IsNullOrWhiteSpace($row.ValidationOwner)) {
throw "Every selected representative page needs ExpectedVisibleContent and ValidationOwner: $($row.PageUrl)"
}
}
# Preview runs use a separate result name. Existing evidence is never replaced unless
# the caller explicitly supplies -Force.
$manifestFolder = Split-Path -Parent $ManifestPath
if ([string]::IsNullOrWhiteSpace($ResultPath)) {
$resultName = if ($WhatIfPreference) {
'representative-page-preview.csv'
}
elseif ($PreflightOnly) {
'representative-page-preflight.csv'
}
else {
'representative-page-results.csv'
}
$ResultPath = Join-Path $manifestFolder $resultName
}
if ([string]::IsNullOrWhiteSpace($LogFolder)) {
$LogFolder = Join-Path $manifestFolder 'representative-page-logs'
}
# Capture the entry script's High-impact ShouldProcess context. The shared engine calls
# this closure before it authenticates or reads SharePoint.
$entryCmdlet = $PSCmdlet
$shouldProcess = {
param($target, $action)
$entryCmdlet.ShouldProcess($target, $action)
}.GetNewClosure()
Test-PageWaveAuthentication `
-AuthenticationMode $AuthenticationMode `
-Tenant $Tenant `
-Thumbprint $Thumbprint `
-CertificatePath $CertificatePath
# Validate every included row before sealing the complete migration scope.
Test-AssessmentPageWaveRows -Rows $includedRows
# The profile binds validation to the exact script version, PnP.PowerShell version,
# and exact page wave scripts used during this representative run.
$transformationProfile = Get-PageWaveTransformationProfile
$includedManifestHash = Get-PageWaveIncludedManifestHash -Rows $includedRows
# Reserve the result file before the first SharePoint write, then append every result
# immediately so an interrupted wave retains completed evidence.
$resultWriter = New-PageWaveResultWriter -Path $ResultPath -Force:$Force
$ResultPath = $resultWriter.Path
$results = Invoke-AssessmentPageWave `
-Rows $selectedRows `
-ClientId $ClientId `
-AuthenticationMode $AuthenticationMode `
-ShouldProcessCallback $shouldProcess `
-ResultWriter $resultWriter.Write `
-TransformationProfile $transformationProfile `
-IncludedManifestHash $includedManifestHash `
-Tenant $Tenant `
-Thumbprint $Thumbprint `
-CertificatePath $CertificatePath `
-CertificatePassword $CertificatePassword `
-AzureEnvironment $AzureEnvironment `
-LogFolder $LogFolder `
-PreflightOnly:$PreflightOnly
# The detailed per-page failures are already durable in the result CSV.
$failed = @($results | Where-Object TransformationStatus -eq 'Failed')
if ($failed.Count -gt 0) {
throw "$($failed.Count) representative page transformations failed. Review $ResultPath."
}
Write-Host "Representative page results: $ResultPath" -ForegroundColor Green
Convert-SelectedPages.ps1
<#
.SYNOPSIS
Transforms all user-selected Wiki and Web Part pages after representative validation passes.
.DESCRIPTION
Refuses to expand the wave unless every representative result has TransformationStatus=Created
and ValidationStatus=Passed. Generated pages remain drafts.
.EXAMPLE
.\Convert-SelectedPages.ps1 `
-PagesPath .\approved-pages.csv `
-RepresentativeManifestPath .\representative-page-groups.csv `
-RepresentativeResultsPath .\representative-page-results.csv `
-ClientId "<client-id>" `
-AuthenticationMode Interactive `
-Confirm
.EXAMPLE
.\Convert-SelectedPages.ps1 `
-PagesPath .\approved-pages.csv `
-RepresentativeManifestPath .\representative-page-groups.csv `
-RepresentativeResultsPath .\representative-page-results.csv `
-ClientId "<client-id>" `
-AuthenticationMode CertificateFile `
-Tenant "contoso.onmicrosoft.com" `
-CertificatePath .\page-transformation.pfx `
-CertificatePassword (Read-Host "Certificate password" -AsSecureString) `
-Confirm:$false
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory = $true)]
[string]$PagesPath,
[Parameter(Mandatory = $true)]
[string]$RepresentativeResultsPath,
[Parameter(Mandatory = $true)]
[string]$RepresentativeManifestPath,
[Parameter(Mandatory = $true)]
[string]$ClientId,
[Parameter(Mandatory = $false)]
[ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')]
[string]$AuthenticationMode = 'Interactive',
[Parameter(Mandatory = $false)]
[string]$Tenant,
[Parameter(Mandatory = $false)]
[string]$Thumbprint,
[Parameter(Mandatory = $false)]
[string]$CertificatePath,
[Parameter(Mandatory = $false)]
[securestring]$CertificatePassword,
[Parameter(Mandatory = $false)]
[string]$AzureEnvironment = 'Production',
[Parameter(Mandatory = $false)]
[string]$ResultPath,
[Parameter(Mandatory = $false)]
[string]$LogFolder,
[Parameter(Mandatory = $false)]
[switch]$PreflightOnly,
[Parameter(Mandatory = $false)]
[switch]$Force
)
Set-StrictMode -Version Latest
# Use the same source validation and conversion engine as the representative run.
. "$PSScriptRoot\PageTransformation.Common.ps1"
# Expansion always requires all three artifacts: the pages to add, the original
# representative manifest, and the manually validated representative results.
foreach ($path in @($PagesPath, $RepresentativeResultsPath, $RepresentativeManifestPath)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Input file not found: $path"
}
}
$PagesPath = (Resolve-Path -LiteralPath $PagesPath).Path
$RepresentativeResultsPath = (Resolve-Path -LiteralPath $RepresentativeResultsPath).Path
$RepresentativeManifestPath = (Resolve-Path -LiteralPath $RepresentativeManifestPath).Path
$representativeManifest = @(Import-Csv -LiteralPath $RepresentativeManifestPath)
$representativeResults = @(Import-Csv -LiteralPath $RepresentativeResultsPath)
if ($representativeManifest.Count -eq 0 -or $representativeResults.Count -eq 0) {
throw "The representative manifest and results files must both contain rows."
}
# Rebuild the complete representative scope from the current manifest. A newly included
# pattern cannot be ignored simply because it has no result yet.
$includedRepresentativeRows = @(
$representativeManifest |
Where-Object {
ConvertTo-PageWaveBoolean -Value $_.IncludePattern -Name "IncludePattern for $($_.PageUrl)"
}
)
$representativeRows = @(
$includedRepresentativeRows |
Where-Object {
ConvertTo-PageWaveBoolean -Value $_.Selected -Name "Selected for $($_.PageUrl)"
}
)
if ($representativeRows.Count -eq 0) {
throw "The representative manifest has no included selected rows."
}
$transformationProfile = Get-PageWaveTransformationProfile
$includedManifestHash = Get-PageWaveIncludedManifestHash -Rows $includedRepresentativeRows
$includedPatterns = @($includedRepresentativeRows.PatternKey | Sort-Object -Unique)
$selectedPatterns = @($representativeRows.PatternKey | Sort-Object -Unique)
$patternsWithoutRepresentative = @(
$includedPatterns |
Where-Object { $_ -notin $selectedPatterns }
)
if ($patternsWithoutRepresentative.Count -gt 0) {
throw "Every included pattern needs a selected representative: $($patternsWithoutRepresentative -join ', ')"
}
# Normalize manifest and result rows by Assessment identity, rejecting duplicates before
# any target page can be created.
$representativeRowsByKey = @{}
foreach ($row in $representativeRows) {
$key = Get-PageWaveKey -Row $row
if ($representativeRowsByKey.ContainsKey($key)) {
throw "Duplicate representative manifest row: $($row.PageUrl)"
}
$representativeRowsByKey[$key] = $row
}
$representativeResultsByKey = @{}
foreach ($result in $representativeResults) {
$key = Get-PageWaveKey -Row $result
if ($representativeResultsByKey.ContainsKey($key)) {
throw "Duplicate representative result row: $($result.PageUrl)"
}
$representativeResultsByKey[$key] = $result
}
if ($representativeRowsByKey.Count -ne $representativeResultsByKey.Count) {
throw "Representative manifest and result row counts don't match."
}
# Bind each approval to both the complete manifest row and the transformation profile.
# This blocks stale results and prevents a different PnP or mapping configuration from
# inheriting an earlier validation decision.
$passedPatterns = @{}
foreach ($key in $representativeRowsByKey.Keys) {
if (-not $representativeResultsByKey.ContainsKey($key)) {
throw "Representative result is missing for $($representativeRowsByKey[$key].PageUrl)."
}
$manifestRow = $representativeRowsByKey[$key]
$resultRow = $representativeResultsByKey[$key]
if ($resultRow.ManifestRowHash -ne (Get-PageWaveManifestHash -Row $manifestRow)) {
throw "Representative result doesn't match the current manifest row: $($manifestRow.PageUrl)"
}
if ($resultRow.TransformationProfileHash -ne $transformationProfile.Hash) {
throw "Representative results used a different PnP version or script profile: $($manifestRow.PageUrl)"
}
if ($resultRow.IncludedManifestHash -ne $includedManifestHash) {
throw "Representative results don't match the complete included manifest."
}
if ($resultRow.TransformationStatus -ne 'Created' -or
$resultRow.ValidationStatus -ne 'Passed') {
throw "Every representative page must be Created and validated as Passed before expanding the wave: $($manifestRow.PageUrl)"
}
foreach ($validationField in @('ValidationNotes', 'ValidatedBy', 'ValidatedAt')) {
if ([string]::IsNullOrWhiteSpace($resultRow.$validationField)) {
throw "Passed representative result requires $validationField`: $($manifestRow.PageUrl)"
}
}
$validatedAt = [datetimeoffset]::MinValue
if (-not [datetimeoffset]::TryParse([string]$resultRow.ValidatedAt, [ref]$validatedAt)) {
throw "ValidatedAt must be an ISO-8601 timestamp: $($manifestRow.PageUrl)"
}
if ([string]::IsNullOrWhiteSpace($resultRow.LogPath) -or
-not (Test-Path -LiteralPath $resultRow.LogPath -PathType Leaf)) {
throw "Passed representative result requires a retained LogPath: $($manifestRow.PageUrl)"
}
$passedPatterns[$manifestRow.PatternKey] = $true
}
$pages = @(Import-Csv -LiteralPath $PagesPath)
if ($pages.Count -eq 0) {
throw "The approved pages file is empty."
}
foreach ($column in @('ExpectedVisibleContent', 'ValidationOwner')) {
if ($pages[0].PSObject.Properties.Name -notcontains $column) {
throw "The approved pages file requires a $column column."
}
}
foreach ($row in $pages) {
if ([string]::IsNullOrWhiteSpace($row.ExpectedVisibleContent) -or
[string]::IsNullOrWhiteSpace($row.ValidationOwner)) {
throw "Every approved page needs ExpectedVisibleContent and ValidationOwner: $($row.PageUrl)"
}
if ($row.PSObject.Properties.Name -notcontains 'PatternKey' -or
[string]::IsNullOrWhiteSpace($row.PatternKey)) {
throw "Every approved page requires PatternKey: $($row.PageUrl)"
}
}
# Bind every approved page to the original included manifest. The caller cannot assign
# a passed PatternKey to an unknown or altered page.
$includedRowsByKey = @{}
foreach ($row in $includedRepresentativeRows) {
$key = Get-PageWaveKey -Row $row
if ($includedRowsByKey.ContainsKey($key)) {
throw "Duplicate included manifest row: $($row.PageUrl)"
}
$includedRowsByKey[$key] = $row
}
# Representative pages are already transformed and validated.
$representativeKeys = @{}
foreach ($row in $representativeResults) {
$representativeKeys[(Get-PageWaveKey -Row $row)] = $true
}
$pagesToTransform = [Collections.Generic.List[object]]::new()
foreach ($approvedRow in $pages) {
$key = Get-PageWaveKey -Row $approvedRow
if ($representativeKeys.ContainsKey($key)) {
continue
}
if (-not $includedRowsByKey.ContainsKey($key)) {
throw "Approved page isn't present in the included representative manifest: $($approvedRow.PageUrl)"
}
$manifestRow = $includedRowsByKey[$key]
if ((Get-PageWaveCandidateHash -Row $approvedRow) -ne
(Get-PageWaveCandidateHash -Row $manifestRow)) {
throw "Approved page doesn't match the reviewed manifest row: $($approvedRow.PageUrl)"
}
if (-not $passedPatterns.ContainsKey($manifestRow.PatternKey)) {
throw "Page pattern doesn't have a passed representative: $($approvedRow.PageUrl)"
}
# PatternKey is authoritative from the reviewed manifest.
$approvedRow.PatternKey = $manifestRow.PatternKey
$pagesToTransform.Add($approvedRow)
}
if ($pagesToTransform.Count -eq 0) {
throw "No additional pages remain after excluding validated representative pages."
}
# Keep preview and execution evidence separate and refuse implicit result replacement.
$pagesFolder = Split-Path -Parent $PagesPath
if ([string]::IsNullOrWhiteSpace($ResultPath)) {
$resultName = if ($WhatIfPreference) {
'selected-page-preview.csv'
}
elseif ($PreflightOnly) {
'selected-page-preflight.csv'
}
else {
'selected-page-results.csv'
}
$ResultPath = Join-Path $pagesFolder $resultName
}
if ([string]::IsNullOrWhiteSpace($LogFolder)) {
$LogFolder = Join-Path $pagesFolder 'selected-page-logs'
}
# Preserve the High-impact confirmation behavior of this entry script when the shared
# engine asks permission for each page.
$entryCmdlet = $PSCmdlet
$shouldProcess = {
param($target, $action)
$entryCmdlet.ShouldProcess($target, $action)
}.GetNewClosure()
Test-PageWaveAuthentication `
-AuthenticationMode $AuthenticationMode `
-Tenant $Tenant `
-Thumbprint $Thumbprint `
-CertificatePath $CertificatePath
Test-AssessmentPageWaveRows -Rows $pagesToTransform
# Persist each page result at the moment it finishes, rather than after the full wave.
$resultWriter = New-PageWaveResultWriter -Path $ResultPath -Force:$Force
$ResultPath = $resultWriter.Path
$results = Invoke-AssessmentPageWave `
-Rows $pagesToTransform `
-ClientId $ClientId `
-AuthenticationMode $AuthenticationMode `
-ShouldProcessCallback $shouldProcess `
-ResultWriter $resultWriter.Write `
-TransformationProfile $transformationProfile `
-IncludedManifestHash $includedManifestHash `
-Tenant $Tenant `
-Thumbprint $Thumbprint `
-CertificatePath $CertificatePath `
-CertificatePassword $CertificatePassword `
-AzureEnvironment $AzureEnvironment `
-LogFolder $LogFolder `
-PreflightOnly:$PreflightOnly
# Surface a failing process exit after every individual error has been written.
$failed = @($results | Where-Object TransformationStatus -eq 'Failed')
if ($failed.Count -gt 0) {
throw "$($failed.Count) selected page transformations failed. Review $ResultPath."
}
Write-Host "Selected page results: $ResultPath" -ForegroundColor Green