Observação
O acesso a essa página exige autorização. Você pode tentar entrar ou alterar diretórios.
O acesso a essa página exige autorização. Você pode tentar alterar os diretórios.
Este artigo contém a fonte para os scripts de onda de página Wiki e Web Part orientados por Avaliação. Siga Transformar páginas clássicas selecionadas com o PowerShell PnP para o procedimento ordenado.
Pré-requisitos
- PowerShell 7.4.0 ou posterior.
- PowerShell PnP atual.
- Uma revisão
representative-page-groups.csvgerada pelo procedimento primário. - Um aplicativo interativo de propriedade do locatário ou um aplicativo de certificado somente aplicativo configurado separadamente.
Salve todos os três arquivos inseridos na mesma pasta com os nomes de arquivo exatos mostrados.
Arquivos de script
| Arquivo | Objetivo |
|---|---|
PageTransformation.Common.ps1 |
Autenticação compartilhada, validação de origem, comprovação, transformação e persistência de resultado. |
Convert-RepresentativePages.ps1 |
Comprovar ou transformar cada página representativa selecionada. |
Convert-SelectedPages.ps1 |
Comprovar ou transformar páginas aprovadas adicionais após a validação do representante ser aprovada. |
Parâmetros comuns
| Parâmetro | Objetivo |
|---|---|
-ClientId |
ID do aplicativo PnP PowerShell de propriedade do locatário. |
-AuthenticationMode |
Interactive, DeviceLogin, CertificateThumbprint, ou CertificateFile. |
-Tenant |
Necessário para autenticação de dispositivo ou certificado. |
-AzureEnvironment |
Seleciona o ambiente de nuvem do Microsoft 365. |
-WhatIf |
Escreve um plano sem autenticar; existência de destino permanece NotChecked. |
-PreflightOnly |
Autentica e valida o plano de origem e destino sem converter. |
-Confirm |
Solicita confirmação de alto impacto para cada página. Use -Confirm:$false somente para uma corrida autônoma aprovada. |
-Force |
Substitui uma visualização local, uma comprovação ou um CSV de resultado existente. Ele nunca habilita a substituição de destino do SharePoint. |
-ResultPath, -LogFolder |
Selecione locais de evidência locais. |
Convert-RepresentativePages.ps1 requer -ManifestPath.
Convert-SelectedPages.ps1 requer -PagesPath, -RepresentativeManifestPathe -RepresentativeResultsPath.
Contrato de resultado
Cada tentativa é escrita imediatamente. Campos importantes incluem:
| Campo | Significado |
|---|---|
ManifestRowHash, CandidateRowHash, IncludedManifestHash |
Vincule cada resultado, página aprovada e o escopo completo incluído ao conteúdo do manifesto revisado. |
TransformationProfileHash e hashes de script |
Vincule a validação representativa aos scripts executados e à versão PnP do PowerShell. |
PlannedAction, PlannedTargetPageUrl, TargetExists |
Retenha a visualização ou o plano de simulação autenticado. |
TransformationStatus |
Skipped, PreflightPassed, Created, ou Failed. |
TargetPageUrl, LogPath |
Rascunho criado e log PnP exato quando disponível. |
ValidationStatus, ValidationNotes, ValidatedBy, ValidatedAt |
Evidência de validação manual necessária antes da expansão. |
Páginas com suporte e excluídas
| Condição da página | Rota de script em lote |
|---|---|
Página de Wiki ou Web Part na biblioteca padrão SitePages |
Compatível depois que todas as verificações de manifesto e comprovação forem aprovadas. |
| Página inicial, página de partes zero, origem modificada, permissões exclusivas ou destino existente | Excluído; Retornar para revisão separada. |
| Página de Publicação ou Blog | Use um procedimento avançado validado separadamente. |
| Mapeamento de Web Part personalizado ou destino entre sites | Use uma página única revisada separadamente ou procedimento avançado. |
| Página de origem ou raiz da Web do Servidor do SharePoint | Use orientação avançada entre sites. |
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