Hinweis
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, sich anzumelden oder das Verzeichnis zu wechseln.
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, das Verzeichnis zu wechseln.
Dieser Artikel enthält die Quelle für die Bewertungsgesteuerten Wiki- und Webpartseiten-Wellenskripts. Folgen Sie den Anweisungen unter Transform selected classic pages with PnP PowerShell (Ausgewählte klassische Seiten mit PnP PowerShell transformieren) für die geordnete Prozedur.
Voraussetzungen
- PowerShell 7.4.0 oder höher.
- Aktuelle PnP PowerShell.
- Eine überprüfte
representative-page-groups.csv, die von der primären Prozedur generiert wird. - Eine mandanteneigene interaktive Anwendung oder eine separat konfigurierte Nur-App-Zertifikatanwendung.
Speichern Sie alle drei eingebetteten Dateien im selben Ordner mit den genau angezeigten Dateinamen.
Skriptdateien
| File | Zweck |
|---|---|
PageTransformation.Common.ps1 |
Freigegebene Authentifizierung, Quellüberprüfung, Preflight, Transformation und Ergebnispersistenz. |
Convert-RepresentativePages.ps1 |
Preflights oder Transformiert jede ausgewählte repräsentative Seite. |
Convert-SelectedPages.ps1 |
Preflights oder Transformieren zusätzlicher genehmigter Seiten nach bestandener repräsentativer Überprüfung. |
Allgemeine Parameter
| Parameter | Zweck |
|---|---|
-ClientId |
Mandanteneigene PnP PowerShell-Anwendungs-ID. |
-AuthenticationMode |
Interactive, DeviceLogin, CertificateThumbprintoder CertificateFile. |
-Tenant |
Erforderlich für die Geräte- oder Zertifikatauthentifizierung. |
-AzureEnvironment |
Wählt die Microsoft 365-Cloudumgebung aus. |
-WhatIf |
Schreibt einen Plan ohne Authentifizierung; Die Zielexistenz bleibt NotCheckedbestehen. |
-PreflightOnly |
Authentifiziert und überprüft den Quell- und Zielplan ohne Konvertierung. |
-Confirm |
Fordert eine Bestätigung mit hoher Auswirkung für jede Seite an. Verwenden Sie -Confirm:$false nur für eine genehmigte unbeaufsichtigte Ausführung. |
-Force |
Ersetzt eine vorhandene lokale Vorschau-, Preflight- oder Ergebnis-CSV. Das Überschreiben des SharePoint-Ziels wird nie aktiviert. |
-ResultPath, -LogFolder |
Wählen Sie lokale Beweisspeicherorte aus. |
Convert-RepresentativePages.ps1 erfordert -ManifestPath.
Convert-SelectedPages.ps1 erfordert -PagesPath, -RepresentativeManifestPathund -RepresentativeResultsPath.
Ergebnisvertrag
Jeder Versuch wird sofort geschrieben. Wichtige Felder sind:
| Feld | Bedeutung |
|---|---|
ManifestRowHash, CandidateRowHash, IncludedManifestHash |
Binden Sie jedes Ergebnis, jede genehmigte Seite und den vollständigen enthaltenen Bereich an den überprüften Manifestinhalt. |
TransformationProfileHash und Skripthashes |
Binden Sie die repräsentative Überprüfung an die ausgeführten Skripts und die PnP-PowerShell-Version. |
PlannedAction, PlannedTargetPageUrl, TargetExists |
Behalten Sie den Vorschau- oder authentifizierten Preflightplan bei. |
TransformationStatus |
Skipped, PreflightPassed, Createdoder Failed. |
TargetPageUrl, LogPath |
Es wurde ein Entwurf und ein genaues PnP-Protokoll erstellt, sofern verfügbar. |
ValidationStatus, ValidationNotes, ValidatedBy, ValidatedAt |
Vor der Erweiterung sind manuelle Überprüfungsnachweise erforderlich. |
Unterstützte und ausgeschlossene Seiten
| Seitenbedingung | Batchskriptroute |
|---|---|
Wiki- oder Webpartseite in der Standardbibliothek SitePages |
Wird unterstützt, nachdem alle Manifest- und Preflightprüfungen bestanden wurden. |
| Startseite, nullteilige Seite, geänderte Quelle, eindeutige Berechtigungen oder vorhandenes Ziel | Ausgeschlossen; zur separaten Überprüfung zurückkehren. |
| Veröffentlichungs- oder Blogseite | Verwenden Sie ein separat überprüftes erweitertes Verfahren. |
| Benutzerdefinierte Webpartzuordnung oder websiteübergreifendes Ziel | Verwenden Sie ein separat überprüftes einzelseitiges oder erweitertes Verfahren. |
| SharePoint Server-Quelle oder Webstammseite | Verwenden Sie erweiterte websiteübergreifende Anleitungen. |
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